mirror of
https://github.com/DorkSquadRR/DorkNet.git
synced 2026-10-25 23:18:44 -07:00
Compare commits
27 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 5b7cded2a9 | |||
| 94781fd11a | |||
| 99f8c2e016 | |||
| d4ce92f1b4 | |||
| c7a087eea1 | |||
| 5bfa463528 | |||
| 891ec89899 | |||
| f0e4679887 | |||
| 260f6605fc | |||
| 12da61407d | |||
| 5b93af4ba9 | |||
| 51104464f0 | |||
| 35768c05ed | |||
| 0a5c3a9a64 | |||
| fd93b4b028 | |||
| 8be0f90dbc | |||
| 503d838fd1 | |||
| 9076fb9fae | |||
| cf7a021898 | |||
| e29707deeb | |||
| 3b332a6476 | |||
| d295fd5c37 | |||
| 8e12db660b | |||
| bafb380fc8 | |||
| 78c64d117a | |||
| bf0f09ce84 | |||
| fd2153d82b |
@@ -25,7 +25,7 @@ jobs:
|
||||
|
||||
- uses: actions/setup-dotnet@v4
|
||||
with:
|
||||
dotnet-version: 9.0.x
|
||||
dotnet-version: 10.0.x
|
||||
|
||||
- name: Install protobuf compiler
|
||||
run: |
|
||||
@@ -40,7 +40,7 @@ jobs:
|
||||
|
||||
- name: Start server
|
||||
run: |
|
||||
dotnet DorkNet.Server/bin/Debug/net9.0/DorkNet.Server.dll > "$RUNNER_TEMP/dorknet-server.log" 2>&1 &
|
||||
dotnet DorkNet.Server/bin/Debug/net10.0/DorkNet.Server.dll > "$RUNNER_TEMP/dorknet-server.log" 2>&1 &
|
||||
echo "$!" > "$RUNNER_TEMP/dorknet-server.pid"
|
||||
|
||||
- name: Wait for server readiness
|
||||
@@ -57,7 +57,7 @@ jobs:
|
||||
- name: Run endpoint contract
|
||||
run: |
|
||||
dotnet run --project tools/DorkNet.EndpointContract/DorkNet.EndpointContract.csproj --no-build -- \
|
||||
--server-assembly DorkNet.Server/bin/Debug/net9.0/DorkNet.Server.dll \
|
||||
--server-assembly DorkNet.Server/bin/Debug/net10.0/DorkNet.Server.dll \
|
||||
--base-url http://127.0.0.1:18080 \
|
||||
--report "$RUNNER_TEMP/endpoint-contract-report.json"
|
||||
|
||||
|
||||
@@ -26,7 +26,7 @@ jobs:
|
||||
|
||||
- uses: actions/setup-dotnet@v4
|
||||
with:
|
||||
dotnet-version: '9.0.x'
|
||||
dotnet-version: '10.0.x'
|
||||
|
||||
# ── Server self-contained publish ────────────────────────────────
|
||||
- name: Publish DorkNet.Server (win-x64, self-contained)
|
||||
|
||||
+2
-2
@@ -12,8 +12,8 @@ dist/
|
||||
# Local SQLite + uploaded blobs only — DON'T match the source-tree
|
||||
# DorkNet.Server/Data/ folder (which holds DbContext + Entities/) on
|
||||
# case-insensitive filesystems. Use the precise runtime data dirs.
|
||||
DorkNet.Server/bin/Debug/net9.0/data/
|
||||
DorkNet.Server/bin/Release/net9.0/data/
|
||||
DorkNet.Server/bin/Debug/net10.0/data/
|
||||
DorkNet.Server/bin/Release/net10.0/data/
|
||||
# appsettings.Production.json is intentionally versioned — contains
|
||||
# only the Postgres provider switch + Kestrel bind address. All real
|
||||
# secrets come from Coolify env vars at runtime.
|
||||
|
||||
+4
-4
@@ -1,14 +1,14 @@
|
||||
# DorkNet.Server container image — Coolify deploy target.
|
||||
# ────────────────────────────────────────────────────────────────────
|
||||
# Multi-stage: SDK image builds + publishes, runtime image is the
|
||||
# slimmer aspnet:9.0 with only the published output.
|
||||
# slimmer aspnet:10.0 with only the published output.
|
||||
#
|
||||
# Build context: repo root (so Tools/ and DorkNet.Models/ are visible
|
||||
# to ProjectReference + dotnet restore). The .dockerignore at the
|
||||
# repo root keeps the bin/, obj/, and other dev junk out of the
|
||||
# context.
|
||||
|
||||
ARG DOTNET_VERSION=9.0
|
||||
ARG DOTNET_VERSION=10.0
|
||||
ARG NODE_VERSION=22
|
||||
|
||||
# ── admin SPA build stage ──────────────────────────────────────────
|
||||
@@ -76,13 +76,13 @@ WORKDIR /app
|
||||
|
||||
# curl + wget for healthcheck probes — Coolify v4 defaults to wget for
|
||||
# its own probe regardless of what the image's HEALTHCHECK uses, so
|
||||
# both have to be present. The aspnet:9.0 base image strips both by
|
||||
# both have to be present. The aspnet:10.0 base image strips both by
|
||||
# default to keep the surface area small.
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends curl wget \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Run as a non-root user — aspnet:9.0 ships with the `app` UID 1654.
|
||||
# Run as a non-root user — aspnet:10.0 ships with the `app` UID 1654.
|
||||
# Coolify mounts persistent volumes with write permission for this UID.
|
||||
USER app
|
||||
|
||||
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
ARG DOTNET_VERSION=9.0
|
||||
ARG DOTNET_VERSION=10.0
|
||||
ARG NODE_VERSION=22
|
||||
|
||||
FROM node:${NODE_VERSION}-alpine AS admin-ui
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net9.0</TargetFramework>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
</PropertyGroup>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk.Web">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net9.0</TargetFramework>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
</PropertyGroup>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net9.0</TargetFramework>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
</PropertyGroup>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net9.0</TargetFramework>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<IsPackable>false</IsPackable>
|
||||
@@ -9,7 +9,7 @@
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="coverlet.collector" Version="6.0.2" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.Mvc.Testing" Version="9.0.4" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.Mvc.Testing" Version="10.0.13" />
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.12.0" />
|
||||
<PackageReference Include="xunit" Version="2.9.2" />
|
||||
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2" />
|
||||
|
||||
@@ -0,0 +1,264 @@
|
||||
using System.Net;
|
||||
using System.Net.Http.Json;
|
||||
using System.Security.Claims;
|
||||
using System.Text.Json;
|
||||
using DorkNet.Server.Data;
|
||||
using DorkNet.Server.Data.Entities;
|
||||
using DorkNet.Server.Services;
|
||||
using Microsoft.AspNetCore.Authentication;
|
||||
using Microsoft.AspNetCore.Authentication.Cookies;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace DorkNet.Server.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Website / admin-panel sign-in with an external OpenID Connect provider.
|
||||
/// The provider round-trip can't run in-process, so each test starts where the
|
||||
/// provider callback leaves off: a <c>DorkNet.External</c> cookie holding the
|
||||
/// provider identity, encrypted with the app's own cookie format. Everything
|
||||
/// from there (link, session, admin SSO, unlink, limits) is the real code.
|
||||
/// </summary>
|
||||
public sealed class ExternalSignInTests : IClassFixture<DorkNetServerFactory>
|
||||
{
|
||||
private const string Issuer = "https://idp.example.com";
|
||||
// Test player ids: sequential, in a range no other test uses.
|
||||
private static long _nextPlayerId = 9_600_000;
|
||||
private readonly DorkNetServerFactory _factory;
|
||||
|
||||
public ExternalSignInTests(DorkNetServerFactory factory) => _factory = factory;
|
||||
|
||||
[Fact]
|
||||
public async Task Linking_with_the_account_password_signs_in_and_the_next_sign_in_needs_no_password()
|
||||
{
|
||||
var (player, password) = await PlayerAsync();
|
||||
var subject = Guid.NewGuid().ToString("N");
|
||||
|
||||
using var site = Client();
|
||||
var link = await PostJsonAsync(site, "/api/site/v1/account/link",
|
||||
new { username = player.Username, password }, ExternalCookie(subject, "player@example.com"));
|
||||
Assert.Equal(HttpStatusCode.OK, link.StatusCode);
|
||||
var session = SessionCookie(link);
|
||||
Assert.NotNull(session);
|
||||
|
||||
var me = await GetJsonAsync(site, "/api/site/v1/account/me", session);
|
||||
Assert.True(me.GetProperty("signedIn").GetBoolean());
|
||||
Assert.Equal(player.Username, me.GetProperty("player").GetProperty("username").GetString());
|
||||
Assert.Equal("player@example.com", me.GetProperty("player").GetProperty("email").GetString());
|
||||
|
||||
// Next time the provider identity alone is enough.
|
||||
await using var scope = _factory.Services.CreateAsyncScope();
|
||||
var signIn = scope.ServiceProvider.GetRequiredService<ExternalSignInService>();
|
||||
var stored = await signIn.FindAsync(Issuer, subject);
|
||||
Assert.Equal(player.Id, stored?.PlayerId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Wrong_passwords_are_refused_then_rate_limited()
|
||||
{
|
||||
var (player, _) = await PlayerAsync();
|
||||
var external = ExternalCookie(Guid.NewGuid().ToString("N"), "guess@example.com");
|
||||
using var site = Client();
|
||||
for (var i = 0; i < 5; i++)
|
||||
{
|
||||
using var r = await PostJsonAsync(site, "/api/site/v1/account/link",
|
||||
new { username = player.Username, password = "wrong-" + i }, external);
|
||||
Assert.Equal(HttpStatusCode.Unauthorized, r.StatusCode);
|
||||
}
|
||||
using var blocked = await PostJsonAsync(site, "/api/site/v1/account/link",
|
||||
new { username = player.Username, password = "still-wrong" }, external);
|
||||
Assert.Equal(HttpStatusCode.TooManyRequests, blocked.StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task An_identity_linked_to_one_account_cannot_be_linked_to_another()
|
||||
{
|
||||
var (first, firstPassword) = await PlayerAsync();
|
||||
var (second, secondPassword) = await PlayerAsync();
|
||||
var subject = Guid.NewGuid().ToString("N");
|
||||
using var site = Client();
|
||||
|
||||
using var ok = await PostJsonAsync(site, "/api/site/v1/account/link",
|
||||
new { username = first.Username, password = firstPassword }, ExternalCookie(subject, "a@example.com"));
|
||||
Assert.Equal(HttpStatusCode.OK, ok.StatusCode);
|
||||
using var refused = await PostJsonAsync(site, "/api/site/v1/account/link",
|
||||
new { username = second.Username, password = secondPassword }, ExternalCookie(subject, "a@example.com"));
|
||||
Assert.Equal(HttpStatusCode.Conflict, refused.StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Admin_sso_issues_tokens_only_to_admins()
|
||||
{
|
||||
var (admin, adminPassword) = await PlayerAsync(isAdmin: true);
|
||||
var (regular, regularPassword) = await PlayerAsync();
|
||||
using var admins = Client("admin");
|
||||
|
||||
var adminSession = SessionCookie(await PostJsonAsync(admins, "/api/site/v1/account/link",
|
||||
new { username = admin.Username, password = adminPassword }, ExternalCookie(Guid.NewGuid().ToString("N"), "")));
|
||||
using var tokens = await SendAsync(admins, HttpMethod.Get, "/api/admin/v1/sso", adminSession);
|
||||
Assert.Equal(HttpStatusCode.OK, tokens.StatusCode);
|
||||
var body = JsonDocument.Parse(await tokens.Content.ReadAsStringAsync()).RootElement;
|
||||
Assert.False(string.IsNullOrEmpty(body.GetProperty("access_token").GetString()));
|
||||
Assert.Equal(admin.Id, body.GetProperty("account_id").GetInt64());
|
||||
|
||||
var regularSession = SessionCookie(await PostJsonAsync(admins, "/api/site/v1/account/link",
|
||||
new { username = regular.Username, password = regularPassword }, ExternalCookie(Guid.NewGuid().ToString("N"), "")));
|
||||
using var refused = await SendAsync(admins, HttpMethod.Get, "/api/admin/v1/sso", regularSession);
|
||||
Assert.Equal(HttpStatusCode.Forbidden, refused.StatusCode);
|
||||
|
||||
using var anonymous = await SendAsync(admins, HttpMethod.Get, "/api/admin/v1/sso", null);
|
||||
Assert.Equal(HttpStatusCode.Unauthorized, anonymous.StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Unlinking_ends_the_session()
|
||||
{
|
||||
var (player, password) = await PlayerAsync();
|
||||
using var site = Client();
|
||||
var session = SessionCookie(await PostJsonAsync(site, "/api/site/v1/account/link",
|
||||
new { username = player.Username, password }, ExternalCookie(Guid.NewGuid().ToString("N"), "")));
|
||||
|
||||
using var unlink = await SendAsync(site, HttpMethod.Post, "/api/site/v1/account/unlink", session);
|
||||
Assert.Equal(HttpStatusCode.OK, unlink.StatusCode);
|
||||
|
||||
// The old cookie is still in the browser, but the link behind it is gone.
|
||||
var me = await GetJsonAsync(site, "/api/site/v1/account/me", session);
|
||||
Assert.False(me.GetProperty("signedIn").GetBoolean());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_banned_player_cannot_link_and_loses_an_existing_session()
|
||||
{
|
||||
var (player, password) = await PlayerAsync();
|
||||
using var site = Client();
|
||||
var session = SessionCookie(await PostJsonAsync(site, "/api/site/v1/account/link",
|
||||
new { username = player.Username, password }, ExternalCookie(Guid.NewGuid().ToString("N"), "")));
|
||||
|
||||
await using (var scope = _factory.Services.CreateAsyncScope())
|
||||
{
|
||||
var db = scope.ServiceProvider.GetRequiredService<DorkNetDbContext>();
|
||||
await db.Players.Where(p => p.Id == player.Id)
|
||||
.ExecuteUpdateAsync(s => s.SetProperty(p => p.BannedUntil, DateTime.UtcNow.AddDays(1)));
|
||||
}
|
||||
|
||||
var me = await GetJsonAsync(site, "/api/site/v1/account/me", session);
|
||||
Assert.False(me.GetProperty("signedIn").GetBoolean());
|
||||
using var relink = await PostJsonAsync(site, "/api/site/v1/account/link",
|
||||
new { username = player.Username, password }, ExternalCookie(Guid.NewGuid().ToString("N"), ""));
|
||||
Assert.Equal(HttpStatusCode.Forbidden, relink.StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Cookie_keys_are_stored_encrypted()
|
||||
{
|
||||
var (player, password) = await PlayerAsync();
|
||||
using var site = Client();
|
||||
using var link = await PostJsonAsync(site, "/api/site/v1/account/link",
|
||||
new { username = player.Username, password }, ExternalCookie(Guid.NewGuid().ToString("N"), ""));
|
||||
Assert.Equal(HttpStatusCode.OK, link.StatusCode);
|
||||
|
||||
await using var scope = _factory.Services.CreateAsyncScope();
|
||||
var db = scope.ServiceProvider.GetRequiredService<DorkNetDbContext>();
|
||||
var stored = await db.DataProtectionKeys.Select(k => k.Xml).ToListAsync();
|
||||
Assert.NotEmpty(stored);
|
||||
Assert.All(stored, xml =>
|
||||
{
|
||||
Assert.Contains("dorknetEncryptedKey", xml);
|
||||
Assert.DoesNotContain("masterKey", xml);
|
||||
});
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Key_encryption_round_trips_and_rejects_another_secret()
|
||||
{
|
||||
var element = new System.Xml.Linq.XElement("key", new System.Xml.Linq.XElement("masterKey", "secret-bytes"));
|
||||
var encrypted = new DataProtectionKeyEncryption("first-secret").Encrypt(element).EncryptedElement;
|
||||
Assert.DoesNotContain("secret-bytes", encrypted.ToString());
|
||||
Assert.Equal(element.ToString(), new DataProtectionKeyEncryption("first-secret").DecryptElement(encrypted).ToString());
|
||||
Assert.ThrowsAny<System.Security.Cryptography.CryptographicException>(
|
||||
() => new DataProtectionKeyEncryption("other-secret").DecryptElement(encrypted));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Downloads_can_require_sign_in()
|
||||
{
|
||||
await using var factory = _factory.WithWebHostBuilder(b =>
|
||||
{
|
||||
b.UseSetting("DorkNet:Downloads:BaseUrl", "http://127.0.0.1:9");
|
||||
b.UseSetting("DorkNet:Downloads:RequireSignIn", "true");
|
||||
});
|
||||
using var site = factory.CreateClient(new() { BaseAddress = new Uri($"http://{_factory.ApexDomain}") });
|
||||
using var manifest = await site.GetAsync("/api/site/v1/downloads");
|
||||
Assert.Equal(HttpStatusCode.Unauthorized, manifest.StatusCode);
|
||||
using var file = await site.GetAsync("/api/site/v1/downloads/files/2023.03/DorkNet-2023.03-Quest.xapk");
|
||||
Assert.Equal(HttpStatusCode.Unauthorized, file.StatusCode);
|
||||
}
|
||||
|
||||
// ── helpers ────────────────────────────────────────────────────────────
|
||||
|
||||
private async Task<(PlayerEntity Player, string Password)> PlayerAsync(bool isAdmin = false)
|
||||
{
|
||||
var password = "pw-" + Guid.NewGuid().ToString("N")[..12];
|
||||
var player = new PlayerEntity
|
||||
{
|
||||
Id = Interlocked.Increment(ref _nextPlayerId),
|
||||
Username = "sso" + Guid.NewGuid().ToString("N")[..10],
|
||||
DisplayName = "SSO test",
|
||||
PasswordHash = BCrypt.Net.BCrypt.HashPassword(password),
|
||||
IsAdmin = isAdmin,
|
||||
};
|
||||
await using var scope = _factory.Services.CreateAsyncScope();
|
||||
var db = scope.ServiceProvider.GetRequiredService<DorkNetDbContext>();
|
||||
db.Players.Add(player);
|
||||
await db.SaveChangesAsync();
|
||||
return (player, password);
|
||||
}
|
||||
|
||||
/// <summary>The cookie the provider callback leaves behind: the provider
|
||||
/// identity in the DorkNet.External scheme, in the app's cookie format.</summary>
|
||||
private string ExternalCookie(string subject, string email)
|
||||
{
|
||||
var options = _factory.Services.GetRequiredService<IOptionsMonitor<CookieAuthenticationOptions>>()
|
||||
.Get(ExternalSignIn.ExternalScheme);
|
||||
var identity = new ClaimsIdentity(new[]
|
||||
{
|
||||
new Claim("iss", Issuer), new Claim("sub", subject), new Claim("email", email),
|
||||
}, "test");
|
||||
var ticket = new AuthenticationTicket(new ClaimsPrincipal(identity), ExternalSignIn.ExternalScheme);
|
||||
return $"{options.Cookie.Name}={options.TicketDataFormat.Protect(ticket)}";
|
||||
}
|
||||
|
||||
private static string? SessionCookie(HttpResponseMessage response) =>
|
||||
response.Headers.TryGetValues("Set-Cookie", out var values)
|
||||
? values.Select(v => v.Split(';')[0]).FirstOrDefault(v => v.StartsWith("dorknet_session=") && v.Length > "dorknet_session=".Length)
|
||||
: null;
|
||||
|
||||
private HttpClient Client(string? subdomain = null)
|
||||
{
|
||||
var host = subdomain is null ? _factory.ApexDomain : $"{subdomain}.{_factory.ApexDomain}";
|
||||
return _factory.CreateClient(new() { BaseAddress = new Uri($"http://{host}"), HandleCookies = false });
|
||||
}
|
||||
|
||||
private static Task<HttpResponseMessage> SendAsync(HttpClient client, HttpMethod method, string path, string? cookie)
|
||||
{
|
||||
var request = new HttpRequestMessage(method, path);
|
||||
if (cookie is not null) request.Headers.Add("Cookie", cookie);
|
||||
return client.SendAsync(request);
|
||||
}
|
||||
|
||||
private static Task<HttpResponseMessage> PostJsonAsync(HttpClient client, string path, object body, string? cookie)
|
||||
{
|
||||
var request = new HttpRequestMessage(HttpMethod.Post, path) { Content = JsonContent.Create(body) };
|
||||
if (cookie is not null) request.Headers.Add("Cookie", cookie);
|
||||
return client.SendAsync(request);
|
||||
}
|
||||
|
||||
private static async Task<JsonElement> GetJsonAsync(HttpClient client, string path, string? cookie)
|
||||
{
|
||||
using var response = await SendAsync(client, HttpMethod.Get, path, cookie);
|
||||
var body = await response.Content.ReadAsStringAsync();
|
||||
Assert.True(response.IsSuccessStatusCode, $"GET {path} -> {(int)response.StatusCode}: {body}");
|
||||
return JsonDocument.Parse(body).RootElement.Clone();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
using System.Net.Http.Headers;
|
||||
using System.Text.Json;
|
||||
using DorkNet.Server.Data;
|
||||
using DorkNet.Server.Data.Entities;
|
||||
using DorkNet.Server.Services;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
|
||||
namespace DorkNet.Server.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// "Publish room" in the 2023 client: RoomModel.TryPublishRoom →
|
||||
/// <c>PUT roomserver/rooms/{id}/accessibility</c> with form
|
||||
/// <c>accessibility=1</c> (boxed Int32, NLDBPDCNNCF_NestedType_OMHIIEAKHCH),
|
||||
/// typed <c>Task<FGCPNAACHIK></c>. The button only exists when
|
||||
/// get_CanPublishRoom holds: role ≥ CoOwner, not a dorm, <b>not IsRRO</b>,
|
||||
/// not already public. A clone of a Rec Room Original template used to be
|
||||
/// advertised as IsRRO and so could never be published.
|
||||
/// </summary>
|
||||
public sealed class RoomPublish2023Tests : IClassFixture<DorkNetServerFactory>
|
||||
{
|
||||
private readonly DorkNetServerFactory _factory;
|
||||
|
||||
public RoomPublish2023Tests(DorkNetServerFactory factory) => _factory = factory;
|
||||
|
||||
[Fact]
|
||||
public void Only_system_owned_originals_are_RRO_on_the_wire()
|
||||
{
|
||||
Assert.True(RoomService.WireIsRRO(new RoomEntity { IsAGRoom = true, CreatorPlayerId = PlayerService.SystemAccountId }));
|
||||
Assert.False(RoomService.WireIsRRO(new RoomEntity { IsAGRoom = true, CreatorPlayerId = 4242 }));
|
||||
Assert.False(RoomService.WireIsRRO(new RoomEntity { IsAGRoom = false, CreatorPlayerId = PlayerService.SystemAccountId }));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Clone_of_an_original_is_not_RRO_and_can_be_published_by_owner_and_co_owner()
|
||||
{
|
||||
using var setup = Client("api");
|
||||
var owner = await GameClientSessionFactory.CreateAsync(setup, _factory.ApexDomain);
|
||||
var coOwner = await GameClientSessionFactory.CreateAsync(setup, _factory.ApexDomain);
|
||||
var moderator = await GameClientSessionFactory.CreateAsync(setup, _factory.ApexDomain);
|
||||
|
||||
var templateId = 9_100_000 + Random.Shared.Next(1, 99_999);
|
||||
await using (var scope = _factory.Services.CreateAsyncScope())
|
||||
{
|
||||
var db = scope.ServiceProvider.GetRequiredService<DorkNetDbContext>();
|
||||
db.Rooms.Add(new RoomEntity
|
||||
{
|
||||
Id = templateId,
|
||||
Name = $"Tmpl{Guid.NewGuid():N}"[..18],
|
||||
CreatorPlayerId = PlayerService.SystemAccountId,
|
||||
IsAGRoom = true,
|
||||
Accessibility = 1,
|
||||
CloningAllowed = false,
|
||||
ImageName = "image_RecCenter.png",
|
||||
LocationReplicationId = "a75f7547-79eb-47c6-8986-6767abcb4f92",
|
||||
});
|
||||
db.RoomScenes.Add(new RoomSceneEntity { RoomId = templateId, OrderIndex = 0, Name = "Home" });
|
||||
await db.SaveChangesAsync();
|
||||
}
|
||||
|
||||
using var ownerRooms = Client("rooms", owner);
|
||||
var template = await GetJsonAsync(ownerRooms, $"/roomserver/rooms/{templateId}");
|
||||
Assert.True(template.GetProperty("IsRRO").GetBoolean());
|
||||
|
||||
using var cloneResp = await ownerRooms.PostAsync($"/roomserver/rooms/{templateId}/clone",
|
||||
new FormUrlEncodedContent(new Dictionary<string, string> { ["name"] = $"Pub{Guid.NewGuid():N}"[..20] }));
|
||||
var cloneText = await cloneResp.Content.ReadAsStringAsync();
|
||||
Assert.True(cloneResp.IsSuccessStatusCode, cloneText);
|
||||
var clone = JsonDocument.Parse(cloneText).RootElement.GetProperty("value").Clone();
|
||||
var cloneId = clone.GetProperty("RoomId").GetInt64();
|
||||
Assert.False(clone.GetProperty("IsRRO").GetBoolean());
|
||||
Assert.Equal(0, clone.GetProperty("Accessibility").GetInt32());
|
||||
|
||||
await using (var scope = _factory.Services.CreateAsyncScope())
|
||||
{
|
||||
var db = scope.ServiceProvider.GetRequiredService<DorkNetDbContext>();
|
||||
db.RoomRoles.Add(new RoomRoleEntity { RoomId = cloneId, PlayerId = coOwner.PlayerId, Role = RoomRoleRules.StoredCoOwner, Accepted = true });
|
||||
db.RoomRoles.Add(new RoomRoleEntity { RoomId = cloneId, PlayerId = moderator.PlayerId, Role = RoomRoleRules.StoredModerator, Accepted = true });
|
||||
await db.SaveChangesAsync();
|
||||
}
|
||||
|
||||
// Publish as the owner: full room details back, now public, still not RRO.
|
||||
var published = await PutAccessibilityAsync(ownerRooms, cloneId, "1");
|
||||
Assert.Equal(1, published.GetProperty("Accessibility").GetInt32());
|
||||
Assert.False(published.GetProperty("IsRRO").GetBoolean());
|
||||
Assert.True(published.TryGetProperty("SubRooms", out _));
|
||||
|
||||
// A moderator may not change accessibility; a co-owner may.
|
||||
using var modRooms = Client("rooms", moderator);
|
||||
using (var denied = await modRooms.PutAsync($"/roomserver/rooms/{cloneId}/accessibility",
|
||||
new FormUrlEncodedContent(new Dictionary<string, string> { ["accessibility"] = "0" })))
|
||||
{
|
||||
// Refusals are 200 {success:false, error} so the client can show them.
|
||||
using var doc = JsonDocument.Parse(await denied.Content.ReadAsStringAsync());
|
||||
Assert.False(doc.RootElement.GetProperty("success").GetBoolean());
|
||||
}
|
||||
using var coRooms = Client("rooms", coOwner);
|
||||
var unpublished = await PutAccessibilityAsync(coRooms, cloneId, "0");
|
||||
Assert.Equal(0, unpublished.GetProperty("Accessibility").GetInt32());
|
||||
}
|
||||
|
||||
private static async Task<JsonElement> PutAccessibilityAsync(HttpClient client, long roomId, string value)
|
||||
{
|
||||
using var response = await client.PutAsync($"/roomserver/rooms/{roomId}/accessibility",
|
||||
new FormUrlEncodedContent(new Dictionary<string, string> { ["accessibility"] = value }));
|
||||
var body = await response.Content.ReadAsStringAsync();
|
||||
Assert.True(response.IsSuccessStatusCode, $"{(int)response.StatusCode}: {body}");
|
||||
// "change room accessibility" goes through the {success, value, error}
|
||||
// helper (NLDBPDCNNCF.BDBHCEGKGDC<FGCPNAACHIK>), same as clone.
|
||||
using var doc = JsonDocument.Parse(body);
|
||||
Assert.True(doc.RootElement.GetProperty("success").GetBoolean(), body);
|
||||
return doc.RootElement.GetProperty("value").Clone();
|
||||
}
|
||||
|
||||
private static async Task<JsonElement> GetJsonAsync(HttpClient client, string path)
|
||||
{
|
||||
using var response = await client.GetAsync(path);
|
||||
var body = await response.Content.ReadAsStringAsync();
|
||||
Assert.True(response.IsSuccessStatusCode, $"GET {path} -> {(int)response.StatusCode}: {body}");
|
||||
return JsonDocument.Parse(body).RootElement.Clone();
|
||||
}
|
||||
|
||||
private HttpClient Client(string subdomain, GameClientSession? session = null)
|
||||
{
|
||||
var client = _factory.CreateClient(new() { AllowAutoRedirect = false });
|
||||
client.BaseAddress = new Uri($"http://{subdomain}.{_factory.ApexDomain}");
|
||||
client.Timeout = TimeSpan.FromSeconds(30);
|
||||
client.DefaultRequestHeaders.Accept.ParseAdd("application/json");
|
||||
client.DefaultRequestHeaders.UserAgent.ParseAdd("RecRoom/2023.03.21");
|
||||
if (session is not null)
|
||||
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", session.AccessToken);
|
||||
return client;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,528 @@
|
||||
using System.Net;
|
||||
using System.Net.Http.Headers;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using DorkNet.Server.Data;
|
||||
using DorkNet.Server.Data.Entities;
|
||||
using DorkNet.Server.Services;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
|
||||
namespace DorkNet.Server.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// 2023 room roles, the co-owner invite flow and the sub-room role-permission
|
||||
/// matrix, against the client contract:
|
||||
/// <list type="bullet">
|
||||
/// <item>Role enum OMMBGJMJJPN: None 0, Host 10, Moderator 20, CoOwner 30,
|
||||
/// TemporaryCoOwner 31, Creator 255. The creator must be listed as 255 —
|
||||
/// the client takes the local role from the Roles list and only lets a
|
||||
/// player edit roles strictly below their own, Creator excepted
|
||||
/// (OMJKHJLFOCO.DIIMLMKEJLC).</item>
|
||||
/// <item>Invite: owner <c>PUT rooms/{id}/roles/{target}/invite</c> role=30;
|
||||
/// invitee gets message Type 62 with RoomId; accepts with
|
||||
/// <c>PUT rooms/{id}/roles/{me}</c> role=30, rejects with
|
||||
/// <c>PUT rooms/{id}/roles/{me}/invite</c> role=0.</item>
|
||||
/// <item>Permissions: <c>PUT rooms/{id}/subrooms/{sub}/permissions</c> raw
|
||||
/// JSON array of {Permission, Role, Override, Type, Value}; read back from
|
||||
/// <c>GET photon_access_token</c>.</item>
|
||||
/// </list>
|
||||
/// </summary>
|
||||
public sealed class RoomRoles2023Tests : IClassFixture<DorkNetServerFactory>
|
||||
{
|
||||
private readonly DorkNetServerFactory _factory;
|
||||
|
||||
public RoomRoles2023Tests(DorkNetServerFactory factory) => _factory = factory;
|
||||
|
||||
// ── pure rules ──────────────────────────────────────────────────────
|
||||
|
||||
private static RoomEntity Room(long creator) => new() { Id = 42, CreatorPlayerId = creator };
|
||||
|
||||
private static RoomRoleEntity Row(long player, int stored, bool accepted = true) =>
|
||||
new() { RoomId = 42, PlayerId = player, Role = stored, Accepted = accepted };
|
||||
|
||||
[Fact]
|
||||
public void Creator_is_255_and_listed_once()
|
||||
{
|
||||
var room = Room(creator: 7);
|
||||
var wires = RoomRoleRules.BuildWires(room, new[]
|
||||
{
|
||||
Row(7, RoomRoleRules.StoredCoOwner), // a stray row for the creator
|
||||
Row(8, RoomRoleRules.StoredModerator),
|
||||
Row(9, RoomRoleRules.StoredCoOwner, accepted: false),
|
||||
});
|
||||
|
||||
Assert.Single(wires, w => w.AccountId == 7);
|
||||
Assert.Equal(255, wires.Single(w => w.AccountId == 7).Role);
|
||||
Assert.Equal(20, wires.Single(w => w.AccountId == 8).Role);
|
||||
var invited = wires.Single(w => w.AccountId == 9);
|
||||
Assert.Equal(0, invited.Role);
|
||||
Assert.Equal(30, invited.InvitedRole);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Creator_may_edit_every_role_including_co_owner_and_creator_permissions()
|
||||
{
|
||||
var room = Room(creator: 7);
|
||||
var rows = Array.Empty<RoomRoleEntity>();
|
||||
foreach (var role in new[] { 0, 10, 20, 30, 31, 255 })
|
||||
Assert.True(RoomRoleRules.CanEditRolePermissions(room, rows, 7, role), $"creator role {role}");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Co_owner_may_edit_only_roles_below_their_own()
|
||||
{
|
||||
var room = Room(creator: 7);
|
||||
var rows = new[] { Row(8, RoomRoleRules.StoredCoOwner), Row(9, RoomRoleRules.StoredModerator) };
|
||||
|
||||
Assert.True(RoomRoleRules.CanEditRolePermissions(room, rows, 8, 20));
|
||||
Assert.True(RoomRoleRules.CanEditRolePermissions(room, rows, 8, 0));
|
||||
Assert.False(RoomRoleRules.CanEditRolePermissions(room, rows, 8, 30));
|
||||
Assert.False(RoomRoleRules.CanEditRolePermissions(room, rows, 8, 31));
|
||||
Assert.False(RoomRoleRules.CanEditRolePermissions(room, rows, 8, 255));
|
||||
// A moderator edits nothing.
|
||||
Assert.False(RoomRoleRules.CanEditRolePermissions(room, rows, 9, 0));
|
||||
// Nor does a stranger.
|
||||
Assert.False(RoomRoleRules.CanEditRolePermissions(room, rows, 99, 0));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Role_changes_follow_the_ladder()
|
||||
{
|
||||
var room = Room(creator: 7);
|
||||
var rows = new List<RoomRoleEntity>
|
||||
{
|
||||
Row(8, RoomRoleRules.StoredCoOwner),
|
||||
Row(9, RoomRoleRules.StoredModerator),
|
||||
Row(10, RoomRoleRules.StoredCoOwner, accepted: false),
|
||||
};
|
||||
|
||||
// Creator: anything on anyone but themselves.
|
||||
Assert.Null(RoomRoleRules.CheckChange(room, rows, 7, 8, 0, false, out var k));
|
||||
Assert.Equal(RoomRoleRules.ChangeKind.Clear, k);
|
||||
Assert.Null(RoomRoleRules.CheckChange(room, rows, 7, 11, 30, true, out k));
|
||||
Assert.Equal(RoomRoleRules.ChangeKind.Invite, k);
|
||||
Assert.Equal("cannot_change_creator_role", RoomRoleRules.CheckChange(room, rows, 7, 7, 0, false, out _));
|
||||
Assert.Equal("cannot_change_creator_role", RoomRoleRules.CheckChange(room, rows, 8, 7, 0, false, out _));
|
||||
|
||||
// Co-owner: only below 30, on targets below 30.
|
||||
Assert.Null(RoomRoleRules.CheckChange(room, rows, 8, 11, 20, false, out _));
|
||||
Assert.Null(RoomRoleRules.CheckChange(room, rows, 8, 9, 10, false, out _));
|
||||
Assert.Equal("forbidden", RoomRoleRules.CheckChange(room, rows, 8, 11, 30, true, out _));
|
||||
Assert.Equal("forbidden", RoomRoleRules.CheckChange(room, rows, 8, 10, 0, true, out _)); // invited co-owner
|
||||
// …and cannot remove a peer co-owner.
|
||||
Assert.Equal("forbidden", RoomRoleRules.CheckChange(
|
||||
room, new List<RoomRoleEntity> { Row(8, 0), Row(12, 0) }, 8, 12, 0, false, out _));
|
||||
|
||||
// Moderator and strangers manage nobody.
|
||||
Assert.Equal("forbidden", RoomRoleRules.CheckChange(room, rows, 9, 11, 10, false, out _));
|
||||
Assert.Equal("forbidden", RoomRoleRules.CheckChange(room, rows, 99, 11, 10, false, out _));
|
||||
|
||||
// Self-service: accept exactly the invited role, decline, leave.
|
||||
Assert.Null(RoomRoleRules.CheckChange(room, rows, 10, 10, 30, false, out k));
|
||||
Assert.Equal(RoomRoleRules.ChangeKind.AcceptInvite, k);
|
||||
Assert.Null(RoomRoleRules.CheckChange(room, rows, 10, 10, 0, true, out k));
|
||||
Assert.Equal(RoomRoleRules.ChangeKind.Clear, k);
|
||||
Assert.Null(RoomRoleRules.CheckChange(room, rows, 9, 9, 0, false, out _));
|
||||
Assert.Equal("forbidden", RoomRoleRules.CheckChange(room, rows, 9, 9, 30, false, out _)); // no invite
|
||||
Assert.Equal("forbidden", RoomRoleRules.CheckChange(room, rows, 10, 10, 20, false, out _)); // not the invited role
|
||||
|
||||
Assert.Equal("invalid_role", RoomRoleRules.CheckChange(room, rows, 7, 11, 255, false, out _));
|
||||
Assert.Equal("invalid_role", RoomRoleRules.CheckChange(room, rows, 7, 11, 31, false, out _));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Permission_matrix_merges_stored_cells_over_defaults()
|
||||
{
|
||||
var merged = RoomPermissionMatrix.Merge(new[]
|
||||
{
|
||||
new RoomRolePermissionEntity { Id = 1, Role = 30, Permission = "13", Type = 0, Override = true, Value = "false" },
|
||||
new RoomRolePermissionEntity { Id = 2, Role = 20, Permission = "13", Type = 0, Override = true, Value = "true" },
|
||||
});
|
||||
|
||||
Assert.Equal("false", merged.Single(c => c.Role == 30 && c.Permission == "13").Value);
|
||||
Assert.Equal("true", merged.Single(c => c.Role == 20 && c.Permission == "13").Value);
|
||||
Assert.Equal("true", merged.Single(c => c.Role == 255 && c.Permission == "13").Value);
|
||||
Assert.Equal(RoomPermissionMatrix.Defaults.Count + 1, merged.Count);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Permission_body_accepts_numbers_strings_and_enum_names()
|
||||
{
|
||||
var cells = RoomPermissionMatrix.Parse(
|
||||
"""[{"Permission":"13","Role":30,"Override":true,"Type":0,"Value":"false"},""" +
|
||||
"""{"permission":"CAN_USE_MAKER_PEN","role":"Moderator","override":"true","type":"Bool","value":true},""" +
|
||||
"""{"Permission":"20","Role":"255","Override":false,"Type":1,"Value":5}]""");
|
||||
|
||||
Assert.NotNull(cells);
|
||||
Assert.Equal(3, cells!.Count);
|
||||
Assert.Equal(new RoomPermissionMatrix.Cell("13", 30, true, 0, "false"), cells[0]);
|
||||
Assert.Equal(new RoomPermissionMatrix.Cell("CAN_USE_MAKER_PEN", 20, true, 0, "true"), cells[1]);
|
||||
Assert.Equal(new RoomPermissionMatrix.Cell("20", 255, false, 1, "5"), cells[2]);
|
||||
Assert.Null(RoomPermissionMatrix.Parse("role=30"));
|
||||
}
|
||||
|
||||
// ── over HTTP ───────────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public async Task Roles_list_reports_the_creator_as_Creator_255()
|
||||
{
|
||||
var (owner, _, _) = await PlayersAsync();
|
||||
var roomId = await SeedRoomAsync(owner.PlayerId);
|
||||
|
||||
using var rooms = Client("rooms", owner);
|
||||
var list = await GetJsonAsync(rooms, $"/roomserver/rooms/{roomId}/roles");
|
||||
var me = list.EnumerateArray().Single(e => e.GetProperty("AccountId").GetInt64() == owner.PlayerId);
|
||||
Assert.Equal(255, me.GetProperty("Role").GetInt32());
|
||||
|
||||
var details = await GetJsonAsync(rooms, $"/roomserver/rooms/{roomId}");
|
||||
var detailsRole = details.GetProperty("Roles").EnumerateArray()
|
||||
.Single(e => e.GetProperty("AccountId").GetInt64() == owner.PlayerId);
|
||||
Assert.Equal(255, detailsRole.GetProperty("Role").GetInt32());
|
||||
|
||||
var single = await GetJsonAsync(rooms, $"/roomserver/rooms/{roomId}/roles/{owner.PlayerId}");
|
||||
Assert.Equal(255, single.GetProperty("Role").GetInt32());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Co_owner_invite_is_notified_then_accepted_by_the_invitee()
|
||||
{
|
||||
var (owner, invitee, _) = await PlayersAsync();
|
||||
var roomId = await SeedRoomAsync(owner.PlayerId);
|
||||
|
||||
using var ownerRooms = Client("rooms", owner);
|
||||
var invited = await PutFormAsync(ownerRooms, $"/roomserver/rooms/{roomId}/roles/{invitee.PlayerId}/invite", ("role", "30"));
|
||||
var inviteeRow = RoleOf(invited, invitee.PlayerId);
|
||||
Assert.Equal(0, inviteeRow.GetProperty("Role").GetInt32());
|
||||
Assert.Equal(30, inviteeRow.GetProperty("InvitedRole").GetInt32());
|
||||
|
||||
// The invitee's inbox carries message Type 62 (RoomCoOwnerInvited) with RoomId.
|
||||
long messageId;
|
||||
await using (var scope = _factory.Services.CreateAsyncScope())
|
||||
{
|
||||
var db = scope.ServiceProvider.GetRequiredService<DorkNetDbContext>();
|
||||
var msg = await db.Messages.SingleAsync(m =>
|
||||
m.RecipientPlayerId == invitee.PlayerId && m.RoomId == roomId
|
||||
&& m.Type == SystemNotificationService.MessageType.RoomCoOwnerInvited);
|
||||
Assert.Equal(owner.PlayerId, msg.SenderPlayerId);
|
||||
Assert.Null(msg.ReadAt);
|
||||
messageId = msg.Id;
|
||||
}
|
||||
|
||||
// Accept: the invitee PUTs their own role to 30.
|
||||
using var inviteeRooms = Client("rooms", invitee);
|
||||
var accepted = await PutFormAsync(inviteeRooms, $"/roomserver/rooms/{roomId}/roles/{invitee.PlayerId}", ("role", "30"));
|
||||
var acceptedRow = RoleOf(accepted, invitee.PlayerId);
|
||||
Assert.Equal(30, acceptedRow.GetProperty("Role").GetInt32());
|
||||
Assert.Equal(0, acceptedRow.GetProperty("InvitedRole").GetInt32());
|
||||
|
||||
await using (var scope = _factory.Services.CreateAsyncScope())
|
||||
{
|
||||
var db = scope.ServiceProvider.GetRequiredService<DorkNetDbContext>();
|
||||
Assert.NotNull((await db.Messages.SingleAsync(m => m.Id == messageId)).ReadAt);
|
||||
Assert.True(await db.RoomRoles.AnyAsync(r =>
|
||||
r.RoomId == roomId && r.PlayerId == invitee.PlayerId && r.Accepted && r.Role == RoomRoleRules.StoredCoOwner));
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Invitee_rejects_through_the_invite_route_and_cannot_self_promote()
|
||||
{
|
||||
var (owner, invitee, stranger) = await PlayersAsync();
|
||||
var roomId = await SeedRoomAsync(owner.PlayerId);
|
||||
|
||||
using var ownerRooms = Client("rooms", owner);
|
||||
await PutFormAsync(ownerRooms, $"/roomserver/rooms/{roomId}/roles/{invitee.PlayerId}/invite", ("role", "30"));
|
||||
|
||||
using var inviteeRooms = Client("rooms", invitee);
|
||||
var rejected = await PutFormAsync(inviteeRooms, $"/roomserver/rooms/{roomId}/roles/{invitee.PlayerId}/invite", ("role", "0"));
|
||||
Assert.DoesNotContain(rejected.GetProperty("Roles").EnumerateArray(),
|
||||
e => e.GetProperty("AccountId").GetInt64() == invitee.PlayerId);
|
||||
|
||||
// With the invite gone, the same accept is refused.
|
||||
using var again = await PutFormRawAsync(inviteeRooms, $"/roomserver/rooms/{roomId}/roles/{invitee.PlayerId}", ("role", "30"));
|
||||
await AssertRefusedAsync(again);
|
||||
|
||||
// A stranger can neither promote themselves nor invite anyone.
|
||||
using var strangerRooms = Client("rooms", stranger);
|
||||
using var self = await PutFormRawAsync(strangerRooms, $"/roomserver/rooms/{roomId}/roles/{stranger.PlayerId}", ("role", "20"));
|
||||
await AssertRefusedAsync(self);
|
||||
using var other = await PutFormRawAsync(strangerRooms, $"/roomserver/rooms/{roomId}/roles/{invitee.PlayerId}/invite", ("role", "30"));
|
||||
await AssertRefusedAsync(other);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Co_owner_manages_roles_below_but_not_co_owners()
|
||||
{
|
||||
var (owner, coOwner, target) = await PlayersAsync();
|
||||
var roomId = await SeedRoomAsync(owner.PlayerId, (coOwner.PlayerId, RoomRoleRules.StoredCoOwner));
|
||||
|
||||
using var coRooms = Client("rooms", coOwner);
|
||||
var granted = await PutFormAsync(coRooms, $"/roomserver/rooms/{roomId}/roles/{target.PlayerId}", ("role", "20"));
|
||||
Assert.Equal(20, RoleOf(granted, target.PlayerId).GetProperty("Role").GetInt32());
|
||||
|
||||
using var inviteCo = await PutFormRawAsync(coRooms, $"/roomserver/rooms/{roomId}/roles/{target.PlayerId}/invite", ("role", "30"));
|
||||
await AssertRefusedAsync(inviteCo);
|
||||
|
||||
// The creator can directly add and remove a co-owner.
|
||||
using var ownerRooms = Client("rooms", owner);
|
||||
var promoted = await PutFormAsync(ownerRooms, $"/roomserver/rooms/{roomId}/roles/{target.PlayerId}", ("role", "30"));
|
||||
Assert.Equal(30, RoleOf(promoted, target.PlayerId).GetProperty("Role").GetInt32());
|
||||
|
||||
// …and now the co-owner can't touch their peer.
|
||||
using var demote = await PutFormRawAsync(coRooms, $"/roomserver/rooms/{roomId}/roles/{target.PlayerId}", ("role", "0"));
|
||||
await AssertRefusedAsync(demote);
|
||||
|
||||
var removed = await PutFormAsync(ownerRooms, $"/roomserver/rooms/{roomId}/roles/{target.PlayerId}", ("role", "0"));
|
||||
Assert.DoesNotContain(removed.GetProperty("Roles").EnumerateArray(),
|
||||
e => e.GetProperty("AccountId").GetInt64() == target.PlayerId);
|
||||
|
||||
await using var scope = _factory.Services.CreateAsyncScope();
|
||||
var db = scope.ServiceProvider.GetRequiredService<DorkNetDbContext>();
|
||||
Assert.True(await db.Messages.AnyAsync(m => m.RecipientPlayerId == target.PlayerId && m.RoomId == roomId
|
||||
&& m.Type == SystemNotificationService.MessageType.RoomCoOwnerAdded));
|
||||
Assert.True(await db.Messages.AnyAsync(m => m.RecipientPlayerId == target.PlayerId && m.RoomId == roomId
|
||||
&& m.Type == SystemNotificationService.MessageType.RoomCoOwnerRemoved));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Creator_saves_co_owner_and_creator_permissions_and_reads_them_back()
|
||||
{
|
||||
var (owner, coOwner, _) = await PlayersAsync();
|
||||
var roomId = await SeedRoomAsync(owner.PlayerId, (coOwner.PlayerId, RoomRoleRules.StoredCoOwner));
|
||||
|
||||
using var ownerRooms = Client("rooms", owner);
|
||||
var body = """[{"Permission":"13","Role":30,"Override":true,"Type":0,"Value":"false"},{"Permission":"20","Role":255,"Override":true,"Type":0,"Value":"false"}]""";
|
||||
using (var put = await PutJsonRawAsync(ownerRooms, $"/roomserver/rooms/{roomId}/subrooms/1/permissions", body))
|
||||
{
|
||||
var text = await put.Content.ReadAsStringAsync();
|
||||
Assert.True(put.IsSuccessStatusCode, $"{(int)put.StatusCode}: {text}");
|
||||
// {success, value: full room details, error}
|
||||
var value = Envelope(text);
|
||||
Assert.Equal(roomId, value.GetProperty("RoomId").GetInt64());
|
||||
}
|
||||
|
||||
// The owner enters the room, then the matrix comes back with the token.
|
||||
using var match = Client("match", owner);
|
||||
using (var join = await match.PostAsync($"/matchmake/room/{roomId}", new FormUrlEncodedContent(new Dictionary<string, string>())))
|
||||
Assert.True(join.IsSuccessStatusCode, await join.Content.ReadAsStringAsync());
|
||||
var token = await GetJsonAsync(ownerRooms, "/roomserver/photon_access_token");
|
||||
var cells = token.GetProperty("Permissions").EnumerateArray().ToList();
|
||||
Assert.Equal("false", cells.Single(c => c.GetProperty("Role").GetInt32() == 30
|
||||
&& c.GetProperty("Permission").GetString() == "13").GetProperty("Value").GetString());
|
||||
Assert.Equal("false", cells.Single(c => c.GetProperty("Role").GetInt32() == 255
|
||||
&& c.GetProperty("Permission").GetString() == "20").GetProperty("Value").GetString());
|
||||
Assert.Equal("true", cells.Single(c => c.GetProperty("Role").GetInt32() == 255
|
||||
&& c.GetProperty("Permission").GetString() == "13").GetProperty("Value").GetString());
|
||||
|
||||
// A co-owner may not edit the Co-Owner row, but may edit Moderator.
|
||||
using var coRooms = Client("rooms", coOwner);
|
||||
using (var denied = await PutJsonRawAsync(coRooms, $"/roomserver/rooms/{roomId}/subrooms/1/permissions",
|
||||
"""[{"Permission":"13","Role":30,"Override":true,"Type":0,"Value":"true"}]"""))
|
||||
await AssertRefusedAsync(denied);
|
||||
using (var allowed = await PutJsonRawAsync(coRooms, $"/roomserver/rooms/{roomId}/subrooms/1/permissions",
|
||||
"""[{"Permission":"13","Role":20,"Override":true,"Type":0,"Value":"true"}]"""))
|
||||
Envelope(await allowed.Content.ReadAsStringAsync());
|
||||
|
||||
await using var scope = _factory.Services.CreateAsyncScope();
|
||||
var db = scope.ServiceProvider.GetRequiredService<DorkNetDbContext>();
|
||||
var stored = await db.RoomRolePermissions.Where(p => p.RoomId == roomId).ToListAsync();
|
||||
Assert.Equal(3, stored.Count);
|
||||
Assert.All(stored, p => Assert.Equal(0, p.SubRoomId)); // wire 1 → OrderIndex 0
|
||||
Assert.Equal("false", stored.Single(p => p.Role == 30).Value); // the denied write changed nothing
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Invite_pushes_the_message_and_room_and_permission_saves_push_the_access_token()
|
||||
{
|
||||
var hub = new CapturingHubContext();
|
||||
var factory = _factory.WithWebHostBuilder(builder =>
|
||||
builder.ConfigureServices(services =>
|
||||
{
|
||||
Microsoft.Extensions.DependencyInjection.Extensions.ServiceCollectionDescriptorExtensions
|
||||
.RemoveAll<Microsoft.AspNetCore.SignalR.IHubContext<DorkNet.Server.Hubs.NotifyHub>>(services);
|
||||
services.AddSingleton<Microsoft.AspNetCore.SignalR.IHubContext<DorkNet.Server.Hubs.NotifyHub>>(hub);
|
||||
}));
|
||||
|
||||
HttpClient FactoryClient(string sub, GameClientSession? session = null)
|
||||
{
|
||||
var c = factory.CreateClient(new() { AllowAutoRedirect = false });
|
||||
c.BaseAddress = new Uri($"http://{sub}.{_factory.ApexDomain}");
|
||||
c.DefaultRequestHeaders.Accept.ParseAdd("application/json");
|
||||
c.DefaultRequestHeaders.UserAgent.ParseAdd("RecRoom/2023.03.21");
|
||||
if (session is not null)
|
||||
c.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", session.AccessToken);
|
||||
return c;
|
||||
}
|
||||
|
||||
using var setup = FactoryClient("api");
|
||||
var owner = await GameClientSessionFactory.CreateAsync(setup, _factory.ApexDomain);
|
||||
var invitee = await GameClientSessionFactory.CreateAsync(setup, _factory.ApexDomain);
|
||||
var roomId = await SeedRoomAsync(owner.PlayerId);
|
||||
|
||||
using var ownerRooms = FactoryClient("rooms", owner);
|
||||
await PutFormAsync(ownerRooms, $"/roomserver/rooms/{roomId}/roles/{invitee.PlayerId}/invite", ("role", "30"));
|
||||
|
||||
var message = Assert.Single(hub.To(CapturingHubContext.PlayerGroup(invitee.PlayerId), "2"));
|
||||
Assert.Equal(62, message.Msg.GetProperty("Type").GetInt32());
|
||||
Assert.Equal(roomId, message.Msg.GetProperty("RoomId").GetInt64());
|
||||
Assert.Equal((int)owner.PlayerId, message.Msg.GetProperty("FromPlayerId").GetInt32());
|
||||
var roomPush = Assert.Single(hub.To(CapturingHubContext.PlayerGroup(invitee.PlayerId), "RoomUpdate"));
|
||||
Assert.Equal(30, RoleOf(roomPush.Msg, invitee.PlayerId).GetProperty("InvitedRole").GetInt32());
|
||||
|
||||
using var match = FactoryClient("match", owner);
|
||||
using (var join = await match.PostAsync($"/matchmake/room/{roomId}", new FormUrlEncodedContent(new Dictionary<string, string>())))
|
||||
Assert.True(join.IsSuccessStatusCode, await join.Content.ReadAsStringAsync());
|
||||
using (var put = await PutJsonRawAsync(ownerRooms, $"/roomserver/rooms/{roomId}/subrooms/1/permissions",
|
||||
"""[{"Permission":"21","Role":20,"Override":true,"Type":0,"Value":"true"}]"""))
|
||||
Envelope(await put.Content.ReadAsStringAsync());
|
||||
|
||||
var token = Assert.Single(hub.To(CapturingHubContext.PlayerGroup(owner.PlayerId), "PhotonAccessToken"));
|
||||
Assert.True(token.Msg.GetProperty("RoomInstanceId").GetInt64() > 0);
|
||||
Assert.Contains(token.Msg.GetProperty("Permissions").EnumerateArray(),
|
||||
c => c.GetProperty("Role").GetInt32() == 20 && c.GetProperty("Permission").GetString() == "21");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Envelope_filter_wraps_success_and_turns_refusals_into_messages()
|
||||
{
|
||||
static JsonElement Body(Microsoft.AspNetCore.Mvc.IActionResult r) =>
|
||||
JsonDocument.Parse(JsonSerializer.Serialize(((Microsoft.AspNetCore.Mvc.ObjectResult)r).Value)).RootElement.Clone();
|
||||
|
||||
var ok = Body(Controllers.RecNetEnvelopeAttribute.Wrap(new Microsoft.AspNetCore.Mvc.OkObjectResult(new { RoomId = 5 })));
|
||||
Assert.True(ok.GetProperty("success").GetBoolean());
|
||||
Assert.Equal(5, ok.GetProperty("value").GetProperty("RoomId").GetInt32());
|
||||
Assert.Equal("", ok.GetProperty("error").GetString());
|
||||
|
||||
var forbid = Body(Controllers.RecNetEnvelopeAttribute.Wrap(new Microsoft.AspNetCore.Mvc.ForbidResult()));
|
||||
Assert.False(forbid.GetProperty("success").GetBoolean());
|
||||
Assert.False(string.IsNullOrWhiteSpace(forbid.GetProperty("error").GetString()));
|
||||
|
||||
var bad = Body(Controllers.RecNetEnvelopeAttribute.Wrap(
|
||||
new Microsoft.AspNetCore.Mvc.BadRequestObjectResult(new { error = "cannot_change_creator_role" })));
|
||||
Assert.Equal("cannot_change_creator_role", bad.GetProperty("error").GetString());
|
||||
|
||||
// An envelope the handler built itself is left alone.
|
||||
var already = new Microsoft.AspNetCore.Mvc.OkObjectResult(new { success = false, value = (object?)null, error = "x" });
|
||||
Assert.Same(already, Controllers.RecNetEnvelopeAttribute.Wrap(already));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Room_mutations_answer_the_success_value_error_envelope_on_bare_and_roomserver_paths()
|
||||
{
|
||||
var (owner, invitee, _) = await PlayersAsync();
|
||||
var roomId = await SeedRoomAsync(owner.PlayerId);
|
||||
using var rooms = Client("rooms", owner);
|
||||
|
||||
// The exact request the client sent in production (bare path, form role=30).
|
||||
var invited = await PutFormAsync(rooms, $"/rooms/{roomId}/roles/{invitee.PlayerId}/invite", ("role", "30"));
|
||||
Assert.Equal(30, RoleOf(invited, invitee.PlayerId).GetProperty("InvitedRole").GetInt32());
|
||||
|
||||
foreach (var (path, key, value) in new[]
|
||||
{
|
||||
($"/rooms/{roomId}/description", "description", "hello"),
|
||||
($"/roomserver/rooms/{roomId}/accessibility", "accessibility", "0"),
|
||||
($"/roomserver/rooms/{roomId}/subrooms/1/name", "name", "Lobby"),
|
||||
($"/roomserver/rooms/{roomId}/subrooms/1/maxplayers", "maxPlayers", "10"),
|
||||
})
|
||||
{
|
||||
var details = await PutFormAsync(rooms, path, (key, value));
|
||||
Assert.Equal(roomId, details.GetProperty("RoomId").GetInt64());
|
||||
}
|
||||
}
|
||||
|
||||
// ── helpers ─────────────────────────────────────────────────────────
|
||||
|
||||
private async Task<(GameClientSession A, GameClientSession B, GameClientSession C)> PlayersAsync()
|
||||
{
|
||||
using var setup = Client("api");
|
||||
return (
|
||||
await GameClientSessionFactory.CreateAsync(setup, _factory.ApexDomain),
|
||||
await GameClientSessionFactory.CreateAsync(setup, _factory.ApexDomain),
|
||||
await GameClientSessionFactory.CreateAsync(setup, _factory.ApexDomain));
|
||||
}
|
||||
|
||||
private async Task<long> SeedRoomAsync(long creatorId, params (long PlayerId, int StoredRole)[] roles)
|
||||
{
|
||||
var roomId = 9_300_000 + Random.Shared.Next(1, 99_999);
|
||||
await using var scope = _factory.Services.CreateAsyncScope();
|
||||
var db = scope.ServiceProvider.GetRequiredService<DorkNetDbContext>();
|
||||
db.Rooms.Add(new RoomEntity
|
||||
{
|
||||
Id = roomId,
|
||||
Name = $"Roles{Guid.NewGuid():N}"[..18],
|
||||
CreatorPlayerId = creatorId,
|
||||
ImageName = RoomService.DefaultRoomImageName,
|
||||
Accessibility = 0,
|
||||
LocationReplicationId = "a75f7547-79eb-47c6-8986-6767abcb4f92",
|
||||
});
|
||||
db.RoomScenes.Add(new RoomSceneEntity { RoomId = roomId, OrderIndex = 0, Name = "Home" });
|
||||
foreach (var (pid, role) in roles)
|
||||
db.RoomRoles.Add(new RoomRoleEntity { RoomId = roomId, PlayerId = pid, Role = role, Accepted = true });
|
||||
await db.SaveChangesAsync();
|
||||
return roomId;
|
||||
}
|
||||
|
||||
private static JsonElement RoleOf(JsonElement details, long playerId) =>
|
||||
details.GetProperty("Roles").EnumerateArray()
|
||||
.Single(e => e.GetProperty("AccountId").GetInt64() == playerId);
|
||||
|
||||
private static Task<HttpResponseMessage> PutFormRawAsync(HttpClient client, string path, params (string Key, string Value)[] fields)
|
||||
=> client.PutAsync(path, new FormUrlEncodedContent(fields.Select(f => new KeyValuePair<string, string>(f.Key, f.Value))));
|
||||
|
||||
private static async Task<JsonElement> PutFormAsync(HttpClient client, string path, params (string Key, string Value)[] fields)
|
||||
{
|
||||
using var response = await PutFormRawAsync(client, path, fields);
|
||||
var body = await response.Content.ReadAsStringAsync();
|
||||
Assert.True(response.IsSuccessStatusCode, $"PUT {path} -> {(int)response.StatusCode}: {body}");
|
||||
return Envelope(body);
|
||||
}
|
||||
|
||||
/// <summary>Every room mutation answers the {success, value, error}
|
||||
/// envelope (NLDBPDCNNCF.BDBHCEGKGDC<FGCPNAACHIK>): assert success and
|
||||
/// return the room details in <c>value</c>.</summary>
|
||||
private static JsonElement Envelope(string body)
|
||||
{
|
||||
using var doc = JsonDocument.Parse(body);
|
||||
var root = doc.RootElement;
|
||||
Assert.True(root.GetProperty("success").GetBoolean(), body);
|
||||
Assert.Equal(string.Empty, root.GetProperty("error").GetString());
|
||||
return root.GetProperty("value").Clone();
|
||||
}
|
||||
|
||||
/// <summary>A refusal is HTTP 200 with success=false and a message: the
|
||||
/// client shows the message, while a 403/404 surfaces only as the
|
||||
/// message-less "Exception of type 'CEMNLBKJABA' was thrown".</summary>
|
||||
private static async Task AssertRefusedAsync(HttpResponseMessage response)
|
||||
{
|
||||
var body = await response.Content.ReadAsStringAsync();
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
using var doc = JsonDocument.Parse(body);
|
||||
Assert.False(doc.RootElement.GetProperty("success").GetBoolean(), body);
|
||||
Assert.False(string.IsNullOrWhiteSpace(doc.RootElement.GetProperty("error").GetString()), body);
|
||||
Assert.Equal(JsonValueKind.Null, doc.RootElement.GetProperty("value").ValueKind);
|
||||
}
|
||||
|
||||
private static Task<HttpResponseMessage> PutJsonRawAsync(HttpClient client, string path, string json)
|
||||
=> client.PutAsync(path, new StringContent(json, Encoding.UTF8, "application/json"));
|
||||
|
||||
private static async Task<JsonElement> GetJsonAsync(HttpClient client, string path)
|
||||
{
|
||||
using var response = await client.GetAsync(path);
|
||||
var body = await response.Content.ReadAsStringAsync();
|
||||
Assert.True(response.IsSuccessStatusCode, $"GET {path} -> {(int)response.StatusCode}: {body}");
|
||||
return JsonDocument.Parse(body).RootElement.Clone();
|
||||
}
|
||||
|
||||
private HttpClient Client(string subdomain, GameClientSession? session = null)
|
||||
{
|
||||
var client = _factory.CreateClient(new() { AllowAutoRedirect = false });
|
||||
client.BaseAddress = new Uri($"http://{subdomain}.{_factory.ApexDomain}");
|
||||
client.Timeout = TimeSpan.FromSeconds(30);
|
||||
client.DefaultRequestHeaders.Accept.ParseAdd("application/json");
|
||||
client.DefaultRequestHeaders.UserAgent.ParseAdd("RecRoom/2023.03.21");
|
||||
if (session is not null)
|
||||
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", session.AccessToken);
|
||||
return client;
|
||||
}
|
||||
}
|
||||
@@ -416,9 +416,10 @@ public sealed class RroRoomBlobTests : IClassFixture<DorkNetServerFactory>
|
||||
}
|
||||
}
|
||||
|
||||
// The entry sub-room (OrderIndex 0) is wire SubRoomId 1 (RoomSceneIds).
|
||||
private static JsonElement SubRoomZero(JsonElement details) =>
|
||||
details.GetProperty("SubRooms").EnumerateArray()
|
||||
.First(e => e.GetProperty("SubRoomId").GetInt64() == 0);
|
||||
.First(e => e.GetProperty("SubRoomId").GetInt64() == 1);
|
||||
|
||||
private static async Task<JsonElement> GetJsonAsync(HttpClient client, string path)
|
||||
{
|
||||
|
||||
@@ -58,7 +58,9 @@ public sealed class SubRoomMaxPlayersTests : IClassFixture<DorkNetServerFactory>
|
||||
await db.SaveChangesAsync();
|
||||
}
|
||||
|
||||
// ── in-game: the owner's slider on sub-room 1 only ──────────────────
|
||||
// ── in-game: the owner's slider on the second sub-room only ─────────
|
||||
// The client addresses it by its wire SubRoomId, OrderIndex + 1 = 2
|
||||
// (RoomSceneIds — the client will not load a sub-room whose id is 0).
|
||||
// Sent exactly as the client sends it. NLDBPDCNNCF.MNBPCGJNLNP(Int64,
|
||||
// Int64, Int32) builds a BLNOGFGHIIF whose single JSON key is
|
||||
// "maxPlayers" and issues it with verb 3 = PUT — NOT the form encoding
|
||||
@@ -66,19 +68,23 @@ public sealed class SubRoomMaxPlayersTests : IClassFixture<DorkNetServerFactory>
|
||||
// real client's body went unread.
|
||||
using var ownerClient = Client("rooms", owner);
|
||||
using var slider = await ownerClient.PutAsJsonAsync(
|
||||
$"/rooms/{roomId}/subrooms/1/maxplayers", new { maxPlayers = 24 });
|
||||
$"/rooms/{roomId}/subrooms/2/maxplayers", new { maxPlayers = 24 });
|
||||
var sliderBody = await slider.Content.ReadAsStringAsync();
|
||||
Assert.True(slider.IsSuccessStatusCode,
|
||||
$"owner PUT maxplayers -> {(int)slider.StatusCode}: {sliderBody}");
|
||||
|
||||
// The client deserialises the reply as FGCPNAACHIK — the room-details
|
||||
// shape — so the slider's own response has to carry the new value.
|
||||
var echoed = JsonDocument.Parse(sliderBody).RootElement
|
||||
// The client reads the reply through the {success, value, error}
|
||||
// helper with value = FGCPNAACHIK, so the slider's own response has to
|
||||
// carry the new value inside the envelope.
|
||||
var envelope = JsonDocument.Parse(sliderBody).RootElement;
|
||||
Assert.True(envelope.GetProperty("success").GetBoolean(), sliderBody);
|
||||
var echoed = envelope.GetProperty("value")
|
||||
.GetProperty("SubRooms").EnumerateArray()
|
||||
.First(e => e.GetProperty("SubRoomId").GetInt64() == 1);
|
||||
.First(e => e.GetProperty("SubRoomId").GetInt64() == 2);
|
||||
Assert.Equal(24, echoed.GetProperty("MaxPlayers").GetInt32());
|
||||
|
||||
// ── admin: reads what the owner just set, then edits sub-room 0 ─────
|
||||
// (the admin API addresses sub-rooms by OrderIndex)
|
||||
using var adminClient = Client("admin", admin);
|
||||
var listed = await GetJsonAsync(adminClient, $"/api/admin/v1/rooms/{roomId}/subrooms");
|
||||
var subRooms = listed.EnumerateArray()
|
||||
@@ -99,8 +105,9 @@ public sealed class SubRoomMaxPlayersTests : IClassFixture<DorkNetServerFactory>
|
||||
.ToDictionary(e => e.GetProperty("SubRoomId").GetInt64(),
|
||||
e => e.GetProperty("MaxPlayers").GetInt32());
|
||||
|
||||
Assert.Equal(40, wire[0]);
|
||||
Assert.Equal(24, wire[1]);
|
||||
// Wire ids are OrderIndex + 1.
|
||||
Assert.Equal(40, wire[1]);
|
||||
Assert.Equal(24, wire[2]);
|
||||
|
||||
// The room-level figure is the room's advertised capacity, NOT a copy
|
||||
// of sub-room 0 — otherwise the admin edit above would have moved it.
|
||||
|
||||
@@ -0,0 +1,277 @@
|
||||
using System.Net.Http.Headers;
|
||||
using System.Text.Json;
|
||||
using DorkNet.Server.Data;
|
||||
using DorkNet.Server.Data.Entities;
|
||||
using DorkNet.Server.Services;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
|
||||
namespace DorkNet.Server.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Sub-room ids and the room instance browser for the 2023 client.
|
||||
/// <list type="bullet">
|
||||
/// <item>The client will not load a sub-room whose id is not positive
|
||||
/// (SubroomModel.DOKCPLJOKIE: <c>RoomId > 0 && SubroomId > 0</c>),
|
||||
/// so the wire SubRoomId is OrderIndex + 1 everywhere the client sees it:
|
||||
/// room details, sub-room routes, matchmaking and the instance browser.</item>
|
||||
/// <item>A new room always owns a real, named entry sub-room, and a startup
|
||||
/// pass repairs rooms that lack one.</item>
|
||||
/// <item><c>GET room/{id}/instances</c> lists live instances with their real
|
||||
/// players, including the private instance of a private room for the people
|
||||
/// allowed into it.</item>
|
||||
/// </list>
|
||||
/// </summary>
|
||||
public sealed class SubRoomsAndInstances2023Tests : IClassFixture<DorkNetServerFactory>
|
||||
{
|
||||
private readonly DorkNetServerFactory _factory;
|
||||
|
||||
public SubRoomsAndInstances2023Tests(DorkNetServerFactory factory) => _factory = factory;
|
||||
|
||||
[Fact]
|
||||
public void Wire_sub_room_ids_are_positive_and_round_trip()
|
||||
{
|
||||
Assert.Equal(1, RoomSceneIds.ToWire(0));
|
||||
Assert.Equal(3, RoomSceneIds.ToWire(2));
|
||||
Assert.Equal(0, RoomSceneIds.ToOrderIndex(1));
|
||||
Assert.Equal(2, RoomSceneIds.ToOrderIndex(3));
|
||||
// 0 (cached client state, bare matchmake) is the entry sub-room.
|
||||
Assert.Equal(0, RoomSceneIds.ToOrderIndex(0));
|
||||
Assert.Equal(0, RoomSceneIds.ToOrderIndex(-1));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Details_matchmaking_and_sub_room_routes_share_the_wire_id()
|
||||
{
|
||||
var owner = await PlayerAsync();
|
||||
var roomId = await SeedRoomAsync(owner.PlayerId, accessibility: 1, scenes: new[] { "Home", "Arena" });
|
||||
|
||||
using var rooms = Client("rooms", owner);
|
||||
var details = await GetJsonAsync(rooms, $"/roomserver/rooms/{roomId}");
|
||||
var subRooms = details.GetProperty("SubRooms").EnumerateArray().ToList();
|
||||
Assert.Equal(new long[] { 1, 2 }, subRooms.Select(s => s.GetProperty("SubRoomId").GetInt64()));
|
||||
Assert.Equal("Home", subRooms[0].GetProperty("Name").GetString());
|
||||
|
||||
// matchmake/room/{id}/{sub} takes the wire id and echoes it.
|
||||
using var match = Client("match", owner);
|
||||
var arena = await PostFormAsync(match, $"/matchmake/room/{roomId}/2");
|
||||
Assert.Equal(2, arena.GetProperty("roomInstance").GetProperty("subRoomId").GetInt64());
|
||||
var lobby = await PostFormAsync(match, $"/matchmake/room/{roomId}");
|
||||
Assert.Equal(1, lobby.GetProperty("roomInstance").GetProperty("subRoomId").GetInt64());
|
||||
|
||||
// A sub-room route addressed by wire id 2 edits OrderIndex 1.
|
||||
using (var rename = await rooms.PutAsync($"/roomserver/rooms/{roomId}/subrooms/2/name",
|
||||
new FormUrlEncodedContent(new Dictionary<string, string> { ["name"] = "Basement" })))
|
||||
Assert.True(rename.IsSuccessStatusCode, await rename.Content.ReadAsStringAsync());
|
||||
|
||||
await using var scope = _factory.Services.CreateAsyncScope();
|
||||
var db = scope.ServiceProvider.GetRequiredService<DorkNetDbContext>();
|
||||
Assert.Equal("Basement", (await db.RoomScenes.SingleAsync(s => s.RoomId == roomId && s.OrderIndex == 1)).Name);
|
||||
Assert.Equal("Home", (await db.RoomScenes.SingleAsync(s => s.RoomId == roomId && s.OrderIndex == 0)).Name);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Cloning_a_template_without_scene_rows_creates_a_named_home_sub_room()
|
||||
{
|
||||
var owner = await PlayerAsync();
|
||||
var templateId = await SeedRoomAsync(1, accessibility: 1, scenes: Array.Empty<string>(), isAg: true);
|
||||
|
||||
using var rooms = Client("rooms", owner);
|
||||
using var resp = await rooms.PostAsync($"/roomserver/rooms/{templateId}/clone",
|
||||
new FormUrlEncodedContent(new Dictionary<string, string> { ["name"] = $"Fresh{Guid.NewGuid():N}"[..20] }));
|
||||
var text = await resp.Content.ReadAsStringAsync();
|
||||
Assert.True(resp.IsSuccessStatusCode, text);
|
||||
using var json = JsonDocument.Parse(text);
|
||||
var value = json.RootElement.GetProperty("value");
|
||||
var cloneId = value.GetProperty("RoomId").GetInt64();
|
||||
var sub = value.GetProperty("SubRooms").EnumerateArray().Single();
|
||||
Assert.Equal(1, sub.GetProperty("SubRoomId").GetInt64());
|
||||
Assert.Equal("Home", sub.GetProperty("Name").GetString());
|
||||
|
||||
await using var scope = _factory.Services.CreateAsyncScope();
|
||||
var db = scope.ServiceProvider.GetRequiredService<DorkNetDbContext>();
|
||||
var scene = await db.RoomScenes.SingleAsync(s => s.RoomId == cloneId);
|
||||
Assert.Equal(0, scene.OrderIndex);
|
||||
Assert.Equal("Home", scene.Name);
|
||||
Assert.Equal("a75f7547-79eb-47c6-8986-6767abcb4f92", scene.RoomSceneLocationId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Startup_repair_gives_scene_less_rooms_a_home_and_names_blank_rows()
|
||||
{
|
||||
var bare = await SeedRoomAsync(1, accessibility: 0, scenes: Array.Empty<string>());
|
||||
var blank = await SeedRoomAsync(1, accessibility: 0, scenes: new[] { "", " " });
|
||||
|
||||
await using var scope = _factory.Services.CreateAsyncScope();
|
||||
var service = scope.ServiceProvider.GetRequiredService<RoomService>();
|
||||
var (added, renamed) = await service.RepairRoomScenesAsync();
|
||||
Assert.True(added >= 1);
|
||||
Assert.True(renamed >= 2);
|
||||
|
||||
var db = scope.ServiceProvider.GetRequiredService<DorkNetDbContext>();
|
||||
var home = await db.RoomScenes.SingleAsync(s => s.RoomId == bare);
|
||||
Assert.Equal((0, "Home"), (home.OrderIndex, home.Name));
|
||||
var names = await db.RoomScenes.Where(s => s.RoomId == blank).OrderBy(s => s.OrderIndex).Select(s => s.Name).ToListAsync();
|
||||
Assert.Equal(new[] { "Home", "Subroom 2" }, names);
|
||||
|
||||
// Idempotent.
|
||||
Assert.Equal((0, 0), await service.RepairRoomScenesAsync());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Room_level_restore_also_restores_the_entry_sub_room()
|
||||
{
|
||||
var owner = await PlayerAsync();
|
||||
var roomId = await SeedRoomAsync(owner.PlayerId, accessibility: 0, scenes: new[] { "Home", "Back" });
|
||||
await using (var scope = _factory.Services.CreateAsyncScope())
|
||||
{
|
||||
var db = scope.ServiceProvider.GetRequiredService<DorkNetDbContext>();
|
||||
await db.RoomScenes.Where(s => s.RoomId == roomId)
|
||||
.ExecuteUpdateAsync(s => s.SetProperty(x => x.DataBlobName, "blob-old"));
|
||||
db.RoomDataBlobs.Add(new RoomDataBlobEntity { RoomId = roomId, BlobName = "blob-new", UploadedByPlayerId = owner.PlayerId });
|
||||
await db.SaveChangesAsync();
|
||||
}
|
||||
|
||||
using var api = Client("api", owner);
|
||||
using var response = await api.PostAsync("/api/rooms/v1/restore", new FormUrlEncodedContent(new Dictionary<string, string>
|
||||
{
|
||||
["roomId"] = roomId.ToString(), ["blobName"] = "blob-new",
|
||||
}));
|
||||
Assert.True(response.IsSuccessStatusCode, await response.Content.ReadAsStringAsync());
|
||||
|
||||
await using var check = _factory.Services.CreateAsyncScope();
|
||||
var checkDb = check.ServiceProvider.GetRequiredService<DorkNetDbContext>();
|
||||
var blobs = await checkDb.RoomScenes.AsNoTracking().Where(s => s.RoomId == roomId)
|
||||
.OrderBy(s => s.OrderIndex).Select(s => s.DataBlobName).ToListAsync();
|
||||
// The entry sub-room follows the restore; other sub-rooms keep theirs.
|
||||
Assert.Equal(new[] { "blob-new", "blob-old" }, blobs);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Startup_repair_restores_a_missing_entry_sub_room_next_to_others()
|
||||
{
|
||||
var roomId = await SeedRoomAsync(1, accessibility: 0, scenes: new[] { "Home", "Back" });
|
||||
await using var scope = _factory.Services.CreateAsyncScope();
|
||||
var db = scope.ServiceProvider.GetRequiredService<DorkNetDbContext>();
|
||||
await db.RoomScenes.Where(s => s.RoomId == roomId && s.OrderIndex == 0).ExecuteDeleteAsync();
|
||||
|
||||
var service = scope.ServiceProvider.GetRequiredService<RoomService>();
|
||||
var (added, _) = await service.RepairRoomScenesAsync();
|
||||
Assert.True(added >= 1);
|
||||
|
||||
var scenes = await db.RoomScenes.AsNoTracking().Where(s => s.RoomId == roomId)
|
||||
.OrderBy(s => s.OrderIndex).Select(s => new { s.OrderIndex, s.Name }).ToListAsync();
|
||||
Assert.Equal(new[] { (0, "Home"), (1, "Back") }, scenes.Select(s => (s.OrderIndex, s.Name)));
|
||||
Assert.Equal((0, 0), await service.RepairRoomScenesAsync());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Instance_browser_lists_the_private_instance_with_its_players_for_allowed_callers()
|
||||
{
|
||||
var owner = await PlayerAsync();
|
||||
var coOwner = await PlayerAsync();
|
||||
var stranger = await PlayerAsync();
|
||||
var roomId = await SeedRoomAsync(owner.PlayerId, accessibility: 0, scenes: new[] { "Home" });
|
||||
await using (var scope = _factory.Services.CreateAsyncScope())
|
||||
{
|
||||
var db = scope.ServiceProvider.GetRequiredService<DorkNetDbContext>();
|
||||
db.RoomRoles.Add(new RoomRoleEntity
|
||||
{
|
||||
RoomId = roomId, PlayerId = coOwner.PlayerId, Role = RoomRoleRules.StoredCoOwner, Accepted = true,
|
||||
});
|
||||
await db.SaveChangesAsync();
|
||||
}
|
||||
|
||||
using var ownerMatch = Client("match", owner);
|
||||
var joined = await PostFormAsync(ownerMatch, $"/matchmake/room/{roomId}");
|
||||
var instanceId = joined.GetProperty("roomInstance").GetProperty("roomInstanceId").GetInt64();
|
||||
|
||||
foreach (var viewer in new[] { owner, coOwner })
|
||||
{
|
||||
using var match = Client("match", viewer);
|
||||
var list = await GetJsonAsync(match, $"/room/{roomId}/instances");
|
||||
var row = list.EnumerateArray().Single();
|
||||
Assert.Equal(instanceId, row.GetProperty("roomInstanceId").GetInt64());
|
||||
Assert.Equal(roomId, row.GetProperty("roomId").GetInt64());
|
||||
Assert.Equal(1, row.GetProperty("subRoomId").GetInt64());
|
||||
Assert.False(row.GetProperty("isFull").GetBoolean());
|
||||
Assert.Equal(new[] { (int)owner.PlayerId },
|
||||
row.GetProperty("playerIds").EnumerateArray().Select(p => p.GetInt32()));
|
||||
}
|
||||
|
||||
// Someone with no way into the private room is not shown it.
|
||||
using var strangerMatch = Client("match", stranger);
|
||||
var hidden = await GetJsonAsync(strangerMatch, $"/room/{roomId}/instances");
|
||||
Assert.Empty(hidden.EnumerateArray());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Private_instance_visibility_rule()
|
||||
{
|
||||
var row = new PrivateInstanceEntity { Id = 5, OwnerPlayerId = 3 };
|
||||
Assert.True(Controllers.Match.MatchController.CanSeePrivateInstance(9, true, Array.Empty<long>(), row, false));
|
||||
Assert.True(Controllers.Match.MatchController.CanSeePrivateInstance(9, false, new long[] { 9 }, row, false));
|
||||
Assert.True(Controllers.Match.MatchController.CanSeePrivateInstance(3, false, Array.Empty<long>(), row, false));
|
||||
Assert.True(Controllers.Match.MatchController.CanSeePrivateInstance(9, false, Array.Empty<long>(), row, true));
|
||||
Assert.False(Controllers.Match.MatchController.CanSeePrivateInstance(9, false, Array.Empty<long>(), row, false));
|
||||
Assert.False(Controllers.Match.MatchController.CanSeePrivateInstance(0, true, Array.Empty<long>(), row, true));
|
||||
}
|
||||
|
||||
// ── helpers ─────────────────────────────────────────────────────────
|
||||
|
||||
private async Task<GameClientSession> PlayerAsync()
|
||||
{
|
||||
using var setup = Client("api");
|
||||
return await GameClientSessionFactory.CreateAsync(setup, _factory.ApexDomain);
|
||||
}
|
||||
|
||||
private async Task<long> SeedRoomAsync(long creatorId, int accessibility, string[] scenes, bool isAg = false)
|
||||
{
|
||||
var roomId = 9_200_000 + Random.Shared.Next(1, 99_999);
|
||||
await using var scope = _factory.Services.CreateAsyncScope();
|
||||
var db = scope.ServiceProvider.GetRequiredService<DorkNetDbContext>();
|
||||
db.Rooms.Add(new RoomEntity
|
||||
{
|
||||
Id = roomId,
|
||||
Name = $"Subs{Guid.NewGuid():N}"[..18],
|
||||
CreatorPlayerId = creatorId,
|
||||
ImageName = RoomService.DefaultRoomImageName,
|
||||
Accessibility = accessibility,
|
||||
CloningAllowed = true,
|
||||
IsAGRoom = isAg,
|
||||
LocationReplicationId = "a75f7547-79eb-47c6-8986-6767abcb4f92",
|
||||
});
|
||||
for (var i = 0; i < scenes.Length; i++)
|
||||
db.RoomScenes.Add(new RoomSceneEntity { RoomId = roomId, OrderIndex = i, Name = scenes[i] });
|
||||
await db.SaveChangesAsync();
|
||||
return roomId;
|
||||
}
|
||||
|
||||
private static async Task<JsonElement> PostFormAsync(HttpClient client, string path)
|
||||
{
|
||||
using var response = await client.PostAsync(path, new FormUrlEncodedContent(new Dictionary<string, string>()));
|
||||
var body = await response.Content.ReadAsStringAsync();
|
||||
Assert.True(response.IsSuccessStatusCode, $"POST {path} -> {(int)response.StatusCode}: {body}");
|
||||
return JsonDocument.Parse(body).RootElement.Clone();
|
||||
}
|
||||
|
||||
private static async Task<JsonElement> GetJsonAsync(HttpClient client, string path)
|
||||
{
|
||||
using var response = await client.GetAsync(path);
|
||||
var body = await response.Content.ReadAsStringAsync();
|
||||
Assert.True(response.IsSuccessStatusCode, $"GET {path} -> {(int)response.StatusCode}: {body}");
|
||||
return JsonDocument.Parse(body).RootElement.Clone();
|
||||
}
|
||||
|
||||
private HttpClient Client(string subdomain, GameClientSession? session = null)
|
||||
{
|
||||
var client = _factory.CreateClient(new() { AllowAutoRedirect = false });
|
||||
client.BaseAddress = new Uri($"http://{subdomain}.{_factory.ApexDomain}");
|
||||
client.Timeout = TimeSpan.FromSeconds(30);
|
||||
client.DefaultRequestHeaders.Accept.ParseAdd("application/json");
|
||||
client.DefaultRequestHeaders.UserAgent.ParseAdd("RecRoom/2023.03.21");
|
||||
if (session is not null)
|
||||
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", session.AccessToken);
|
||||
return client;
|
||||
}
|
||||
}
|
||||
@@ -1254,8 +1254,10 @@ public class InventionsController(
|
||||
if (!canPush) return Forbid();
|
||||
|
||||
var subRoomId = req.SubRoomId;
|
||||
// Wire SubRoomId (OrderIndex + 1) → the scene's OrderIndex.
|
||||
var sceneOrderIndex = Services.RoomSceneIds.ToOrderIndex(subRoomId);
|
||||
var scene = await db.RoomScenes
|
||||
.FirstOrDefaultAsync(s => s.RoomId == room.Id && s.OrderIndex == subRoomId);
|
||||
.FirstOrDefaultAsync(s => s.RoomId == room.Id && s.OrderIndex == sceneOrderIndex);
|
||||
|
||||
var subRoomDataFilename = Clamp((req.SubRoomData?.Filename ?? string.Empty).Trim(), 128);
|
||||
if (subRoomDataFilename.Length == 0)
|
||||
@@ -1269,7 +1271,7 @@ public class InventionsController(
|
||||
if (subRoomBlob is null)
|
||||
return BadRequest(new { Error = "unknown SubRoomData.Filename" });
|
||||
if (subRoomBlob.RoomId == 0) subRoomBlob.RoomId = room.Id;
|
||||
subRoomBlob.SubRoomId ??= subRoomId;
|
||||
subRoomBlob.SubRoomId ??= sceneOrderIndex;
|
||||
|
||||
var roomDataFilename = Clamp((req.RoomData?.Filename ?? string.Empty).Trim(), 128);
|
||||
if (roomDataFilename.Length > 0)
|
||||
|
||||
@@ -394,7 +394,11 @@ public class PlayerEventsController(
|
||||
if (evt is null) return NotFound();
|
||||
|
||||
var extras = (await LoadExtrasAsync(new[] { evt }))[evt.Id];
|
||||
var subRoomId = extras.SubRoomId is long sub && sub > 0 ? sub : (long?)null;
|
||||
// The event stores the client's wire SubRoomId; presence and private
|
||||
// instances hold the OrderIndex (RoomSceneIds).
|
||||
var subRoomId = extras.SubRoomId is long sub && sub > 0
|
||||
? RoomSceneIds.ToOrderIndex(sub)
|
||||
: (long?)null;
|
||||
|
||||
// Same sourcing as the room browser: presence rows are the live truth
|
||||
// (one instance per distinct PhotonRoomId), and the caller's OWN private
|
||||
@@ -417,7 +421,7 @@ public class PlayerEventsController(
|
||||
{
|
||||
roomInstanceId = room.RoomInstanceId,
|
||||
roomId = room.RoomId,
|
||||
subRoomId = room.SubRoomId,
|
||||
subRoomId = RoomSceneIds.ToWire(room.SubRoomId),
|
||||
// MaxCapacity is the per-instance cap the matchmaker handed
|
||||
// out, so this is a real answer rather than a constant false.
|
||||
isFull = room.MaxCapacity > 0 && playerIds.Count >= room.MaxCapacity,
|
||||
@@ -438,7 +442,7 @@ public class PlayerEventsController(
|
||||
{
|
||||
roomInstanceId = p.Id,
|
||||
roomId = p.RoomId,
|
||||
subRoomId = p.SubRoomId,
|
||||
subRoomId = RoomSceneIds.ToWire(p.SubRoomId),
|
||||
isFull = false,
|
||||
createdAt = DateTime.SpecifyKind(p.CreatedAt, DateTimeKind.Utc),
|
||||
playerIds = Array.Empty<int>(),
|
||||
|
||||
@@ -48,6 +48,7 @@ public class PlaylistsController(
|
||||
/// query or JSON body so JSON callers (admin UI) keep working.</summary>
|
||||
[HttpPost("/playlists")]
|
||||
[Authorize]
|
||||
[RecNetEnvelope]
|
||||
public async Task<IActionResult> Create()
|
||||
{
|
||||
var name = await ReadFieldAsync("name", "Name");
|
||||
@@ -87,6 +88,7 @@ public class PlaylistsController(
|
||||
[HttpPost("/playlists/{playlistId:long}/name")]
|
||||
[HttpPut("/playlists/{playlistId:long}/name")]
|
||||
[Authorize]
|
||||
[RecNetEnvelope]
|
||||
public async Task<IActionResult> PlaylistName(long playlistId)
|
||||
{
|
||||
var value = await ReadFieldAsync("name", "Name", "value", "Value");
|
||||
@@ -96,6 +98,7 @@ public class PlaylistsController(
|
||||
[HttpPost("/playlists/{playlistId:long}/description")]
|
||||
[HttpPut("/playlists/{playlistId:long}/description")]
|
||||
[Authorize]
|
||||
[RecNetEnvelope]
|
||||
public async Task<IActionResult> PlaylistDescription(long playlistId)
|
||||
{
|
||||
var value = await ReadFieldAsync("description", "Description", "value", "Value");
|
||||
@@ -105,6 +108,7 @@ public class PlaylistsController(
|
||||
[HttpPost("/playlists/{playlistId:long}/image")]
|
||||
[HttpPut("/playlists/{playlistId:long}/image")]
|
||||
[Authorize]
|
||||
[RecNetEnvelope]
|
||||
public async Task<IActionResult> PlaylistImage(long playlistId)
|
||||
{
|
||||
var value = await ReadFieldAsync("imageName", "ImageName", "value", "Value");
|
||||
@@ -116,6 +120,7 @@ public class PlaylistsController(
|
||||
[HttpPost("/playlists/{playlistId:long}/tags")]
|
||||
[HttpPut("/playlists/{playlistId:long}/tags")]
|
||||
[Authorize]
|
||||
[RecNetEnvelope]
|
||||
public async Task<IActionResult> PlaylistTags(long playlistId)
|
||||
{
|
||||
var tags = await ReadFieldsAsync("tag", "Tag", "autoTag", "AutoTag", "tags", "Tags");
|
||||
@@ -131,6 +136,7 @@ public class PlaylistsController(
|
||||
[HttpPost("/playlists/{playlistId:long}/visibility")]
|
||||
[HttpPut("/playlists/{playlistId:long}/visibility")]
|
||||
[Authorize]
|
||||
[RecNetEnvelope]
|
||||
public async Task<IActionResult> PlaylistAccessibility(long playlistId)
|
||||
{
|
||||
var value = await ReadFieldAsync(
|
||||
@@ -144,6 +150,7 @@ public class PlaylistsController(
|
||||
[HttpPost("/playlists/{playlistId:long}/levelvoting")]
|
||||
[HttpPut("/playlists/{playlistId:long}/levelvoting")]
|
||||
[Authorize]
|
||||
[RecNetEnvelope]
|
||||
public async Task<IActionResult> PlaylistLevelVoting(long playlistId)
|
||||
{
|
||||
var value = await ReadFieldAsync("supportsLevelVoting", "SupportsLevelVoting", "value", "Value");
|
||||
@@ -156,6 +163,7 @@ public class PlaylistsController(
|
||||
[HttpPost("/playlists/{playlistId:long}/restrictions")]
|
||||
[HttpPut("/playlists/{playlistId:long}/restrictions")]
|
||||
[Authorize]
|
||||
[RecNetEnvelope]
|
||||
public async Task<IActionResult> PlaylistRestrictions(long playlistId)
|
||||
{
|
||||
var juniors = await ReadFieldAsync("supportsJuniors", "SupportsJuniors");
|
||||
@@ -174,6 +182,7 @@ public class PlaylistsController(
|
||||
[HttpPost("/playlists/{playlistId:long}/warning")]
|
||||
[HttpPut("/playlists/{playlistId:long}/warning")]
|
||||
[Authorize]
|
||||
[RecNetEnvelope]
|
||||
public async Task<IActionResult> PlaylistWarning(long playlistId)
|
||||
{
|
||||
var mask = await ReadFieldAsync("warningMask", "WarningMask");
|
||||
@@ -295,6 +304,7 @@ public class PlaylistsController(
|
||||
[HttpPut("/playlists/{playlistId:long}/rooms/{roomId:long}")]
|
||||
[HttpPost("/playlists/{playlistId:long}/rooms/{roomId:long}")]
|
||||
[Authorize]
|
||||
[RecNetEnvelope]
|
||||
public async Task<IActionResult> AddRoom(long playlistId, long roomId)
|
||||
{
|
||||
try
|
||||
@@ -307,6 +317,7 @@ public class PlaylistsController(
|
||||
|
||||
[HttpDelete("/playlists/{playlistId:long}/rooms/{roomId:long}")]
|
||||
[Authorize]
|
||||
[RecNetEnvelope]
|
||||
public async Task<IActionResult> RemoveRoom(long playlistId, long roomId)
|
||||
{
|
||||
try
|
||||
|
||||
@@ -35,6 +35,7 @@ public class RoomsController(
|
||||
DomainConfig domain,
|
||||
NotificationService notifications,
|
||||
ServerSettingsService serverSettings,
|
||||
SystemNotificationService systemNotifications,
|
||||
ILogger<RoomsController> logger) : ControllerBase
|
||||
{
|
||||
private long? CurrentPlayerId => ControllerBaseExtensions.CurrentPlayerId(this);
|
||||
@@ -574,6 +575,7 @@ public class RoomsController(
|
||||
[HttpGet("rooms/{roomId:long}/subrooms/{subRoomId:long}/datahistory")]
|
||||
public async Task<IActionResult> SubRoomDataHistory(long roomId, long subRoomId)
|
||||
{
|
||||
subRoomId = RoomSceneIds.ToOrderIndex(subRoomId); // wire id → OrderIndex
|
||||
var blobs = await db.RoomDataBlobs
|
||||
.Where(b => b.RoomId == roomId
|
||||
&& (b.SubRoomId == subRoomId || b.SubRoomId == null))
|
||||
@@ -586,7 +588,7 @@ public class RoomsController(
|
||||
.Take(50)
|
||||
.Select(b => new
|
||||
{
|
||||
SubRoomId = subRoomId,
|
||||
SubRoomId = RoomSceneIds.ToWire(subRoomId),
|
||||
DataBlob = b.BlobName,
|
||||
SavedByAccountId = b.UploadedByPlayerId,
|
||||
CreatedAt = b.UploadedAt,
|
||||
@@ -613,6 +615,7 @@ public class RoomsController(
|
||||
[FromQuery] int? unityAssetTarget,
|
||||
[FromQuery] int? unityAssetVersion)
|
||||
{
|
||||
subRoomId = RoomSceneIds.ToOrderIndex(subRoomId); // wire id → OrderIndex
|
||||
var room = await rooms.GetByIdAsync(roomId);
|
||||
if (room is null) return NotFound(new { error = "room_not_found", roomId });
|
||||
|
||||
@@ -661,6 +664,7 @@ public class RoomsController(
|
||||
[Authorize]
|
||||
public async Task<IActionResult> SubRoom(long roomId, long subRoomId)
|
||||
{
|
||||
subRoomId = RoomSceneIds.ToOrderIndex(subRoomId); // wire id → OrderIndex
|
||||
var room = await rooms.GetByIdAsync(roomId);
|
||||
if (room is null) return NotFound(new { error = "room_not_found", roomId });
|
||||
|
||||
@@ -689,6 +693,7 @@ public class RoomsController(
|
||||
[FromQuery] int? unityAssetTarget,
|
||||
[FromQuery] int? unityAssetVersion)
|
||||
{
|
||||
subRoomId = RoomSceneIds.ToOrderIndex(subRoomId); // wire id → OrderIndex
|
||||
var room = await rooms.GetByIdAsync(roomId);
|
||||
if (room is null) return NotFound(new { error = "room_not_found", roomId });
|
||||
|
||||
@@ -731,6 +736,7 @@ public class RoomsController(
|
||||
long roomId, long subRoomId,
|
||||
[FromQuery] int skip = 0, [FromQuery] int take = 50)
|
||||
{
|
||||
subRoomId = RoomSceneIds.ToOrderIndex(subRoomId); // wire id → OrderIndex
|
||||
var room = await rooms.GetByIdAsync(roomId);
|
||||
if (room is null) return NotFound(new { error = "room_not_found", roomId });
|
||||
|
||||
@@ -763,7 +769,7 @@ public class RoomsController(
|
||||
saves.Add(new
|
||||
{
|
||||
SubRoomDataSaveId = currentSaveId,
|
||||
SubRoomId = subRoomId,
|
||||
SubRoomId = RoomSceneIds.ToWire(subRoomId),
|
||||
UnityAssetId = scene?.StudioUnityAssetId ?? string.Empty,
|
||||
SubRoomUnityAssetId = scene?.StudioUnityAssetId ?? string.Empty,
|
||||
ReferencedUnityAssetIds = Array.Empty<string>(),
|
||||
@@ -809,7 +815,7 @@ public class RoomsController(
|
||||
var modified = scene?.DataModifiedAt ?? room.UpdatedAt;
|
||||
return new
|
||||
{
|
||||
SubRoomId = (long)orderIndex,
|
||||
SubRoomId = RoomSceneIds.ToWire(orderIndex),
|
||||
RoomId = room.Id,
|
||||
Name = scene?.Name ?? "Home",
|
||||
DataBlob = dataBlob,
|
||||
@@ -824,7 +830,7 @@ public class RoomsController(
|
||||
CurrentSave = new
|
||||
{
|
||||
SubRoomDataSaveId = scene?.StudioSubRoomDataSaveId ?? 0L,
|
||||
SubRoomId = (long)orderIndex,
|
||||
SubRoomId = RoomSceneIds.ToWire(orderIndex),
|
||||
UnityAssetId = scene?.StudioUnityAssetId ?? string.Empty,
|
||||
SubRoomUnityAssetId = scene?.StudioUnityAssetId ?? string.Empty,
|
||||
ReferencedUnityAssetIds = Array.Empty<string>(),
|
||||
@@ -871,7 +877,7 @@ public class RoomsController(
|
||||
return new
|
||||
{
|
||||
SubRoomDataSaveId = saveId,
|
||||
SubRoomId = subRoomId,
|
||||
SubRoomId = RoomSceneIds.ToWire(subRoomId),
|
||||
UnityAssetId = unityAssetId,
|
||||
SubRoomUnityAssetId = unityAssetId,
|
||||
ReferencedUnityAssetIds = Array.Empty<string>(),
|
||||
@@ -916,13 +922,14 @@ public class RoomsController(
|
||||
public async Task<IActionResult> SubRoomRestoreData(
|
||||
long roomId, long subRoomId, [FromForm] string? filename)
|
||||
{
|
||||
subRoomId = RoomSceneIds.ToOrderIndex(subRoomId); // wire id → OrderIndex
|
||||
if (string.IsNullOrWhiteSpace(filename))
|
||||
return BadRequest(new { error = "missing_filename" });
|
||||
|
||||
var pid = this.RequireCurrentPlayerId();
|
||||
var room = await rooms.GetByIdAsync(roomId);
|
||||
if (room is null) return NotFound(new { error = "room_not_found" });
|
||||
if (room.CreatorPlayerId != pid) return Forbid();
|
||||
if (!await CanEditRoomAsync(room, pid)) return Forbid();
|
||||
|
||||
var blob = await db.RoomDataBlobs
|
||||
.FirstOrDefaultAsync(b => b.BlobName == filename && b.RoomId == roomId);
|
||||
@@ -1399,6 +1406,7 @@ public class RoomsController(
|
||||
.ExecuteUpdateAsync(s => s
|
||||
.SetProperty(r => r.CurrentDataBlobName, blob.BlobName)
|
||||
.SetProperty(r => r.UpdatedAt, DateTime.UtcNow));
|
||||
if (!room.IsDormRoom) await SyncEntrySceneBlobAsync(room.Id, blob.BlobName);
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
// Push the same SubscriptionUpdateRoom the save flow sends so
|
||||
@@ -1472,10 +1480,27 @@ public class RoomsController(
|
||||
.ExecuteUpdateAsync(s => s
|
||||
.SetProperty(r => r.CurrentDataBlobName, blob.BlobName)
|
||||
.SetProperty(r => r.UpdatedAt, DateTime.UtcNow));
|
||||
if (!room.IsDormRoom) await SyncEntrySceneBlobAsync(roomId, blob.BlobName);
|
||||
await db.SaveChangesAsync();
|
||||
return Ok(new { restored = blob.BlobName });
|
||||
}
|
||||
|
||||
/// <summary>A room-level restore also restores the entry sub-room
|
||||
/// (OrderIndex 0): room details serve the entry scene's own blob ahead
|
||||
/// of the room's, so updating only <c>Rooms.CurrentDataBlobName</c>
|
||||
/// would leave the old version in place. The per-sub-room restore keeps
|
||||
/// the two in step the other way round. Dorms keep their blob per
|
||||
/// player (DormState) instead.</summary>
|
||||
private Task SyncEntrySceneBlobAsync(long roomId, string blobName)
|
||||
{
|
||||
var now = DateTime.UtcNow;
|
||||
return db.RoomScenes
|
||||
.Where(s => s.RoomId == roomId && s.OrderIndex == 0)
|
||||
.ExecuteUpdateAsync(s => s
|
||||
.SetProperty(x => x.DataBlobName, blobName)
|
||||
.SetProperty(x => x.DataModifiedAt, now));
|
||||
}
|
||||
|
||||
/// <summary>GET <c>api/rooms/v{1,2}/personaldetails/{roomId}</c> —
|
||||
/// per-caller bookmark/cheer flags for one room. Wire shape verified
|
||||
/// against <c>RecNet.PersonalRoomDetails.Deserialize</c>
|
||||
@@ -2001,7 +2026,7 @@ public class RoomsController(
|
||||
UseLevelBasedMatchmaking = false,
|
||||
UseAgeBasedMatchmaking = false,
|
||||
UseRecRoyaleMatchmaking = false,
|
||||
SubRoomId = (long)s.OrderIndex,
|
||||
SubRoomId = RoomSceneIds.ToWire(s.OrderIndex),
|
||||
RoomId = room.Id,
|
||||
Name = s.Name,
|
||||
UnityAssetId = s.StudioUnityAssetId,
|
||||
@@ -2027,7 +2052,7 @@ public class RoomsController(
|
||||
UseLevelBasedMatchmaking = false,
|
||||
UseAgeBasedMatchmaking = false,
|
||||
UseRecRoyaleMatchmaking = false,
|
||||
SubRoomId = 0L,
|
||||
SubRoomId = RoomSceneIds.ToWire(0),
|
||||
RoomId = room.Id,
|
||||
Name = "Home",
|
||||
UnityAssetId = string.Empty,
|
||||
@@ -2046,11 +2071,10 @@ public class RoomsController(
|
||||
},
|
||||
};
|
||||
|
||||
var wireRoles = new List<object>
|
||||
{
|
||||
BuildRoomAccountRoleWire(room.CreatorPlayerId, 30),
|
||||
};
|
||||
wireRoles.AddRange(roleList.Select(BuildRoomRoleGrantWire));
|
||||
// The creator is Creator (255), never CoOwner (30): the client reads
|
||||
// the local player's role from this list and only lets a player edit
|
||||
// roles strictly below their own (see RoomRoleRules).
|
||||
var wireRoles = RoomRoleRules.BuildWires(room, roleList);
|
||||
|
||||
var tags = string.IsNullOrEmpty(room.TagsCsv)
|
||||
? Array.Empty<object>()
|
||||
@@ -2075,7 +2099,9 @@ public class RoomsController(
|
||||
State = room.State,
|
||||
Accessibility = room.Accessibility,
|
||||
SupportsLevelVoting = room.SupportsLevelVoting,
|
||||
IsRRO = room.IsAGRoom,
|
||||
// Player rooms are never RRO on the wire (see RoomService.WireIsRRO):
|
||||
// the client refuses publish and room settings for an RRO.
|
||||
IsRRO = RoomService.WireIsRRO(room),
|
||||
SupportsScreens = room.SupportsScreens,
|
||||
SupportsWalkVR = room.SupportsWalkVR,
|
||||
SupportsTeleportVR = room.SupportsTeleportVR,
|
||||
@@ -2904,6 +2930,7 @@ public class RoomsController(
|
||||
[HttpPost("roomserver/rooms/{roomId:long}/name")]
|
||||
[HttpPut("roomserver/rooms/{roomId:long}/name")]
|
||||
[Authorize]
|
||||
[RecNetEnvelope]
|
||||
public async Task<IActionResult> RenameRoom(
|
||||
long roomId,
|
||||
[ModelBinder(typeof(DorkNet.Server.Binding.FormOrJsonModelBinder))] RenameBody body)
|
||||
@@ -2922,9 +2949,9 @@ public class RoomsController(
|
||||
{
|
||||
// RoomRoles co-owner check — same authorisation as the
|
||||
// existing modify endpoint, lets co-owners rename too.
|
||||
var coOwner = await db.RoomRoles.AnyAsync(rr =>
|
||||
rr.RoomId == roomId && rr.PlayerId == me && rr.Accepted && (rr.Role == 0 || rr.Role == 1));
|
||||
if (!coOwner) return Forbid();
|
||||
// Room settings are creator / co-owner only; moderators and
|
||||
// hosts moderate players, they don't edit the room.
|
||||
if (!await CanEditRoomAsync(room, me)) return Forbid();
|
||||
}
|
||||
|
||||
// Uniqueness — Rooms.Name has a unique index (DorkNetDbContext.cs:118).
|
||||
@@ -2959,11 +2986,13 @@ public class RoomsController(
|
||||
[HttpPost("roomserver/rooms/{roomId:long}/subrooms/{subRoomId:long}/name")]
|
||||
[HttpPut("roomserver/rooms/{roomId:long}/subrooms/{subRoomId:long}/name")]
|
||||
[Authorize]
|
||||
[RecNetEnvelope]
|
||||
public async Task<IActionResult> RenameSubroom(
|
||||
long roomId,
|
||||
long subRoomId,
|
||||
[ModelBinder(typeof(DorkNet.Server.Binding.FormOrJsonModelBinder))] RenameBody body)
|
||||
{
|
||||
subRoomId = RoomSceneIds.ToOrderIndex(subRoomId); // wire id → OrderIndex
|
||||
var newName = body?.Name;
|
||||
if (string.IsNullOrWhiteSpace(newName))
|
||||
return BadRequest(new { Result = 1, error = "empty_name" });
|
||||
@@ -2976,9 +3005,9 @@ public class RoomsController(
|
||||
if (room is null) return NotFound(new { Result = 4, error = "room_not_found" });
|
||||
if (room.CreatorPlayerId != me)
|
||||
{
|
||||
var coOwner = await db.RoomRoles.AnyAsync(rr =>
|
||||
rr.RoomId == roomId && rr.PlayerId == me && rr.Accepted && (rr.Role == 0 || rr.Role == 1));
|
||||
if (!coOwner) return Forbid();
|
||||
// Room settings are creator / co-owner only; moderators and
|
||||
// hosts moderate players, they don't edit the room.
|
||||
if (!await CanEditRoomAsync(room, me)) return Forbid();
|
||||
}
|
||||
|
||||
var scene = await db.RoomScenes.FirstOrDefaultAsync(s =>
|
||||
@@ -3607,6 +3636,7 @@ public class RoomsController(
|
||||
[HttpPost("rooms/{roomId:long}/subrooms")]
|
||||
[HttpPost("roomserver/rooms/{roomId:long}/subrooms")]
|
||||
[Authorize]
|
||||
[RecNetEnvelope]
|
||||
public async Task<IActionResult> AddSubRoom(
|
||||
long roomId,
|
||||
[ModelBinder(typeof(DorkNet.Server.Binding.FormOrJsonModelBinder))] CreateSubRoomRequest req)
|
||||
@@ -3614,7 +3644,7 @@ public class RoomsController(
|
||||
var pid = this.RequireCurrentPlayerId();
|
||||
var room = await db.Rooms.FirstOrDefaultAsync(r => r.Id == roomId);
|
||||
if (room is null) return NotFound();
|
||||
if (room.CreatorPlayerId != pid) return Forbid();
|
||||
if (!await CanEditRoomAsync(room, pid)) return Forbid();
|
||||
|
||||
var name = (req.Name ?? string.Empty).Trim();
|
||||
if (name.Length == 0) name = "NewScene";
|
||||
@@ -3649,12 +3679,14 @@ public class RoomsController(
|
||||
[HttpDelete("rooms/{roomId:long}/subrooms/{subRoomId:long}")]
|
||||
[HttpDelete("roomserver/rooms/{roomId:long}/subrooms/{subRoomId:long}")]
|
||||
[Authorize]
|
||||
[RecNetEnvelope]
|
||||
public async Task<IActionResult> DeleteSubRoom(long roomId, long subRoomId)
|
||||
{
|
||||
subRoomId = RoomSceneIds.ToOrderIndex(subRoomId); // wire id → OrderIndex
|
||||
var pid = this.RequireCurrentPlayerId();
|
||||
var room = await db.Rooms.FirstOrDefaultAsync(r => r.Id == roomId);
|
||||
if (room is null) return NotFound();
|
||||
if (room.CreatorPlayerId != pid) return Forbid();
|
||||
if (!await CanEditRoomAsync(room, pid)) return Forbid();
|
||||
var scene = await db.RoomScenes
|
||||
.FirstOrDefaultAsync(s => s.RoomId == roomId && s.OrderIndex == subRoomId);
|
||||
if (scene is null) return NotFound();
|
||||
@@ -3710,20 +3742,6 @@ public class RoomsController(
|
||||
/// <summary>One row of the per-subroom role-permission matrix the 2023
|
||||
/// client PUTs to <c>subrooms/{sub}/permissions</c>. Keys verified in the
|
||||
/// writer <c>HDOJMEBCAGP.txt</c> ISIL 050/069/085/101/117.</summary>
|
||||
public sealed class SubRoomPermissionEntry
|
||||
{
|
||||
// Field order + CLR types come from the BNLPBMJJOMM constructor
|
||||
// (BNLPBMJJOMM.txt:126): (String Permission, OMMBGJMJJPN Role,
|
||||
// Boolean Override, FEOJFGECKGH Type, String Value). The two enum
|
||||
// members serialize as numbers (the role writer boxes them
|
||||
// System.Int32 — BFKNABDHMGG.txt ISIL 015), hence int? here.
|
||||
public string? Permission { get; set; }
|
||||
public int? Role { get; set; }
|
||||
public bool? Override { get; set; }
|
||||
public int? Type { get; set; }
|
||||
public string? Value { get; set; }
|
||||
}
|
||||
|
||||
public sealed class PublishSubRoomSaveRequest
|
||||
{
|
||||
public long? SubRoomDataSaveId { get; set; }
|
||||
@@ -3746,6 +3764,7 @@ public class RoomsController(
|
||||
[HttpPost("roomserver/rooms/{roomId:long}/subrooms/{subRoomId:long}/maxplayers")]
|
||||
[HttpPut("roomserver/rooms/{roomId:long}/subrooms/{subRoomId:long}/maxplayers")]
|
||||
[Authorize]
|
||||
[RecNetEnvelope]
|
||||
public Task<IActionResult> SubRoomMaxPlayers(
|
||||
long roomId,
|
||||
long subRoomId,
|
||||
@@ -3763,6 +3782,7 @@ public class RoomsController(
|
||||
[HttpPost("roomserver/rooms/{roomId:long}/subrooms/{subRoomId:long}/accessibility")]
|
||||
[HttpPut("roomserver/rooms/{roomId:long}/subrooms/{subRoomId:long}/accessibility")]
|
||||
[Authorize]
|
||||
[RecNetEnvelope]
|
||||
public Task<IActionResult> SubRoomAccessibility(
|
||||
long roomId,
|
||||
long subRoomId,
|
||||
@@ -3773,45 +3793,120 @@ public class RoomsController(
|
||||
s.CanMatchmakeInto = a != AccessibilityPrivate;
|
||||
});
|
||||
|
||||
/// <summary>PUT <c>rooms/{id}/subrooms/{sub}/permissions</c> — the
|
||||
/// role-permission matrix. Client:
|
||||
/// <summary>PUT <c>rooms/{id}/subrooms/{sub}/permissions</c> — "change
|
||||
/// subroom permissions", the role-permission matrix. Client:
|
||||
/// <c>NLDBPDCNNCF.AAMEOMHKPHF(long, long, IReadOnlyList<BNLPBMJJOMM>)</c>
|
||||
/// (NLDBPDCNNCF.txt:9198, verb 3). Unlike its siblings this one does NOT
|
||||
/// form-encode: the closure calls <c>BNDIAONDFFF.FJLLPHFOOJJ(String)</c>
|
||||
/// (CCNOMNNHNNG.txt ISIL 009), which stamps body-kind 4 = raw string
|
||||
/// (BNDIAONDFFF.txt:275, <c>mov dword ptr [rbx+38h],4</c>) — i.e. a raw
|
||||
/// JSON ARRAY. FormOrJsonModelBinder handles that (non-form → JSON
|
||||
/// deserialize into the List).
|
||||
/// (NLDBPDCNNCF.txt:9198, verb 3, same typed helper as every other room
|
||||
/// mutation, so the response is the full <c>FGCPNAACHIK</c> details).
|
||||
/// Unlike its siblings the body is NOT form-encoded: the closure calls
|
||||
/// <c>BNDIAONDFFF.FJLLPHFOOJJ(String)</c> (CCNOMNNHNNG.txt ISIL 009), a raw
|
||||
/// JSON array of cells. The editor sends one changed cell per call
|
||||
/// (OMJKHJLFOCO.AFHJLBBFAGP.<LocalRequestModifyRolePermission>g__ChangeAndRefresh
|
||||
/// builds a one-element BNLPBMJJOMM[]), so each cell is upserted; cells not
|
||||
/// sent are left alone.
|
||||
///
|
||||
/// <para><b>Scope note — deliberately not persisted.</b> DorkNet has no
|
||||
/// per-subroom permission storage: <see cref="RoomSceneEntity"/> has no
|
||||
/// column for it and there is no permissions table. Adding one means an
|
||||
/// entity + DbContext + migration, which is outside this file. Until then
|
||||
/// the matrix is accepted, logged, and the room details echoed back so the
|
||||
/// client's promise resolves instead of 415-ing; the values do not survive
|
||||
/// a round-trip. The old handler silently bound an unrelated DTO whose keys
|
||||
/// the client never sends, which was strictly worse.</para></summary>
|
||||
/// <para>Read back through <c>GET photon_access_token</c> (its
|
||||
/// <c>Permissions</c> list), and pushed to everyone in that sub-room as
|
||||
/// the <c>PhotonAccessToken</c> hub message so the change takes effect
|
||||
/// without a rejoin.</para>
|
||||
///
|
||||
/// <para>Authorization mirrors the client's own rule
|
||||
/// (OMJKHJLFOCO.DIIMLMKEJLC): the creator may edit every role, including
|
||||
/// Co-Owner and Creator; a co-owner only the roles below their own;
|
||||
/// nobody else may edit (<see cref="RoomRoleRules.CanEditRolePermissions"/>).</para></summary>
|
||||
[HttpPost("rooms/{roomId:long}/subrooms/{subRoomId:long}/permissions")]
|
||||
[HttpPut("rooms/{roomId:long}/subrooms/{subRoomId:long}/permissions")]
|
||||
[HttpPost("roomserver/rooms/{roomId:long}/subrooms/{subRoomId:long}/permissions")]
|
||||
[HttpPut("roomserver/rooms/{roomId:long}/subrooms/{subRoomId:long}/permissions")]
|
||||
[Authorize]
|
||||
public async Task<IActionResult> SubRoomPermissions(
|
||||
long roomId,
|
||||
long subRoomId,
|
||||
[ModelBinder(typeof(DorkNet.Server.Binding.FormOrJsonModelBinder))] List<SubRoomPermissionEntry> req)
|
||||
[RecNetEnvelope]
|
||||
public async Task<IActionResult> SubRoomPermissions(long roomId, long subRoomId)
|
||||
{
|
||||
subRoomId = RoomSceneIds.ToOrderIndex(subRoomId); // wire id → OrderIndex
|
||||
var pid = this.RequireCurrentPlayerId();
|
||||
var room = await db.Rooms.FirstOrDefaultAsync(r => r.Id == roomId);
|
||||
if (room is null) return NotFound();
|
||||
if (room.CreatorPlayerId != pid) return Forbid();
|
||||
var roleRows = await db.RoomRoles.Where(r => r.RoomId == roomId).ToListAsync();
|
||||
if (!RoomRoleRules.CanEditRoom(room, roleRows, pid)) return Forbid();
|
||||
|
||||
string body;
|
||||
using (var reader = new StreamReader(Request.Body))
|
||||
body = await reader.ReadToEndAsync();
|
||||
var cells = RoomPermissionMatrix.Parse(body);
|
||||
if (cells is null) return BadRequest(new { error = "invalid_permissions_body" });
|
||||
|
||||
var denied = cells.Where(c => !RoomRoleRules.CanEditRolePermissions(room, roleRows, pid, c.Role)).ToList();
|
||||
if (denied.Count > 0)
|
||||
{
|
||||
logger.LogWarning(
|
||||
"[subroom-permissions] denied room={Room} sub={Sub} by={By} roles={Roles}",
|
||||
roomId, subRoomId, pid, string.Join(",", denied.Select(d => d.Role).Distinct()));
|
||||
return Forbid();
|
||||
}
|
||||
|
||||
var scene = await GetOrCreateSceneForMutationAsync(room, subRoomId);
|
||||
if (scene is null) return NotFound();
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
logger.LogWarning(
|
||||
"[subroom-permissions] room={Room} sub={Sub} by={By} entries={Count} — accepted but NOT persisted (no permission storage yet)",
|
||||
roomId, subRoomId, pid, req?.Count ?? 0);
|
||||
var now = DateTime.UtcNow;
|
||||
scene.DataModifiedAt = now;
|
||||
for (var attempt = 1; ; attempt++)
|
||||
{
|
||||
var existing = await db.RoomRolePermissions
|
||||
.Where(p => p.RoomId == roomId && p.SubRoomId == subRoomId)
|
||||
.ToListAsync();
|
||||
foreach (var cell in cells)
|
||||
{
|
||||
// Exact match, like the unique index on
|
||||
// (RoomId, SubRoomId, Role, Permission).
|
||||
var row = existing.FirstOrDefault(p =>
|
||||
p.Role == cell.Role && string.Equals(p.Permission, cell.Permission, StringComparison.Ordinal));
|
||||
if (row is null)
|
||||
{
|
||||
row = new RoomRolePermissionEntity
|
||||
{
|
||||
RoomId = roomId,
|
||||
SubRoomId = subRoomId,
|
||||
Role = cell.Role,
|
||||
Permission = cell.Permission,
|
||||
};
|
||||
db.RoomRolePermissions.Add(row);
|
||||
existing.Add(row);
|
||||
}
|
||||
row.Type = cell.Type;
|
||||
row.Override = cell.Override;
|
||||
row.Value = cell.Value;
|
||||
row.UpdatedByPlayerId = pid;
|
||||
row.UpdatedAt = now;
|
||||
}
|
||||
try
|
||||
{
|
||||
await db.SaveChangesAsync();
|
||||
break;
|
||||
}
|
||||
catch (DbUpdateException) when (attempt < 3)
|
||||
{
|
||||
// Another editor inserted the same new cell between our read
|
||||
// and our insert (unique index). Forget our pending rows and
|
||||
// apply the cells again, now as updates of theirs.
|
||||
foreach (var entry in db.ChangeTracker.Entries<RoomRolePermissionEntity>().ToList())
|
||||
entry.State = EntityState.Detached;
|
||||
}
|
||||
}
|
||||
|
||||
logger.LogInformation(
|
||||
"[subroom-permissions] room={Room} sub={Sub} by={By} cells={Count}",
|
||||
roomId, subRoomId, pid, cells.Count);
|
||||
|
||||
// Everyone in that sub-room gets the new matrix; the client's
|
||||
// PhotonAccessToken handler re-initialises its permissions from it.
|
||||
var matrix = await RoomPermissionMatrix.LoadAsync(db, roomId, subRoomId);
|
||||
foreach (var (instance, players) in presence.EnumerateActiveInstances(roomId, includePrivate: true))
|
||||
{
|
||||
if (instance.SubRoomId != subRoomId) continue;
|
||||
var tokenBody = RoomPermissionMatrix.AccessTokenBody(instance.RoomInstanceId, matrix);
|
||||
foreach (var other in players)
|
||||
await notifications.NotifyTypedAsync(other, "PhotonAccessToken", tokenBody);
|
||||
}
|
||||
|
||||
return Ok(await BuildRoomServerDetailsWithRolesAsync(roomId));
|
||||
}
|
||||
@@ -3840,12 +3935,14 @@ public class RoomsController(
|
||||
[HttpPost("roomserver/rooms/{roomId:long}/subrooms/{subRoomId:long}/publish_save")]
|
||||
[HttpPut("roomserver/rooms/{roomId:long}/subrooms/{subRoomId:long}/publish_save")]
|
||||
[Authorize]
|
||||
[RecNetEnvelope]
|
||||
public async Task<IActionResult> PublishSubRoomSave(long roomId, long subRoomId)
|
||||
{
|
||||
subRoomId = RoomSceneIds.ToOrderIndex(subRoomId); // wire id → OrderIndex
|
||||
var pid = this.RequireCurrentPlayerId();
|
||||
var room = await db.Rooms.FirstOrDefaultAsync(r => r.Id == roomId);
|
||||
if (room is null) return NotFound();
|
||||
if (room.CreatorPlayerId != pid) return Forbid();
|
||||
if (!await CanEditRoomAsync(room, pid)) return Forbid();
|
||||
|
||||
var scene = await GetOrCreateSceneForMutationAsync(room, subRoomId);
|
||||
if (scene is null) return NotFound();
|
||||
@@ -3919,12 +4016,14 @@ public class RoomsController(
|
||||
[HttpPost("rooms/{roomId:long}/subrooms/{subRoomId:long}/clone")]
|
||||
[HttpPost("roomserver/rooms/{roomId:long}/subrooms/{subRoomId:long}/clone")]
|
||||
[Authorize]
|
||||
[RecNetEnvelope]
|
||||
public async Task<IActionResult> CloneSubRoom(long roomId, long subRoomId)
|
||||
{
|
||||
subRoomId = RoomSceneIds.ToOrderIndex(subRoomId); // wire id → OrderIndex
|
||||
var pid = this.RequireCurrentPlayerId();
|
||||
var room = await db.Rooms.FirstOrDefaultAsync(r => r.Id == roomId);
|
||||
if (room is null) return NotFound();
|
||||
if (room.CreatorPlayerId != pid) return Forbid();
|
||||
if (!await CanEditRoomAsync(room, pid)) return Forbid();
|
||||
var src = await db.RoomScenes
|
||||
.FirstOrDefaultAsync(s => s.RoomId == roomId && s.OrderIndex == subRoomId);
|
||||
if (src is null) return NotFound();
|
||||
@@ -3971,6 +4070,7 @@ public class RoomsController(
|
||||
[HttpPost("roomserver/rooms/{roomId:long}/subrooms/{subRoomId:long}/modify")]
|
||||
[HttpPut("roomserver/rooms/{roomId:long}/subrooms/{subRoomId:long}/modify")]
|
||||
[Authorize]
|
||||
[RecNetEnvelope]
|
||||
public Task<IActionResult> ModifySubRoom(
|
||||
long roomId,
|
||||
long subRoomId,
|
||||
@@ -4010,11 +4110,13 @@ public class RoomsController(
|
||||
[HttpPost("roomserver/rooms/{roomId:long}/subrooms/{subRoomId:long}/move")]
|
||||
[HttpPut("roomserver/rooms/{roomId:long}/subrooms/{subRoomId:long}/move")]
|
||||
[Authorize]
|
||||
[RecNetEnvelope]
|
||||
public async Task<IActionResult> MoveSubRoom(
|
||||
long roomId,
|
||||
long subRoomId,
|
||||
[ModelBinder(typeof(DorkNet.Server.Binding.FormOrJsonModelBinder))] SubRoomMoveRequest req)
|
||||
{
|
||||
subRoomId = RoomSceneIds.ToOrderIndex(subRoomId); // wire id → OrderIndex
|
||||
var pid = this.RequireCurrentPlayerId();
|
||||
var room = await db.Rooms.FirstOrDefaultAsync(r => r.Id == roomId);
|
||||
if (room is null) return NotFound();
|
||||
@@ -4105,6 +4207,7 @@ public class RoomsController(
|
||||
[Authorize]
|
||||
public async Task<IActionResult> SubRoomData(long roomId, long subRoomId)
|
||||
{
|
||||
subRoomId = RoomSceneIds.ToOrderIndex(subRoomId); // wire id → OrderIndex
|
||||
var body = await ReadSaveRoomSceneRequestAsync();
|
||||
body.RoomSceneId = subRoomId;
|
||||
return await SaveDataCore(body, roomId, wrapCreateModifyResponse: true);
|
||||
@@ -4165,10 +4268,11 @@ public class RoomsController(
|
||||
/// successful mutation surface as a client-side parse failure.</summary>
|
||||
private async Task<IActionResult> MutateScene(long roomId, long subRoomId, Action<RoomSceneEntity> mutator)
|
||||
{
|
||||
subRoomId = RoomSceneIds.ToOrderIndex(subRoomId); // wire id → OrderIndex
|
||||
var pid = this.RequireCurrentPlayerId();
|
||||
var room = await db.Rooms.FirstOrDefaultAsync(r => r.Id == roomId);
|
||||
if (room is null) return NotFound();
|
||||
if (room.CreatorPlayerId != pid) return Forbid();
|
||||
if (!await CanEditRoomAsync(room, pid)) return Forbid();
|
||||
var scene = await GetOrCreateSceneForMutationAsync(room, subRoomId);
|
||||
if (scene is null) return NotFound();
|
||||
mutator(scene);
|
||||
@@ -4219,12 +4323,7 @@ public class RoomsController(
|
||||
var room = await db.Rooms.FirstOrDefaultAsync(r => r.Id == roomId);
|
||||
if (room is null) return NotFound();
|
||||
var rows = await db.RoomRoles.Where(r => r.RoomId == roomId).ToListAsync();
|
||||
var list = new List<object>(rows.Count + 1)
|
||||
{
|
||||
BuildRoomAccountRoleWire(room.CreatorPlayerId, 30),
|
||||
};
|
||||
list.AddRange(rows.Select(BuildRoomRoleGrantWire));
|
||||
return Ok(list);
|
||||
return Ok(RoomRoleRules.BuildWires(room, rows));
|
||||
}
|
||||
|
||||
private static string CurrentOrSyntheticDataBlobName(RoomEntity room)
|
||||
@@ -4339,30 +4438,38 @@ public class RoomsController(
|
||||
/// <summary>GET <c>rooms/{id}/roles/{playerId}</c> — one role row
|
||||
/// (<c>NLDBPDCNNCF.PCFOIPELKHN</c> → <c>Task<EFHPLDPNGIM></c>,
|
||||
/// NLDBPDCNNCF.txt:2192). roomserver/ twin required, same as the list
|
||||
/// above.</summary>
|
||||
/// above. The creator answers as Creator (255).</summary>
|
||||
[HttpGet("rooms/{roomId:long}/roles/{playerId:long}")]
|
||||
[HttpGet("roomserver/rooms/{roomId:long}/roles/{playerId:long}")]
|
||||
public async Task<IActionResult> RoleForPlayer(long roomId, long playerId)
|
||||
{
|
||||
var room = await db.Rooms.FirstOrDefaultAsync(r => r.Id == roomId);
|
||||
if (room is null) return NotFound();
|
||||
if (room.CreatorPlayerId == playerId)
|
||||
return Ok(BuildRoomAccountRoleWire(playerId, 30));
|
||||
var row = await db.RoomRoles
|
||||
.FirstOrDefaultAsync(r => r.RoomId == roomId && r.PlayerId == playerId);
|
||||
if (row is null) return NotFound();
|
||||
return Ok(BuildRoomRoleGrantWire(row));
|
||||
var rows = await db.RoomRoles
|
||||
.Where(r => r.RoomId == roomId && r.PlayerId == playerId)
|
||||
.ToListAsync();
|
||||
var wire = RoomRoleRules.BuildWires(room, rows)
|
||||
.FirstOrDefault(w => w.AccountId == RoomRoleRules.ToClientAccountId(playerId));
|
||||
if (wire is null) return NotFound();
|
||||
return Ok(wire);
|
||||
}
|
||||
|
||||
public sealed class GrantRoleRequest { public int? Role { get; set; } }
|
||||
|
||||
/// <summary>PUT <c>rooms/{id}/roles/{playerId}</c> — owner grants
|
||||
/// a role to a player. Auto-accepted (no invite step).
|
||||
/// <c>NLDBPDCNNCF.LMIEIJMCEAB</c> ("change room account role",
|
||||
/// NLDBPDCNNCF.txt:7380) is verb 3 = PUT, form key <c>role</c> carrying a
|
||||
/// boxed Int32 (BFKNABDHMGG.txt ISIL 015), and returns the full
|
||||
/// <c>FGCPNAACHIK</c> details — which this already does. Only the
|
||||
/// roomserver/ twin was missing.</summary>
|
||||
/// <summary>PUT <c>rooms/{id}/roles/{playerId}</c> — "change room account
|
||||
/// role" (<c>NLDBPDCNNCF.LMIEIJMCEAB</c>, NLDBPDCNNCF.txt:7516, verb 3 =
|
||||
/// PUT, single form key <c>role</c> boxed as Int32 —
|
||||
/// NLDBPDCNNCF_NestedType_BFKNABDHMGG.txt ISIL 015-024), response the full
|
||||
/// <c>FGCPNAACHIK</c> details. Three callers:
|
||||
/// <list type="bullet">
|
||||
/// <item>the owner's role button (AddRemovePlayerRoomRoleConditionalButton
|
||||
/// .EEGJBELCBDI): Host/Moderator are set directly, and "Remove" sends
|
||||
/// role 0;</item>
|
||||
/// <item>the invitee accepting a co-owner invite (message Type 62,
|
||||
/// KPFKPIKDBFJ.ELKAEBMMIGL / KLNLKDJJDLE Accept): their OWN account id
|
||||
/// with role 30;</item>
|
||||
/// <item>a co-owner rejecting a direct add (Type 60, COJCABEJDFN Reject):
|
||||
/// their own id with role 0.</item>
|
||||
/// </list>
|
||||
/// Authorization is <see cref="RoomRoleRules.CheckChange"/>.</summary>
|
||||
[HttpPut("rooms/{roomId:long}/roles/{playerId:long}")]
|
||||
[HttpPost("rooms/{roomId:long}/roles/{playerId:long}")]
|
||||
[HttpPut("roomserver/rooms/{roomId:long}/roles/{playerId:long}")]
|
||||
@@ -4372,45 +4479,20 @@ public class RoomsController(
|
||||
[HttpPut("/api/rooms/v2/rooms/{roomId:long}/roles/{playerId:long}")]
|
||||
[HttpPost("/api/rooms/v2/rooms/{roomId:long}/roles/{playerId:long}")]
|
||||
[Authorize]
|
||||
public async Task<IActionResult> GrantRole(long roomId, long playerId)
|
||||
{
|
||||
var me = this.RequireCurrentPlayerId();
|
||||
var room = await db.Rooms.FirstOrDefaultAsync(r => r.Id == roomId);
|
||||
if (room is null) return NotFound();
|
||||
if (!await CanManageRoomRolesAsync(room, me)) return Forbid();
|
||||
var role = await ReadClientRoomRoleAsync();
|
||||
if (role is null) return BadRequest(new { error = "missing_role" });
|
||||
if (role.Value == 0)
|
||||
return await ClearRoomAccountRoleAsync(room, playerId);
|
||||
[RecNetEnvelope]
|
||||
public Task<IActionResult> GrantRole(long roomId, long playerId)
|
||||
=> ChangeRoomAccountRoleAsync(roomId, playerId, isInvite: false);
|
||||
|
||||
if (!TryMapClientRoleToRoomRole(role.Value, out var roomRole))
|
||||
return BadRequest(new { error = "invalid_role", role });
|
||||
|
||||
var existing = await db.RoomRoles
|
||||
.FirstOrDefaultAsync(r => r.RoomId == roomId && r.PlayerId == playerId && r.Role == roomRole);
|
||||
if (existing is null)
|
||||
{
|
||||
await RemoveOtherRoomRolesAsync(roomId, playerId, roomRole);
|
||||
db.RoomRoles.Add(new RoomRoleEntity
|
||||
{
|
||||
RoomId = roomId, PlayerId = playerId, Role = roomRole,
|
||||
Accepted = true, GrantedByPlayerId = me,
|
||||
});
|
||||
}
|
||||
else
|
||||
{
|
||||
existing.Accepted = true;
|
||||
}
|
||||
await db.SaveChangesAsync();
|
||||
return Ok(await BuildRoomServerDetailsWithRolesAsync(roomId));
|
||||
}
|
||||
|
||||
/// <summary>POST <c>rooms/{id}/roles/{playerId}/invite</c> — same
|
||||
/// as grant but Accepted=false; the target's accept-invite flow
|
||||
/// flips the flag (separate endpoint, not yet exposed). For now
|
||||
/// invited rows surface in RoomDetails.InvitedCoOwners etc.
|
||||
/// <c>NLDBPDCNNCF.KIJDCLINOIC</c> ("invite account to role",
|
||||
/// NLDBPDCNNCF.txt:7550) is verb 3 = PUT under the roomserver/ prefix.</summary>
|
||||
/// <summary>PUT <c>rooms/{id}/roles/{playerId}/invite</c> — "invite account
|
||||
/// to role" (<c>NLDBPDCNNCF.KIJDCLINOIC</c>, NLDBPDCNNCF.txt:7686, verb 3,
|
||||
/// form key <c>role</c> as Int32 — NLDBPDCNNCF_NestedType_ENHMMCAOING.txt).
|
||||
/// Only Co-Owner goes through an invite in this client: the owner's button
|
||||
/// sends role 30 here, the invitee is told with a Type 62
|
||||
/// (RoomCoOwnerInvited) message carrying RoomId, and either accepts with
|
||||
/// <c>PUT roles/{me}</c> role 30 or rejects by sending role 0 back to THIS
|
||||
/// route on their own id (KPFKPIKDBFJ.OGONAKHNMGN, KLNLKDJJDLE Reject),
|
||||
/// which clears the pending invite but leaves any role they already
|
||||
/// hold.</summary>
|
||||
[HttpPut("rooms/{roomId:long}/roles/{playerId:long}/invite")]
|
||||
[HttpPost("rooms/{roomId:long}/roles/{playerId:long}/invite")]
|
||||
[HttpPut("roomserver/rooms/{roomId:long}/roles/{playerId:long}/invite")]
|
||||
@@ -4420,62 +4502,168 @@ public class RoomsController(
|
||||
[HttpPut("/api/rooms/v2/rooms/{roomId:long}/roles/{playerId:long}/invite")]
|
||||
[HttpPost("/api/rooms/v2/rooms/{roomId:long}/roles/{playerId:long}/invite")]
|
||||
[Authorize]
|
||||
public async Task<IActionResult> InviteRole(long roomId, long playerId)
|
||||
[RecNetEnvelope]
|
||||
public Task<IActionResult> InviteRole(long roomId, long playerId)
|
||||
=> ChangeRoomAccountRoleAsync(roomId, playerId, isInvite: true);
|
||||
|
||||
private async Task<IActionResult> ChangeRoomAccountRoleAsync(long roomId, long playerId, bool isInvite)
|
||||
{
|
||||
var me = this.RequireCurrentPlayerId();
|
||||
var room = await db.Rooms.FirstOrDefaultAsync(r => r.Id == roomId);
|
||||
if (room is null) return NotFound();
|
||||
if (!await CanManageRoomRolesAsync(room, me)) return Forbid();
|
||||
var role = await ReadClientRoomRoleAsync();
|
||||
if (role is null) return BadRequest(new { error = "missing_role" });
|
||||
if (role.Value == 0)
|
||||
return await ClearRoomAccountRoleAsync(room, playerId);
|
||||
var requested = await ReadClientRoomRoleAsync();
|
||||
if (requested is null) return BadRequest(new { error = "missing_role" });
|
||||
if (playerId <= 0 || !await db.Players.AnyAsync(p => p.Id == playerId))
|
||||
return NotFound(new { error = "unknown_player" });
|
||||
|
||||
if (!TryMapClientRoleToRoomRole(role.Value, out var roomRole))
|
||||
return BadRequest(new { error = "invalid_role", role });
|
||||
|
||||
var existing = await db.RoomRoles
|
||||
.FirstOrDefaultAsync(r => r.RoomId == roomId && r.PlayerId == playerId && r.Role == roomRole);
|
||||
if (existing is null)
|
||||
var rows = await db.RoomRoles.Where(r => r.RoomId == roomId).ToListAsync();
|
||||
var error = RoomRoleRules.CheckChange(
|
||||
room, rows, me, playerId, requested.Value, isInvite, out var kind);
|
||||
if (error is not null)
|
||||
{
|
||||
await RemoveOtherRoomRolesAsync(roomId, playerId, roomRole);
|
||||
db.RoomRoles.Add(new RoomRoleEntity
|
||||
logger.LogWarning(
|
||||
"[room-roles] denied room={Room} actor={Actor} target={Target} role={Role} invite={Invite} error={Error}",
|
||||
roomId, me, playerId, requested.Value, isInvite, error);
|
||||
return error == "cannot_change_creator_role" || error == "invalid_role"
|
||||
? BadRequest(new { error, role = requested.Value })
|
||||
: Forbid();
|
||||
}
|
||||
|
||||
var targetRows = rows.Where(r => r.PlayerId == playerId).ToList();
|
||||
var wasCoOwner = targetRows.Any(r => r.Accepted && r.Role == RoomRoleRules.StoredCoOwner);
|
||||
var hadCoOwnerInvite = targetRows.Any(r => !r.Accepted && r.Role == RoomRoleRules.StoredCoOwner);
|
||||
RoomRoleRules.TryFromWire(requested.Value, out var storedRole);
|
||||
|
||||
switch (kind)
|
||||
{
|
||||
case RoomRoleRules.ChangeKind.Clear when isInvite:
|
||||
// Reject / withdraw an invite: only the pending rows go.
|
||||
db.RoomRoles.RemoveRange(targetRows.Where(r => !r.Accepted));
|
||||
break;
|
||||
|
||||
case RoomRoleRules.ChangeKind.Clear:
|
||||
db.RoomRoles.RemoveRange(targetRows);
|
||||
break;
|
||||
|
||||
case RoomRoleRules.ChangeKind.Invite:
|
||||
{
|
||||
RoomId = roomId, PlayerId = playerId, Role = roomRole,
|
||||
Accepted = false, GrantedByPlayerId = me,
|
||||
});
|
||||
// Already holding the role: nothing to invite to. Otherwise
|
||||
// replace any other pending invite but keep the role the
|
||||
// player holds until they accept.
|
||||
if (targetRows.Any(r => r.Accepted && r.Role == storedRole)) break;
|
||||
db.RoomRoles.RemoveRange(targetRows.Where(r => !r.Accepted && r.Role != storedRole));
|
||||
var pending = targetRows.FirstOrDefault(r => !r.Accepted && r.Role == storedRole);
|
||||
if (pending is null)
|
||||
{
|
||||
db.RoomRoles.Add(new RoomRoleEntity
|
||||
{
|
||||
RoomId = roomId, PlayerId = playerId, Role = storedRole,
|
||||
Accepted = false, GrantedByPlayerId = me, GrantedAt = DateTime.UtcNow,
|
||||
});
|
||||
}
|
||||
else
|
||||
{
|
||||
pending.GrantedByPlayerId = me;
|
||||
pending.GrantedAt = DateTime.UtcNow;
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case RoomRoleRules.ChangeKind.AcceptInvite:
|
||||
case RoomRoleRules.ChangeKind.Grant:
|
||||
{
|
||||
// One role per account: the new one replaces the rest
|
||||
// (an accepted invite replaces the role held meanwhile).
|
||||
var keep = targetRows.FirstOrDefault(r => r.Role == storedRole);
|
||||
db.RoomRoles.RemoveRange(targetRows.Where(r => !ReferenceEquals(r, keep)));
|
||||
if (keep is null)
|
||||
{
|
||||
db.RoomRoles.Add(new RoomRoleEntity
|
||||
{
|
||||
RoomId = roomId, PlayerId = playerId, Role = storedRole,
|
||||
Accepted = true,
|
||||
GrantedByPlayerId = kind == RoomRoleRules.ChangeKind.AcceptInvite
|
||||
? targetRows.FirstOrDefault(r => !r.Accepted)?.GrantedByPlayerId ?? me
|
||||
: me,
|
||||
GrantedAt = DateTime.UtcNow,
|
||||
});
|
||||
}
|
||||
else
|
||||
{
|
||||
keep.Accepted = true;
|
||||
if (kind == RoomRoleRules.ChangeKind.Grant) keep.GrantedByPlayerId = me;
|
||||
keep.GrantedAt = DateTime.UtcNow;
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
else
|
||||
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
var isCoOwnerNow = await db.RoomRoles.AnyAsync(r =>
|
||||
r.RoomId == roomId && r.PlayerId == playerId && r.Accepted && r.Role == RoomRoleRules.StoredCoOwner);
|
||||
var hasCoOwnerInviteNow = await db.RoomRoles.AnyAsync(r =>
|
||||
r.RoomId == roomId && r.PlayerId == playerId && !r.Accepted && r.Role == RoomRoleRules.StoredCoOwner);
|
||||
logger.LogInformation(
|
||||
"[room-roles] room={Room} actor={Actor} target={Target} role={Role} invite={Invite} kind={Kind} coOwner={Was}->{Now}",
|
||||
roomId, me, playerId, requested.Value, isInvite, kind, wasCoOwner, isCoOwnerNow);
|
||||
|
||||
// The invitee's notifications are how the role reaches them: the
|
||||
// 2023 client renders message Types 60/61/62 (factory
|
||||
// OCGKOJMFLAI.ALEHDHAEFOK, enum KJECOLODAFA.MEPCALGGMJC) and reads
|
||||
// only RoomId from them. Self-service changes (accept / reject /
|
||||
// leave) notify nobody.
|
||||
if (playerId != me)
|
||||
{
|
||||
existing.Accepted = false;
|
||||
if (!hadCoOwnerInvite && hasCoOwnerInviteNow)
|
||||
await systemNotifications.SendAsync(playerId,
|
||||
SystemNotificationService.MessageType.RoomCoOwnerInvited, me, roomId: roomId);
|
||||
if (!wasCoOwner && isCoOwnerNow)
|
||||
await systemNotifications.SendAsync(playerId,
|
||||
SystemNotificationService.MessageType.RoomCoOwnerAdded, me, roomId: roomId);
|
||||
if (wasCoOwner && !isCoOwnerNow)
|
||||
await systemNotifications.SendAsync(playerId,
|
||||
SystemNotificationService.MessageType.RoomCoOwnerRemoved, me, roomId: roomId);
|
||||
}
|
||||
await db.SaveChangesAsync();
|
||||
return Ok(await BuildRoomServerDetailsWithRolesAsync(roomId));
|
||||
|
||||
if (hadCoOwnerInvite && !hasCoOwnerInviteNow)
|
||||
await MarkCoOwnerInviteMessagesReadAsync(playerId, roomId);
|
||||
|
||||
var details = await BuildRoomServerDetailsWithRolesAsync(roomId);
|
||||
await PushRoomUpdateAsync(roomId, details, playerId, me);
|
||||
return Ok(details);
|
||||
}
|
||||
|
||||
private async Task<IActionResult> ClearRoomAccountRoleAsync(RoomEntity room, long playerId)
|
||||
/// <summary>Once an invite is answered (or withdrawn) its Type 62 message
|
||||
/// must stop offering Accept/Reject in the inbox.</summary>
|
||||
private async Task MarkCoOwnerInviteMessagesReadAsync(long playerId, long roomId)
|
||||
{
|
||||
if (room.CreatorPlayerId == playerId)
|
||||
return BadRequest(new { error = "cannot_change_creator_role" });
|
||||
|
||||
var rows = await db.RoomRoles
|
||||
.Where(r => r.RoomId == room.Id && r.PlayerId == playerId)
|
||||
var stale = await db.Messages
|
||||
.Where(m => m.RecipientPlayerId == playerId
|
||||
&& m.RoomId == roomId
|
||||
&& m.Type == SystemNotificationService.MessageType.RoomCoOwnerInvited
|
||||
&& m.ReadAt == null)
|
||||
.ToListAsync();
|
||||
if (rows.Count > 0)
|
||||
db.RoomRoles.RemoveRange(rows);
|
||||
|
||||
if (stale.Count == 0) return;
|
||||
foreach (var m in stale) m.ReadAt = DateTime.UtcNow;
|
||||
await db.SaveChangesAsync();
|
||||
return Ok(await BuildRoomServerDetailsWithRolesAsync(room.Id));
|
||||
}
|
||||
|
||||
private async Task<bool> CanManageRoomRolesAsync(RoomEntity room, long playerId)
|
||||
/// <summary>Push the fresh room details as <c>RoomUpdate</c> (handler
|
||||
/// IBEOONPEELF.txt:507, Action<FGCPNAACHIK>) to everyone currently
|
||||
/// in the room plus the players whose view changed, so the role list,
|
||||
/// the role-gated buttons and the local player's own role refresh
|
||||
/// without leaving the room.</summary>
|
||||
private async Task PushRoomUpdateAsync(long roomId, object details, params long[] alsoNotify)
|
||||
{
|
||||
if (room.CreatorPlayerId == playerId) return true;
|
||||
return await db.RoomRoles.AnyAsync(r =>
|
||||
r.RoomId == room.Id
|
||||
&& r.PlayerId == playerId
|
||||
&& r.Accepted
|
||||
&& r.Role == 0);
|
||||
var recipients = presence.EnumerateActiveInstances(roomId, includePrivate: true)
|
||||
.SelectMany(i => i.PlayerIds)
|
||||
.Concat(alsoNotify)
|
||||
.Where(pid => pid > 0)
|
||||
.Distinct()
|
||||
.ToList();
|
||||
foreach (var pid in recipients)
|
||||
await notifications.NotifyAsync(pid, PushNotificationId.SubscriptionUpdateRoom, details);
|
||||
}
|
||||
|
||||
private async Task<int?> ReadClientRoomRoleAsync()
|
||||
@@ -4486,6 +4674,8 @@ public class RoomsController(
|
||||
var form = await Request.ReadFormAsync();
|
||||
if (int.TryParse(form["role"].FirstOrDefault(), out parsed)) return parsed;
|
||||
if (int.TryParse(form["Role"].FirstOrDefault(), out parsed)) return parsed;
|
||||
if (TryParseRoleName(form["role"].FirstOrDefault() ?? form["Role"].FirstOrDefault(), out parsed))
|
||||
return parsed;
|
||||
}
|
||||
|
||||
if (int.TryParse(Request.Query["role"].FirstOrDefault(), out parsed)) return parsed;
|
||||
@@ -4495,10 +4685,20 @@ public class RoomsController(
|
||||
{
|
||||
try
|
||||
{
|
||||
var body = await JsonSerializer.DeserializeAsync<GrantRoleRequest>(
|
||||
Request.Body,
|
||||
new JsonSerializerOptions { PropertyNameCaseInsensitive = true });
|
||||
if (body?.Role is int bodyRole) return bodyRole;
|
||||
using var doc = await JsonDocument.ParseAsync(Request.Body);
|
||||
if (doc.RootElement.ValueKind == JsonValueKind.Object)
|
||||
{
|
||||
foreach (var prop in doc.RootElement.EnumerateObject())
|
||||
{
|
||||
if (!prop.Name.Equals("role", StringComparison.OrdinalIgnoreCase)) continue;
|
||||
if (prop.Value.ValueKind == JsonValueKind.Number && prop.Value.TryGetInt32(out parsed))
|
||||
return parsed;
|
||||
if (prop.Value.ValueKind == JsonValueKind.String
|
||||
&& (int.TryParse(prop.Value.GetString(), out parsed)
|
||||
|| TryParseRoleName(prop.Value.GetString(), out parsed)))
|
||||
return parsed;
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
@@ -4509,64 +4709,34 @@ public class RoomsController(
|
||||
return null;
|
||||
}
|
||||
|
||||
private async Task RemoveOtherRoomRolesAsync(long roomId, long playerId, int keepRole)
|
||||
/// <summary>OMMBGJMJJPN member names, for callers that box the enum
|
||||
/// itself (Enum.ToString) instead of an Int32.</summary>
|
||||
internal static bool TryParseRoleName(string? raw, out int role)
|
||||
{
|
||||
var rows = await db.RoomRoles
|
||||
.Where(r => r.RoomId == roomId && r.PlayerId == playerId && r.Role != keepRole)
|
||||
.ToListAsync();
|
||||
if (rows.Count > 0)
|
||||
db.RoomRoles.RemoveRange(rows);
|
||||
}
|
||||
|
||||
private static bool TryMapClientRoleToRoomRole(int clientRole, out int roomRole)
|
||||
{
|
||||
roomRole = clientRole switch
|
||||
role = (raw ?? string.Empty).Trim().ToLowerInvariant() switch
|
||||
{
|
||||
30 => 0, // CoOwner
|
||||
20 => 1, // Moderator
|
||||
10 => 2, // Host
|
||||
"none" => RoomRoleRules.WireNone,
|
||||
"banned" => RoomRoleRules.WireBanned,
|
||||
"host" => RoomRoleRules.WireHost,
|
||||
"moderator" => RoomRoleRules.WireModerator,
|
||||
"coowner" => RoomRoleRules.WireCoOwner,
|
||||
"temporarycoowner" => RoomRoleRules.WireTemporaryCoOwner,
|
||||
"creator" => RoomRoleRules.WireCreator,
|
||||
_ => -1,
|
||||
};
|
||||
return roomRole >= 0;
|
||||
return role >= 0;
|
||||
}
|
||||
|
||||
private static object BuildRoomRoleGrantWire(RoomRoleEntity role)
|
||||
=> BuildRoomAccountRoleWire(
|
||||
role.PlayerId,
|
||||
ToClientRoomRole(role.Role),
|
||||
role.Accepted,
|
||||
role.GrantedByPlayerId);
|
||||
|
||||
private static object BuildRoomAccountRoleWire(
|
||||
long accountId,
|
||||
int role,
|
||||
bool accepted = true,
|
||||
long? lastChangedByAccountId = null)
|
||||
=> new
|
||||
{
|
||||
AccountId = ToClientAccountId(accountId),
|
||||
Role = accepted ? role : 0,
|
||||
LastChangedByAccountId = ToClientAccountId(lastChangedByAccountId),
|
||||
InvitedRole = accepted ? 0 : role,
|
||||
};
|
||||
|
||||
private static int ToClientRoomRole(int roomRole) => roomRole switch
|
||||
/// <summary>Creator or accepted co-owner (see
|
||||
/// <see cref="RoomRoleRules.CanEditRoom"/>).</summary>
|
||||
private async Task<bool> CanEditRoomAsync(RoomEntity room, long playerId)
|
||||
{
|
||||
0 => 30, // CoOwner
|
||||
1 => 20, // Moderator
|
||||
2 => 10, // Host
|
||||
_ => 0,
|
||||
};
|
||||
|
||||
private static int ToClientAccountId(long accountId)
|
||||
=> accountId > int.MaxValue
|
||||
? int.MaxValue
|
||||
: accountId < int.MinValue
|
||||
? int.MinValue
|
||||
: (int)accountId;
|
||||
|
||||
private static int? ToClientAccountId(long? accountId)
|
||||
=> accountId.HasValue ? ToClientAccountId(accountId.Value) : null;
|
||||
if (room.CreatorPlayerId == playerId) return true;
|
||||
var rows = await db.RoomRoles
|
||||
.Where(r => r.RoomId == room.Id && r.PlayerId == playerId)
|
||||
.ToListAsync();
|
||||
return RoomRoleRules.CanEditRoom(room, rows, playerId);
|
||||
}
|
||||
|
||||
private async Task<object> BuildRoomServerDetailsWithRolesAsync(long roomId)
|
||||
{
|
||||
|
||||
@@ -501,6 +501,7 @@ public class RoomsModerationController(
|
||||
[HttpPost("roomserver/rooms/{roomId:long}/description")]
|
||||
[HttpPut("roomserver/rooms/{roomId:long}/description")]
|
||||
[Consumes("application/x-www-form-urlencoded", "multipart/form-data", "application/json")]
|
||||
[RecNetEnvelope]
|
||||
public async Task<IActionResult> BareDescription(long roomId)
|
||||
{
|
||||
// RawValue, not the whitespace-skipping reader: clearing the
|
||||
@@ -520,6 +521,7 @@ public class RoomsModerationController(
|
||||
[HttpPost("roomserver/rooms/{roomId:long}/image")]
|
||||
[HttpPut("roomserver/rooms/{roomId:long}/image")]
|
||||
[Consumes("application/x-www-form-urlencoded", "multipart/form-data", "application/json")]
|
||||
[RecNetEnvelope]
|
||||
public async Task<IActionResult> BareImage(long roomId)
|
||||
{
|
||||
var payload = await ReadPayloadAsync();
|
||||
@@ -541,6 +543,7 @@ public class RoomsModerationController(
|
||||
[HttpPost("roomserver/rooms/{roomId:long}/tags")]
|
||||
[HttpPut("roomserver/rooms/{roomId:long}/tags")]
|
||||
[Consumes("application/x-www-form-urlencoded", "multipart/form-data", "application/json")]
|
||||
[RecNetEnvelope]
|
||||
public async Task<IActionResult> BareTags(long roomId)
|
||||
{
|
||||
var payload = await ReadPayloadAsync();
|
||||
@@ -567,6 +570,7 @@ public class RoomsModerationController(
|
||||
[HttpPost("roomserver/rooms/{roomId:long}/accessibility")]
|
||||
[HttpPut("roomserver/rooms/{roomId:long}/accessibility")]
|
||||
[Consumes("application/x-www-form-urlencoded", "multipart/form-data", "application/json")]
|
||||
[RecNetEnvelope]
|
||||
public async Task<IActionResult> BareAccessibility(long roomId)
|
||||
{
|
||||
var value = await ReadBareIntAsync("accessibility", "Accessibility", "value", "Value");
|
||||
@@ -899,6 +903,7 @@ public class RoomsModerationController(
|
||||
[HttpPost("roomserver/rooms/{roomId:long}/cloning")]
|
||||
[HttpPut("roomserver/rooms/{roomId:long}/cloning")]
|
||||
[Consumes("application/x-www-form-urlencoded", "multipart/form-data", "application/json")]
|
||||
[RecNetEnvelope]
|
||||
public async Task<IActionResult> BareCloning(long roomId)
|
||||
{
|
||||
var value = await ReadBareBoolAsync("cloningAllowed", "CloningAllowed", "value", "Value");
|
||||
@@ -918,6 +923,7 @@ public class RoomsModerationController(
|
||||
[HttpPost("roomserver/rooms/{roomId:long}/automute")]
|
||||
[HttpPut("roomserver/rooms/{roomId:long}/automute")]
|
||||
[Consumes("application/x-www-form-urlencoded", "multipart/form-data", "application/json")]
|
||||
[RecNetEnvelope]
|
||||
public async Task<IActionResult> BareAutomute(long roomId)
|
||||
{
|
||||
var value = await ReadBareBoolAsync("disable", "Disable", "DisableMicAutoMute", "value", "Value");
|
||||
@@ -939,6 +945,7 @@ public class RoomsModerationController(
|
||||
[HttpPost("roomserver/rooms/{roomId:long}/restrictions")]
|
||||
[HttpPut("roomserver/rooms/{roomId:long}/restrictions")]
|
||||
[Consumes("application/x-www-form-urlencoded", "multipart/form-data", "application/json")]
|
||||
[RecNetEnvelope]
|
||||
public async Task<IActionResult> BareRestrictions(long roomId)
|
||||
{
|
||||
var payload = await ReadPayloadAsync();
|
||||
@@ -965,6 +972,7 @@ public class RoomsModerationController(
|
||||
[HttpPost("roomserver/rooms/{roomId:long}/warning")]
|
||||
[HttpPut("roomserver/rooms/{roomId:long}/warning")]
|
||||
[Consumes("application/x-www-form-urlencoded", "multipart/form-data", "application/json")]
|
||||
[RecNetEnvelope]
|
||||
public async Task<IActionResult> BareWarning(long roomId)
|
||||
{
|
||||
var payload = await ReadPayloadAsync();
|
||||
@@ -983,6 +991,7 @@ public class RoomsModerationController(
|
||||
[HttpPost("roomserver/rooms/{roomId:long}/creator")]
|
||||
[HttpPut("roomserver/rooms/{roomId:long}/creator")]
|
||||
[Consumes("application/x-www-form-urlencoded", "multipart/form-data", "application/json")]
|
||||
[RecNetEnvelope]
|
||||
public async Task<IActionResult> BareCreator(long roomId)
|
||||
{
|
||||
var creatorId = await ReadBareLongAsync(
|
||||
@@ -1001,6 +1010,7 @@ public class RoomsModerationController(
|
||||
[HttpPost("roomserver/rooms/{roomId:long}/allow_new_users")]
|
||||
[HttpPut("roomserver/rooms/{roomId:long}/allow_new_users")]
|
||||
[Consumes("application/x-www-form-urlencoded", "multipart/form-data", "application/json")]
|
||||
[RecNetEnvelope]
|
||||
public async Task<IActionResult> BareAllowNewUsers(long roomId)
|
||||
{
|
||||
var value = await ReadBareBoolAsync("allowNewUsers", "AllowNewUsers", "value", "Value");
|
||||
@@ -1015,6 +1025,7 @@ public class RoomsModerationController(
|
||||
[HttpPost("roomserver/rooms/{roomId:long}/min_level")]
|
||||
[HttpPut("roomserver/rooms/{roomId:long}/min_level")]
|
||||
[Consumes("application/x-www-form-urlencoded", "multipart/form-data", "application/json")]
|
||||
[RecNetEnvelope]
|
||||
public async Task<IActionResult> BareMinLevel(long roomId)
|
||||
{
|
||||
var value = await ReadBareIntAsync("minLevel", "MinLevel", "level", "Level", "value", "Value");
|
||||
@@ -1029,6 +1040,7 @@ public class RoomsModerationController(
|
||||
[HttpPost("roomserver/rooms/{roomId:long}/max_player_calculation_mode")]
|
||||
[HttpPut("roomserver/rooms/{roomId:long}/max_player_calculation_mode")]
|
||||
[Consumes("application/x-www-form-urlencoded", "multipart/form-data", "application/json")]
|
||||
[RecNetEnvelope]
|
||||
public async Task<IActionResult> BareMaxPlayerCalculationMode(long roomId)
|
||||
{
|
||||
var value = await ReadBareIntAsync(
|
||||
@@ -1054,6 +1066,7 @@ public class RoomsModerationController(
|
||||
[HttpPost("roomserver/rooms/{roomId:long}/loadscreen")]
|
||||
[HttpPut("roomserver/rooms/{roomId:long}/loadscreen")]
|
||||
[Consumes("application/x-www-form-urlencoded", "multipart/form-data", "application/json")]
|
||||
[RecNetEnvelope]
|
||||
public async Task<IActionResult> BareLoadScreen(long roomId)
|
||||
{
|
||||
var payload = await ReadPayloadAsync();
|
||||
@@ -1085,6 +1098,7 @@ public class RoomsModerationController(
|
||||
[HttpDelete("rooms/{roomId:long}/loadscreen")]
|
||||
[HttpDelete("roomserver/rooms/{roomId:long}/loadscreen")]
|
||||
[Consumes("application/x-www-form-urlencoded", "multipart/form-data", "application/json")]
|
||||
[RecNetEnvelope]
|
||||
public async Task<IActionResult> BareLoadScreenRemove(long roomId)
|
||||
{
|
||||
var imageName = await ReadStringValueAsync("imageName", "ImageName", "name", "Name", "value", "Value")
|
||||
@@ -1121,6 +1135,7 @@ public class RoomsModerationController(
|
||||
[HttpPost("roomserver/rooms/{roomId:long}/promo_images")]
|
||||
[HttpPut("roomserver/rooms/{roomId:long}/promo_images")]
|
||||
[Consumes("application/x-www-form-urlencoded", "multipart/form-data", "application/json")]
|
||||
[RecNetEnvelope]
|
||||
public async Task<IActionResult> BarePromoImagesAdd(long roomId)
|
||||
{
|
||||
var image = await ReadStringValueAsync("imageName", "ImageName", "name", "Name", "value", "Value");
|
||||
@@ -1137,6 +1152,7 @@ public class RoomsModerationController(
|
||||
[HttpDelete("rooms/{roomId:long}/promo_images")]
|
||||
[HttpDelete("roomserver/rooms/{roomId:long}/promo_images")]
|
||||
[Consumes("application/x-www-form-urlencoded", "multipart/form-data", "application/json")]
|
||||
[RecNetEnvelope]
|
||||
public async Task<IActionResult> BarePromoImagesRemove(long roomId)
|
||||
{
|
||||
var image = await ReadStringValueAsync("imageName", "ImageName", "name", "Name", "value", "Value");
|
||||
@@ -1167,6 +1183,7 @@ public class RoomsModerationController(
|
||||
[HttpPost("roomserver/rooms/{roomId:long}/promo_external")]
|
||||
[HttpPut("roomserver/rooms/{roomId:long}/promo_external")]
|
||||
[Consumes("application/x-www-form-urlencoded", "multipart/form-data", "application/json")]
|
||||
[RecNetEnvelope]
|
||||
public async Task<IActionResult> BarePromoExternalAdd(long roomId)
|
||||
{
|
||||
var payload = await ReadPayloadAsync();
|
||||
@@ -1197,6 +1214,7 @@ public class RoomsModerationController(
|
||||
[HttpDelete("rooms/{roomId:long}/promo_external")]
|
||||
[HttpDelete("roomserver/rooms/{roomId:long}/promo_external")]
|
||||
[Consumes("application/x-www-form-urlencoded", "multipart/form-data", "application/json")]
|
||||
[RecNetEnvelope]
|
||||
public async Task<IActionResult> BarePromoExternalRemove(long roomId)
|
||||
{
|
||||
var payload = await ReadPayloadAsync();
|
||||
@@ -1275,6 +1293,7 @@ public class RoomsModerationController(
|
||||
[HttpPost("roomserver/rooms/{roomId:long}/voice_chat_encryption")]
|
||||
[HttpPut("roomserver/rooms/{roomId:long}/voice_chat_encryption")]
|
||||
[Consumes("application/x-www-form-urlencoded", "multipart/form-data", "application/json")]
|
||||
[RecNetEnvelope]
|
||||
public async Task<IActionResult> BareAck(long roomId) =>
|
||||
await ApplyAndReturn(roomId, _ => { /* value not persistable: no column */ });
|
||||
|
||||
@@ -1305,6 +1324,7 @@ public class RoomsModerationController(
|
||||
[HttpPost("roomserver/rooms/{roomId:long}/modify")]
|
||||
[HttpPut("roomserver/rooms/{roomId:long}/modify")]
|
||||
[Consumes("application/x-www-form-urlencoded", "multipart/form-data", "application/json")]
|
||||
[RecNetEnvelope]
|
||||
public async Task<IActionResult> BareModify(long roomId)
|
||||
{
|
||||
var payload = await ReadPayloadAsync();
|
||||
@@ -1432,6 +1452,13 @@ public class RoomsModerationController(
|
||||
// it editable. Safe for admin: the purge gate and "RR Original"
|
||||
// badge both key on CreatorPlayerId == SystemAccountId, so a
|
||||
// user-owned clone is still shown as Custom and stays purgeable.
|
||||
//
|
||||
// The WIRE IsRRO is a different thing: it is false for every
|
||||
// player-owned room (RoomService.WireIsRRO). The 2023 client's
|
||||
// room-load pipeline never reads IsRRO (the baked scene comes
|
||||
// from the sub-room's UnitySceneId, the blob step from DataBlob),
|
||||
// while its owner UI refuses publish and every room setting on
|
||||
// an RRO, which is what kept cloned rooms from being published.
|
||||
IsAGRoom = source.IsAGRoom,
|
||||
IsDormRoom = false,
|
||||
CloningAllowed = source.CloningAllowed,
|
||||
@@ -1509,6 +1536,18 @@ public class RoomsModerationController(
|
||||
CanMatchmakeInto = s.CanMatchmakeInto,
|
||||
DataModifiedAt = DateTime.UtcNow,
|
||||
}).ToList();
|
||||
// A template with no scene rows still has to give the new room a
|
||||
// real entry sub-room (named "Home", from the clone's own location,
|
||||
// blob and capacity). Without it the details served a synthesised
|
||||
// placeholder and Room Settings > Subrooms showed an empty row.
|
||||
if (clonedScenes.Count == 0)
|
||||
{
|
||||
var home = Services.RoomService.NewHomeScene(clone);
|
||||
home.Id = ++nextSceneId;
|
||||
clonedScenes.Add(home);
|
||||
}
|
||||
foreach (var scene in clonedScenes.Where(s => string.IsNullOrWhiteSpace(s.Name)))
|
||||
scene.Name = Services.RoomService.DefaultSubRoomName(scene.OrderIndex);
|
||||
if (clonedScenes.Count > 0)
|
||||
{
|
||||
db.RoomScenes.AddRange(clonedScenes);
|
||||
@@ -1580,8 +1619,11 @@ public class RoomsModerationController(
|
||||
// The scene was created as a shallow copy, so its DataBlobName is
|
||||
// currently the SOURCE's. Either a copy replaces it or it gets
|
||||
// cleared — it must never survive as-is.
|
||||
// A synthesised Home scene (template had no rows) has no source
|
||||
// scene; it starts from the room-level blob it was built with.
|
||||
var sourceBlob = i < sourceScenes.Count ? sourceScenes[i].DataBlobName : target.DataBlobName;
|
||||
var copied = await CopyBlobForCloneAsync(
|
||||
sourceScenes[i].DataBlobName, clone.Id, target.OrderIndex, callerId);
|
||||
sourceBlob, clone.Id, target.OrderIndex, callerId);
|
||||
|
||||
target.DataBlobName = copied ?? string.Empty;
|
||||
target.DataModifiedAt = DateTime.UtcNow;
|
||||
@@ -1892,7 +1934,13 @@ public class RoomsModerationController(
|
||||
/// <see cref="BareClone"/>). The slim RoomService.ToWireRoom has no
|
||||
/// SubRooms/Roles/Tags/LoadScreens/PromoImages/DataBlob, so the strict
|
||||
/// reader's dispose-walk NRE'd on the missing nested lists and every
|
||||
/// mutation reported failure even though it had persisted.</summary>
|
||||
/// mutation reported failure even though it had persisted.
|
||||
///
|
||||
/// <para>That helper is <c>NLDBPDCNNCF.BDBHCEGKGDC<TResult></c>, the
|
||||
/// <c>{success, value, error}</c> envelope reader, so the details are the
|
||||
/// envelope's <c>value</c>: every caller carries
|
||||
/// <see cref="Controllers.RecNetEnvelopeAttribute"/>. A bare details body
|
||||
/// fails with "Exception of type 'CEMNLBKJABA' was thrown."</para></summary>
|
||||
private async Task<object> BuildRoomDetailsAsync(RoomEntity room)
|
||||
{
|
||||
var scenes = await db.RoomScenes
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
using Microsoft.AspNetCore.Authentication;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using DorkNet.Server.Data;
|
||||
@@ -41,6 +42,27 @@ public class AdminLoginController(
|
||||
if (!player.IsAdmin)
|
||||
return StatusCode(403, new { error = "not_admin" });
|
||||
|
||||
return TokenResponse(player);
|
||||
}
|
||||
|
||||
/// <summary>GET api/admin/v1/sso — exchange a single sign-on session
|
||||
/// (<see cref="ExternalSignIn.WebScheme"/>, signed in on this admin host via
|
||||
/// the external provider) for the same token pair <c>login</c> returns.
|
||||
/// Admin rights still come from the account's <c>IsAdmin</c>.</summary>
|
||||
[HttpGet("sso")]
|
||||
public async Task<ActionResult> Sso()
|
||||
{
|
||||
var web = await HttpContext.AuthenticateAsync(ExternalSignIn.WebScheme);
|
||||
if (!web.Succeeded || ExternalSignIn.PlayerId(web.Principal) is not { } playerId)
|
||||
return Unauthorized(new { error = "not_signed_in" });
|
||||
var player = await db.Players.FirstOrDefaultAsync(p => p.Id == playerId);
|
||||
if (player is null) return Unauthorized(new { error = "not_signed_in" });
|
||||
if (!player.IsAdmin) return StatusCode(403, new { error = "not_admin" });
|
||||
return TokenResponse(player);
|
||||
}
|
||||
|
||||
private OkObjectResult TokenResponse(Data.Entities.PlayerEntity player)
|
||||
{
|
||||
var (access, refresh) = authService.GenerateTokenPair(player.Id);
|
||||
return Ok(new
|
||||
{
|
||||
|
||||
@@ -317,6 +317,10 @@ public class GoToController(
|
||||
[FromForm(Name = "JoinMode")] int joinMode = 0,
|
||||
[FromForm(Name = "AdditionalPlayerIds")] List<long>? additionalPlayerIds = null)
|
||||
{
|
||||
// The client sends the wire SubRoomId it read from the room details
|
||||
// (OrderIndex + 1, see RoomSceneIds); everything below works in
|
||||
// OrderIndex.
|
||||
subRoomId = RoomSceneIds.ToOrderIndex(subRoomId);
|
||||
var room = await rooms.GetByIdAsync(roomId);
|
||||
if (room is null)
|
||||
{
|
||||
@@ -1552,9 +1556,25 @@ public class RoomInstanceDto
|
||||
[JsonPropertyName("roomId")]
|
||||
public long RoomId { get; set; }
|
||||
|
||||
[JsonPropertyName("subRoomId")]
|
||||
/// <summary>The sub-room's <see cref="Data.Entities.RoomSceneEntity.OrderIndex"/>
|
||||
/// (0 = the room's first sub-room). Server code reads and writes this;
|
||||
/// the wire carries <see cref="WireSubRoomId"/>.</summary>
|
||||
[JsonIgnore]
|
||||
public long SubRoomId { get; set; }
|
||||
|
||||
/// <summary>The 2023 client's SubRoomId: OrderIndex + 1 (see
|
||||
/// <see cref="Services.RoomSceneIds"/>). It must equal the
|
||||
/// <c>SubRooms[].SubRoomId</c> the room details advertise — the client
|
||||
/// compares the two (<c>SubroomModel.get_SubroomIsLocal</c>) and looks the
|
||||
/// current sub-room up by it. Presence round-trips through this property
|
||||
/// too, so stored JSON stays consistent.</summary>
|
||||
[JsonPropertyName("subRoomId")]
|
||||
public long WireSubRoomId
|
||||
{
|
||||
get => Services.RoomSceneIds.ToWire(SubRoomId);
|
||||
set => SubRoomId = Services.RoomSceneIds.ToOrderIndex(value);
|
||||
}
|
||||
|
||||
// STRING RoomSceneLocationId GUID — foreign-keys to
|
||||
// AGRoomRuntimeConfig.Locations[].ReplicationId. Resolved via
|
||||
// AGRoomSettings.TryGetRoomSceneLocationById (RVA 0x10F57D0). Default
|
||||
|
||||
@@ -140,66 +140,48 @@ public class MatchController(
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>GET <c>photon_access_token</c> — read by the room-permissions
|
||||
/// runtime as <c>ALIIIAGAPND {RoomInstanceId, PhotonAccessToken,
|
||||
/// Permissions}</c> (NLDBPDCNNCF.CEOPCANHPPH, verb 0, issued from the
|
||||
/// rooms client, hence the <c>roomserver/</c> twin).
|
||||
/// <c>Permissions</c> is the role-permission matrix of the sub-room the
|
||||
/// player is in: the defaults plus whatever the room's editors saved with
|
||||
/// <c>PUT rooms/{id}/subrooms/{sub}/permissions</c>
|
||||
/// (<see cref="RoomPermissionMatrix"/>). It must not be empty: with no
|
||||
/// Permissions the client falls into its persisted-role migration path,
|
||||
/// which NREs during first spawn.</summary>
|
||||
[HttpGet("/photon_access_token")]
|
||||
public IActionResult PhotonAccessToken()
|
||||
[HttpGet("/roomserver/photon_access_token")]
|
||||
public async Task<IActionResult> PhotonAccessToken()
|
||||
{
|
||||
var pid = this.RequireCurrentPlayerId();
|
||||
var appId = config["Photon:AppId"] ?? string.Empty;
|
||||
var voiceAppId = config["Photon:VoiceAppId"] ?? string.Empty;
|
||||
var region = (config["Photon:CloudRegion"] ?? "us").ToLowerInvariant();
|
||||
var currentRoom = presence.GetRoom(pid);
|
||||
var permissions = BuildPhotonPermissionSeed();
|
||||
var permissions = currentRoom is null
|
||||
? RoomPermissionMatrix.Merge(Array.Empty<RoomRolePermissionEntity>())
|
||||
: await RoomPermissionMatrix.LoadAsync(db, currentRoom.RoomId, currentRoom.SubRoomId);
|
||||
logger.LogInformation(
|
||||
"[photon-token] player={PlayerId} roomInstance={RoomInstanceId} permissions={PermissionCount}",
|
||||
pid, currentRoom?.RoomInstanceId ?? 0L, permissions.Length);
|
||||
return Ok(new Dictionary<string, object>
|
||||
{
|
||||
// 2023 deserializes this endpoint as ALIIIAGAPND before room
|
||||
// permissions initialization. If Permissions is missing/empty,
|
||||
// OMJKHJLFOCO falls into the persisted-role migration path that
|
||||
// currently NREs during first spawn.
|
||||
["RoomInstanceId"] = currentRoom?.RoomInstanceId ?? 0L,
|
||||
["PhotonAccessToken"] = string.Empty,
|
||||
["Permissions"] = permissions,
|
||||
"[photon-token] player={PlayerId} room={RoomId} sub={SubRoomId} roomInstance={RoomInstanceId} permissions={PermissionCount}",
|
||||
pid, currentRoom?.RoomId ?? 0L, currentRoom?.SubRoomId ?? 0L, currentRoom?.RoomInstanceId ?? 0L, permissions.Count);
|
||||
|
||||
// Existing custom-auth/app-id shape kept for Photon auth callers.
|
||||
["ResultCode"] = 1,
|
||||
["Message"] = string.Empty,
|
||||
["UserId"] = pid.ToString(System.Globalization.CultureInfo.InvariantCulture),
|
||||
["PhotonAppId"] = appId,
|
||||
["PhotonVoiceAppId"] = voiceAppId,
|
||||
["AppId"] = appId,
|
||||
["VoiceAppId"] = voiceAppId,
|
||||
["CloudRegion"] = region,
|
||||
["Region"] = region,
|
||||
["Token"] = string.Empty,
|
||||
});
|
||||
var body = RoomPermissionMatrix.AccessTokenBody(currentRoom?.RoomInstanceId ?? 0L, permissions);
|
||||
// Existing custom-auth/app-id shape kept for Photon auth callers; the
|
||||
// ALIIIAGAPND reader ignores keys it does not register.
|
||||
body["ResultCode"] = 1;
|
||||
body["Message"] = string.Empty;
|
||||
body["UserId"] = pid.ToString(System.Globalization.CultureInfo.InvariantCulture);
|
||||
body["PhotonAppId"] = appId;
|
||||
body["PhotonVoiceAppId"] = voiceAppId;
|
||||
body["AppId"] = appId;
|
||||
body["VoiceAppId"] = voiceAppId;
|
||||
body["CloudRegion"] = region;
|
||||
body["Region"] = region;
|
||||
body["Token"] = string.Empty;
|
||||
return Ok(body);
|
||||
}
|
||||
|
||||
private static object[] BuildPhotonPermissionSeed()
|
||||
=>
|
||||
[
|
||||
BuildPhotonPermission(13, 30), // Can Use Maker Pen
|
||||
BuildPhotonPermission(20, 30), // Can Spawn Inventions
|
||||
BuildPhotonPermission(21, 30), // Can Spawn Consumables
|
||||
BuildPhotonPermission(22, 30), // Can Use Room Reset Button
|
||||
BuildPhotonPermission(23, 30), // Can Use Play Gizmos Toggle
|
||||
BuildPhotonPermission(13, 255), // Creator alias
|
||||
BuildPhotonPermission(20, 255),
|
||||
BuildPhotonPermission(21, 255),
|
||||
BuildPhotonPermission(22, 255),
|
||||
BuildPhotonPermission(23, 255),
|
||||
];
|
||||
|
||||
private static object BuildPhotonPermission(int permission, int role) => new
|
||||
{
|
||||
Permission = permission.ToString(System.Globalization.CultureInfo.InvariantCulture),
|
||||
Role = role,
|
||||
Override = true,
|
||||
Type = 0,
|
||||
Value = "true",
|
||||
};
|
||||
|
||||
[HttpPost("/matchmake/dorm")]
|
||||
public async Task<IActionResult> MatchmakeDorm()
|
||||
{
|
||||
@@ -490,97 +472,125 @@ public class MatchController(
|
||||
return Ok(session);
|
||||
}
|
||||
|
||||
/// <summary>GET <c>match.*/room/{id}/instances</c> — the
|
||||
/// "RoomInstanceBrowser" the watch shows when you tap a room tile.
|
||||
/// Returns the currently-active instances so the player can see
|
||||
/// which one their friends are in (and join it) instead of being
|
||||
/// matchmade into a fresh empty room. Verified URL at
|
||||
/// Matchmaking.txt:6286; response is a
|
||||
/// <c>List<SimpleRoomInstance></c> per
|
||||
/// <c>SimpleRoomInstance.cs</c>: RoomInstanceId/RoomId/SubRoomId,
|
||||
/// IsFull, CreatedAt, PlayerIds, SubroomName, PlayerCount,
|
||||
/// HasModPresent, HashedInstanceId.
|
||||
/// <summary>GET <c>match.*/room/{id}/instances</c> — the room instance
|
||||
/// browser.
|
||||
///
|
||||
/// We derive instances from PlayerPresenceService's per-player
|
||||
/// RoomInstanceDto: every distinct PhotonRoomId in the same RoomId
|
||||
/// is one instance, with the players currently presenced there.
|
||||
/// PrivateInstances are surfaced as well so the inviter and
|
||||
/// invitee see the same room in the browser.</summary>
|
||||
/// <para><b>Client contract.</b> <c>RecNet.Matchmaking.KAIPGDGACGG(long)</c>
|
||||
/// (RecNet.Runtime/RecNet/Matchmaking.txt:13966, route at :14237, verb 0 =
|
||||
/// GET on the matchmaking host) returns <c>List<PNDCMIMEJLD></c>, a bare
|
||||
/// array. The reader (JPNMBICAMEE.txt) takes exactly RoomInstanceId,
|
||||
/// RoomId, SubRoomId, IsFull, CreatedAt and PlayerIds; the post-parse hook
|
||||
/// (PNDCMIMEJLD.FKDDCNLJOLF) derives PlayerCount from PlayerIds.Count,
|
||||
/// SubroomName from the cached room details' SubRooms by SubRoomId, and
|
||||
/// HasModPresent from the room's Roles. The screen
|
||||
/// (RoomInstanceBrowserScreen / RoomInstancePagedGrid) shows every row it
|
||||
/// is given, current instance first then oldest CreatedAt first, and
|
||||
/// renders the age as UtcNow - CreatedAt, so CreatedAt must be UTC.</para>
|
||||
///
|
||||
/// <para><b>What is listed.</b> Every live instance of the room: each
|
||||
/// distinct Photon room in which at least one player is presenced with a
|
||||
/// live activity marker (<see cref="PlayerPresenceService.EnumerateActiveInstances"/>).
|
||||
/// Private instances are included only for callers allowed into them —
|
||||
/// the room creator, accepted role holders, the instance owner, its
|
||||
/// invitees, and players already inside — so another room's invite-only
|
||||
/// match never leaks. Empty instances are not listed: there is nothing to
|
||||
/// join and they are what made the old list stale.</para>
|
||||
///
|
||||
/// <para><b>Fixed defects.</b> The Redis presence path returned nothing at
|
||||
/// all, so the browser was empty on every multi-replica deployment;
|
||||
/// private instances (every user room is born private and has one
|
||||
/// instance per sub-room) were dropped from presence and re-added only
|
||||
/// for their owner with an empty PlayerIds, so they showed 0 players or
|
||||
/// nothing; expired or crashed presences still counted; IsFull was always
|
||||
/// false; and SubRoomId used the OrderIndex, which does not match the
|
||||
/// room details' wire ids.</para></summary>
|
||||
[HttpGet("/room/{roomId:long}/instances")]
|
||||
[AllowAnonymous]
|
||||
public async Task<IActionResult> RoomInstances(long roomId)
|
||||
{
|
||||
var presenced = presence.EnumerateActiveInstances(roomId).ToList();
|
||||
// Only surface the caller's OWN private instances — we never
|
||||
// leak someone else's PhotonRoomId into the browser. The Photon
|
||||
// join check still requires CanJoinAsync, but redacting at the
|
||||
// list level matches the existing RoomInstanceDto.Redact policy.
|
||||
var pid = this.CurrentPlayerId() ?? 0;
|
||||
var privates = pid == 0
|
||||
? new List<Data.Entities.PrivateInstanceEntity>()
|
||||
: await db.PrivateInstances
|
||||
.Where(p => p.RoomId == roomId && p.OwnerPlayerId == pid)
|
||||
.ToListAsync();
|
||||
var room = await db.Rooms.AsNoTracking().FirstOrDefaultAsync(r => r.Id == roomId);
|
||||
if (room is null) return Ok(Array.Empty<object>());
|
||||
|
||||
// Merge: presence rows are authoritative (player currently
|
||||
// there); add any owned private instance the inviter created
|
||||
// but nobody's joined yet so the inviter can still see it.
|
||||
var byKey = new Dictionary<string, SimpleInstance>(StringComparer.Ordinal);
|
||||
foreach (var (room, playerIds) in presenced)
|
||||
var live = presence.EnumerateActiveInstances(roomId, includePrivate: true).ToList();
|
||||
|
||||
var privateIds = live.Where(l => l.Room.IsPrivate).Select(l => l.Room.RoomInstanceId).Distinct().ToList();
|
||||
var privateRows = privateIds.Count == 0
|
||||
? new Dictionary<long, PrivateInstanceEntity>()
|
||||
: await db.PrivateInstances.AsNoTracking()
|
||||
.Where(p => privateIds.Contains(p.Id))
|
||||
.ToDictionaryAsync(p => p.Id);
|
||||
|
||||
var callerHasRole = pid != 0 && (room.CreatorPlayerId == pid
|
||||
|| await db.RoomRoles.AsNoTracking().AnyAsync(r =>
|
||||
r.RoomId == roomId && r.PlayerId == pid && r.Accepted));
|
||||
var invitedTo = pid == 0 || privateIds.Count == 0
|
||||
? new HashSet<long>()
|
||||
: (await db.PrivateInstanceInvitees.AsNoTracking()
|
||||
.Where(i => i.PlayerId == pid && privateIds.Contains(i.PrivateInstanceId))
|
||||
.Select(i => i.PrivateInstanceId)
|
||||
.ToListAsync()).ToHashSet();
|
||||
|
||||
var rows = new List<RoomInstanceRow>();
|
||||
foreach (var (inst, playerIds) in live)
|
||||
{
|
||||
if (string.IsNullOrEmpty(room.PhotonRoomId)) continue;
|
||||
byKey[room.PhotonRoomId] = new SimpleInstance(
|
||||
RoomInstanceId: room.RoomInstanceId,
|
||||
RoomId: room.RoomId,
|
||||
SubRoomId: room.SubRoomId,
|
||||
IsFull: false,
|
||||
CreatedAt: DateTime.UtcNow,
|
||||
PlayerIds: playerIds.Select(p => (int)p).ToList(),
|
||||
SubroomName: room.Name ?? string.Empty,
|
||||
PlayerCount: playerIds.Count,
|
||||
HasModPresent: false,
|
||||
HashedInstanceId: room.PhotonRoomId);
|
||||
}
|
||||
foreach (var p in privates)
|
||||
{
|
||||
if (string.IsNullOrEmpty(p.PhotonRoomId) || byKey.ContainsKey(p.PhotonRoomId)) continue;
|
||||
byKey[p.PhotonRoomId] = new SimpleInstance(
|
||||
RoomInstanceId: p.Id,
|
||||
RoomId: p.RoomId,
|
||||
SubRoomId: p.SubRoomId,
|
||||
IsFull: false,
|
||||
CreatedAt: DateTime.SpecifyKind(p.CreatedAt, DateTimeKind.Utc),
|
||||
PlayerIds: new List<int>(),
|
||||
SubroomName: p.Name,
|
||||
PlayerCount: 0,
|
||||
HasModPresent: false,
|
||||
HashedInstanceId: p.PhotonRoomId);
|
||||
privateRows.TryGetValue(inst.RoomInstanceId, out var privateRow);
|
||||
if (inst.IsPrivate && !CanSeePrivateInstance(
|
||||
pid, callerHasRole, playerIds, privateRow, invitedTo.Contains(inst.RoomInstanceId)))
|
||||
continue;
|
||||
|
||||
var capacity = inst.MaxCapacity > 0 ? inst.MaxCapacity : privateRow?.MaxCapacity ?? 0;
|
||||
rows.Add(new RoomInstanceRow(
|
||||
RoomInstanceId: inst.RoomInstanceId,
|
||||
RoomId: inst.RoomId,
|
||||
SubRoomId: inst.SubRoomId,
|
||||
IsFull: capacity > 0 && playerIds.Count >= capacity,
|
||||
CreatedAt: privateRow is not null
|
||||
? DateTime.SpecifyKind(privateRow.CreatedAt, DateTimeKind.Utc)
|
||||
: DateTime.UtcNow,
|
||||
PlayerIds: playerIds.Distinct().OrderBy(p => p).ToList()));
|
||||
}
|
||||
|
||||
// Wire shape: SimpleRoomInstance.Deserialize reads CAMELCASE
|
||||
// keys — verified at Cpp2IL_ISIL/.../RecNet/SimpleRoomInstance.txt
|
||||
// lines 765-808 ({roomInstanceId, roomId, subRoomId, isFull,
|
||||
// createdAt, playerIds}). SubroomName / PlayerCount /
|
||||
// HasModPresent / HashedInstanceId are populated locally on the
|
||||
// watch (PlayerIds.Count, local hash, etc.) — not read from
|
||||
// JSON, so we don't send them. Earlier PascalCase response made
|
||||
// every field default to 0/null on the watch, breaking the
|
||||
// browser.
|
||||
return Ok(byKey.Values.Select(i => new
|
||||
{
|
||||
roomInstanceId = i.RoomInstanceId,
|
||||
roomId = i.RoomId,
|
||||
subRoomId = i.SubRoomId,
|
||||
isFull = i.IsFull,
|
||||
createdAt = i.CreatedAt,
|
||||
playerIds = i.PlayerIds.ToArray(),
|
||||
}));
|
||||
logger.LogInformation(
|
||||
"[room-instances] room={Room} caller={Caller} live={Live} listed={Listed}",
|
||||
roomId, pid, live.Count, rows.Count);
|
||||
return Ok(rows.OrderBy(r => r.CreatedAt).Select(ToWire));
|
||||
}
|
||||
|
||||
private sealed record SimpleInstance(
|
||||
/// <summary>Who may see (and so be offered a join into) a private
|
||||
/// instance in the browser.</summary>
|
||||
public static bool CanSeePrivateInstance(
|
||||
long callerId,
|
||||
bool callerHasRoomRole,
|
||||
IReadOnlyCollection<long> playersInside,
|
||||
PrivateInstanceEntity? privateRow,
|
||||
bool callerInvited)
|
||||
{
|
||||
if (callerId == 0) return false;
|
||||
if (callerHasRoomRole) return true;
|
||||
if (playersInside.Contains(callerId)) return true;
|
||||
if (privateRow is not null && privateRow.OwnerPlayerId == callerId) return true;
|
||||
return callerInvited;
|
||||
}
|
||||
|
||||
internal sealed record RoomInstanceRow(
|
||||
long RoomInstanceId, long RoomId, long SubRoomId, bool IsFull,
|
||||
DateTime CreatedAt, List<int> PlayerIds, string SubroomName,
|
||||
int PlayerCount, bool HasModPresent, string HashedInstanceId);
|
||||
DateTime CreatedAt, List<long> PlayerIds);
|
||||
|
||||
/// <summary>PNDCMIMEJLD wire row. Account ids are Int32 on this DTO;
|
||||
/// SubRoomId is the wire id the room details advertise
|
||||
/// (<see cref="RoomSceneIds"/>), which the client uses to name the row.
|
||||
/// The reader matches PascalCase, camelCase and lowercase keys; camelCase
|
||||
/// is what this route has always sent.</summary>
|
||||
internal static object ToWire(RoomInstanceRow r) => new
|
||||
{
|
||||
roomInstanceId = r.RoomInstanceId,
|
||||
roomId = r.RoomId,
|
||||
subRoomId = RoomSceneIds.ToWire(r.SubRoomId),
|
||||
isFull = r.IsFull,
|
||||
createdAt = r.CreatedAt,
|
||||
playerIds = r.PlayerIds.Select(p => (int)p).ToArray(),
|
||||
};
|
||||
|
||||
/// <summary>DELETE <c>match.*/invite/{id}</c> — Matchmaking.cs
|
||||
/// DeclineRoomInvite. Verified URL at Matchmaking.txt:6505 (method
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
using System.Text.Json;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.AspNetCore.Mvc.Filters;
|
||||
|
||||
namespace DorkNet.Server.Controllers;
|
||||
|
||||
/// <summary>
|
||||
/// Wraps an action's reply in the legacy <c>{success, value, error}</c>
|
||||
/// envelope the 2023 rooms client reads for every room and playlist mutation.
|
||||
///
|
||||
/// <para><b>Client contract.</b> All of these calls go through one generic
|
||||
/// helper, <c>NLDBPDCNNCF.BDBHCEGKGDC<TResult>(string opName, HTTPMethods,
|
||||
/// string route, …)</c> (RVA 0x35E50A0, recnet-runtime-decomp/NLDBPDCNNCF.cs:2310;
|
||||
/// called as <c>Call 0x1835E50A0</c> 47 times in
|
||||
/// IsilDump/RecNet.Runtime/NLDBPDCNNCF.txt). It is the same helper, with the
|
||||
/// same <c>BDBHCEGKGDC<FGCPNAACHIK></c> instantiation token
|
||||
/// <c>[0x1884C1948]</c>, that "clone room" uses (NLDBPDCNNCF.txt:4815-4823), and
|
||||
/// clone is proven in game to need <c>{success, value, error}</c>
|
||||
/// (docs/recroom-2023-room-save.md). A bare room-details body has no
|
||||
/// <c>success</c>, so the helper rejects it with no message and the player sees
|
||||
/// <c>Exception of type 'CEMNLBKJABA' was thrown.</c> even though the server
|
||||
/// applied the change. The routes are: rooms/{0}/{accessibility,
|
||||
/// allow_new_users, automute, cloning, comments, creator, description, image,
|
||||
/// loadscreen, max_player_calculation_mode, min_level, modify, name,
|
||||
/// promo_external, promo_images, restrictions, tags, voice_chat_encryption,
|
||||
/// warning}, rooms/{0}/roles/{1}[/invite], rooms/{0}/subrooms[/{1}[/accessibility,
|
||||
/// clone, maxplayers, modify, move, name, permissions, publish_save]],
|
||||
/// playlists and playlists/{0}/{accessibility, description, image, levelvoting,
|
||||
/// name, restrictions, rooms/{1}, tags, warning}.</para>
|
||||
///
|
||||
/// <para>A 2xx object result becomes <c>{success:true, value, error:""}</c>.
|
||||
/// Anything else becomes HTTP 200 <c>{success:false, value:null, error}</c>
|
||||
/// with a message the client can show, because a non-2xx reply also surfaces
|
||||
/// only as the message-less CEMNLBKJABA.</para>
|
||||
/// </summary>
|
||||
[AttributeUsage(AttributeTargets.Method | AttributeTargets.Class)]
|
||||
public sealed class RecNetEnvelopeAttribute : ResultFilterAttribute
|
||||
{
|
||||
public override void OnResultExecuting(ResultExecutingContext context)
|
||||
{
|
||||
context.Result = Wrap(context.Result);
|
||||
base.OnResultExecuting(context);
|
||||
}
|
||||
|
||||
public static IActionResult Wrap(IActionResult result)
|
||||
{
|
||||
switch (result)
|
||||
{
|
||||
case ObjectResult obj when obj.StatusCode is null or (>= 200 and < 300):
|
||||
return IsEnvelope(obj.Value)
|
||||
? result
|
||||
: Success(obj.Value);
|
||||
case ObjectResult obj:
|
||||
return Failure(ErrorText(obj.Value) ?? DefaultError(obj.StatusCode ?? 500));
|
||||
case ForbidResult or ChallengeResult or UnauthorizedResult:
|
||||
return Failure(DefaultError(403));
|
||||
case StatusCodeResult code when code.StatusCode is >= 200 and < 300:
|
||||
return Success(null);
|
||||
case StatusCodeResult code:
|
||||
return Failure(DefaultError(code.StatusCode));
|
||||
case EmptyResult:
|
||||
return Success(null);
|
||||
default:
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
public static OkObjectResult Success(object? value) =>
|
||||
new(new { success = true, value, error = string.Empty });
|
||||
|
||||
public static OkObjectResult Failure(string error) =>
|
||||
new(new { success = false, value = (object?)null, error });
|
||||
|
||||
private static string DefaultError(int status) => status switch
|
||||
{
|
||||
400 => "That request was not valid.",
|
||||
401 or 403 => "You don't have permission to do that.",
|
||||
404 => "That room doesn't exist.",
|
||||
409 => "That name is already taken.",
|
||||
_ => "Something went wrong.",
|
||||
};
|
||||
|
||||
/// <summary>An <c>error</c>/<c>Error</c> string on the failure body, if any.</summary>
|
||||
private static string? ErrorText(object? value)
|
||||
{
|
||||
if (value is null) return null;
|
||||
if (value is string s) return string.IsNullOrWhiteSpace(s) ? null : s;
|
||||
try
|
||||
{
|
||||
using var doc = JsonDocument.Parse(JsonSerializer.Serialize(value));
|
||||
if (doc.RootElement.ValueKind != JsonValueKind.Object) return null;
|
||||
foreach (var prop in doc.RootElement.EnumerateObject())
|
||||
{
|
||||
if (!prop.Name.Equals("error", StringComparison.OrdinalIgnoreCase)) continue;
|
||||
if (prop.Value.ValueKind == JsonValueKind.String)
|
||||
{
|
||||
var text = prop.Value.GetString();
|
||||
return string.IsNullOrWhiteSpace(text) ? null : text;
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (NotSupportedException)
|
||||
{
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private static bool IsEnvelope(object? value)
|
||||
{
|
||||
if (value is null) return false;
|
||||
var props = value.GetType().GetProperties();
|
||||
bool Has(string name) => props.Any(p => p.Name.Equals(name, StringComparison.OrdinalIgnoreCase));
|
||||
return Has("success") && Has("value") && Has("error");
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
using Microsoft.AspNetCore.Authentication;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using DorkNet.Server.Services;
|
||||
@@ -24,6 +25,12 @@ public class DownloadsController(DownloadsService downloads, DomainConfig domain
|
||||
private bool Available => downloads.IsConfigured
|
||||
&& string.Equals(Request.Host.Host, domain.Apex, StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
/// <summary>With <see cref="DownloadsService.RequireSignIn"/>, only a
|
||||
/// signed-in, linked player may read the builds.</summary>
|
||||
private async Task<bool> SignedInOrOpenAsync() =>
|
||||
!downloads.RequireSignIn
|
||||
|| (await HttpContext.AuthenticateAsync(ExternalSignIn.WebScheme)).Succeeded;
|
||||
|
||||
/// <summary>GET /api/site/v1/downloads — the manifest as published
|
||||
/// (snake_case keys), or 404 when downloads aren't set up or the request
|
||||
/// isn't for the site's host.</summary>
|
||||
@@ -31,6 +38,7 @@ public class DownloadsController(DownloadsService downloads, DomainConfig domain
|
||||
public async Task<IActionResult> GetManifest(CancellationToken ct)
|
||||
{
|
||||
if (!Available) return NotFound(new { error = "Downloads aren't set up on this server." });
|
||||
if (!await SignedInOrOpenAsync()) return Unauthorized(new { error = "sign_in_required" });
|
||||
var manifest = await downloads.GetManifestAsync(ct);
|
||||
Response.Headers.CacheControl = "no-cache";
|
||||
return Content((manifest ?? new System.Text.Json.Nodes.JsonObject { ["releases"] = new System.Text.Json.Nodes.JsonArray() })
|
||||
@@ -43,6 +51,7 @@ public class DownloadsController(DownloadsService downloads, DomainConfig domain
|
||||
public async Task GetFile(string path, CancellationToken ct)
|
||||
{
|
||||
if (!Available) { Response.StatusCode = StatusCodes.Status404NotFound; return; }
|
||||
if (!await SignedInOrOpenAsync()) { Response.StatusCode = StatusCodes.Status401Unauthorized; return; }
|
||||
var manifest = await downloads.GetManifestAsync(ct);
|
||||
var entry = manifest is null ? null : DownloadsService.FindFile(manifest, path);
|
||||
if (entry is null) { Response.StatusCode = StatusCodes.Status404NotFound; return; }
|
||||
|
||||
@@ -0,0 +1,178 @@
|
||||
using Microsoft.AspNetCore.Authentication;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.Extensions.Options;
|
||||
using DorkNet.Server.Services;
|
||||
|
||||
namespace DorkNet.Server.Controllers.Site;
|
||||
|
||||
/// <summary>
|
||||
/// Sign-in with the external OpenID Connect provider (<see cref="ExternalSignInOptions"/>)
|
||||
/// for the website and the admin panel. The game client's login is separate.
|
||||
///
|
||||
/// <para>Flow: <c>login</c> challenges the provider; its callback
|
||||
/// (<see cref="ExternalSignIn.CallbackPath"/>, handled by the OpenID Connect
|
||||
/// middleware) stores the provider identity in the short-lived
|
||||
/// <c>DorkNet.External</c> cookie and lands on <c>complete</c>. A linked identity
|
||||
/// becomes a <c>DorkNet.Web</c> session at once; an unlinked one goes to the
|
||||
/// website's <c>/link</c> page, where the player proves the account with its
|
||||
/// password once (<c>account/link</c>).</para>
|
||||
///
|
||||
/// <para>Each host signs in on its own (website and admin panel), because the
|
||||
/// hyphen host style puts them under different cookie domains.</para>
|
||||
/// </summary>
|
||||
[ApiController]
|
||||
[AllowAnonymous]
|
||||
public class ExternalSignInController(
|
||||
IOptions<ExternalSignInOptions> options,
|
||||
ExternalSignInService signIn,
|
||||
LinkAttemptLimiter limiter,
|
||||
DomainConfig domain,
|
||||
ILogger<ExternalSignInController> log) : ControllerBase
|
||||
{
|
||||
private ExternalSignInOptions Options => options.Value;
|
||||
|
||||
/// <summary>GET /api/auth/external/login?returnUrl=/path — start sign-in.</summary>
|
||||
[HttpGet(ExternalSignIn.LoginPath)]
|
||||
public IActionResult Login([FromQuery] string? returnUrl)
|
||||
{
|
||||
if (!Options.IsConfigured) return NotFound(new { error = "sign_in_not_configured" });
|
||||
var complete = $"{ExternalSignIn.CompletePath}?returnUrl={Uri.EscapeDataString(LocalUrl(returnUrl))}";
|
||||
return Challenge(new AuthenticationProperties { RedirectUri = complete }, ExternalSignIn.OidcScheme);
|
||||
}
|
||||
|
||||
/// <summary>GET /api/auth/external/complete — after the provider's callback.</summary>
|
||||
[HttpGet(ExternalSignIn.CompletePath)]
|
||||
public async Task<IActionResult> Complete([FromQuery] string? returnUrl)
|
||||
{
|
||||
returnUrl = LocalUrl(returnUrl);
|
||||
var external = await HttpContext.AuthenticateAsync(ExternalSignIn.ExternalScheme);
|
||||
if (!external.Succeeded || ExternalIdentity(external.Principal) is not { } identity)
|
||||
return Redirect(WithQuery(returnUrl, "signin_error", "no_identity"));
|
||||
var (issuer, subject, email) = identity;
|
||||
|
||||
var link = await signIn.FindAsync(issuer, subject);
|
||||
var player = link is null ? null : await signIn.ActivePlayerAsync(link);
|
||||
if (link is not null && player is not null)
|
||||
{
|
||||
await signIn.TouchAsync(link, email);
|
||||
await HttpContext.SignInAsync(ExternalSignIn.WebScheme, ExternalSignIn.PlayerPrincipal(player, link));
|
||||
await HttpContext.SignOutAsync(ExternalSignIn.ExternalScheme);
|
||||
log.LogInformation("[signin] player={Player} via {Issuer}", player.Id, issuer);
|
||||
return Redirect(returnUrl);
|
||||
}
|
||||
if (link is not null)
|
||||
return Redirect(WithQuery(returnUrl, "signin_error", "account_unavailable"));
|
||||
|
||||
// Not linked yet. Linking happens on the website; the admin panel
|
||||
// tells its user to link there first.
|
||||
if (IsAdminHost())
|
||||
return Redirect(WithQuery(returnUrl, "signin_error", "not_linked"));
|
||||
return Redirect($"/link?returnUrl={Uri.EscapeDataString(returnUrl)}");
|
||||
}
|
||||
|
||||
/// <summary>GET /api/site/v1/account/me — who is signed in, or which
|
||||
/// provider identity is waiting to be linked.</summary>
|
||||
[HttpGet("api/site/v1/account/me")]
|
||||
public async Task<IActionResult> Me()
|
||||
{
|
||||
var web = await HttpContext.AuthenticateAsync(ExternalSignIn.WebScheme);
|
||||
var external = await HttpContext.AuthenticateAsync(ExternalSignIn.ExternalScheme);
|
||||
var pending = external.Succeeded ? ExternalIdentity(external.Principal) : null;
|
||||
return Ok(new
|
||||
{
|
||||
signInAvailable = Options.IsConfigured,
|
||||
provider = Options.DisplayName,
|
||||
signedIn = web.Succeeded,
|
||||
player = web.Succeeded
|
||||
? new
|
||||
{
|
||||
id = ExternalSignIn.PlayerId(web.Principal)?.ToString(),
|
||||
username = web.Principal!.Identity?.Name,
|
||||
email = web.Principal.FindFirst(ExternalSignIn.EmailClaim)?.Value,
|
||||
}
|
||||
: null,
|
||||
pendingLink = pending is { } p ? new { email = p.Email } : null,
|
||||
});
|
||||
}
|
||||
|
||||
public sealed record LinkRequest(string? Username, string? Password);
|
||||
|
||||
/// <summary>POST /api/site/v1/account/link {username, password} — link the
|
||||
/// pending provider identity to the account these credentials prove.</summary>
|
||||
[HttpPost("api/site/v1/account/link")]
|
||||
public async Task<IActionResult> Link([FromBody] LinkRequest body)
|
||||
{
|
||||
var external = await HttpContext.AuthenticateAsync(ExternalSignIn.ExternalScheme);
|
||||
if (!external.Succeeded || ExternalIdentity(external.Principal) is not { } identity)
|
||||
return Unauthorized(new { error = "no_pending_identity" });
|
||||
var (issuer, subject, email) = identity;
|
||||
var username = body.Username?.Trim() ?? string.Empty;
|
||||
if (username.Length == 0 || string.IsNullOrEmpty(body.Password))
|
||||
return BadRequest(new { error = "missing_credentials" });
|
||||
|
||||
var keys = new[] { $"sub:{issuer}|{subject}", $"user:{username.ToLowerInvariant()}" };
|
||||
if (limiter.IsBlocked(keys))
|
||||
return StatusCode(StatusCodes.Status429TooManyRequests, new { error = "too_many_attempts" });
|
||||
|
||||
var (outcome, player, link) = await signIn.LinkAsync(issuer, subject, email, username, body.Password);
|
||||
switch (outcome)
|
||||
{
|
||||
case LinkOutcome.Linked:
|
||||
limiter.Reset(keys);
|
||||
await HttpContext.SignInAsync(ExternalSignIn.WebScheme, ExternalSignIn.PlayerPrincipal(player!, link!));
|
||||
await HttpContext.SignOutAsync(ExternalSignIn.ExternalScheme);
|
||||
log.LogInformation("[signin] linked player={Player} to {Issuer}", player!.Id, issuer);
|
||||
return Ok(new { linked = true, username = player.Username });
|
||||
case LinkOutcome.InvalidCredentials:
|
||||
limiter.RecordFailure(keys);
|
||||
return Unauthorized(new { error = "invalid_credentials" });
|
||||
case LinkOutcome.Banned:
|
||||
return StatusCode(StatusCodes.Status403Forbidden, new { error = "banned" });
|
||||
default:
|
||||
return Conflict(new { error = "identity_linked_to_another_account" });
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>POST /api/site/v1/account/unlink — remove this provider identity
|
||||
/// from the account and sign out.</summary>
|
||||
[HttpPost("api/site/v1/account/unlink")]
|
||||
public async Task<IActionResult> Unlink()
|
||||
{
|
||||
var web = await HttpContext.AuthenticateAsync(ExternalSignIn.WebScheme);
|
||||
if (!web.Succeeded || ExternalSignIn.PlayerId(web.Principal) is not { } playerId)
|
||||
return Unauthorized(new { error = "not_signed_in" });
|
||||
var issuer = web.Principal!.FindFirst(ExternalSignIn.IssuerClaim)?.Value ?? string.Empty;
|
||||
await signIn.UnlinkAsync(playerId, issuer);
|
||||
await HttpContext.SignOutAsync(ExternalSignIn.WebScheme);
|
||||
return Ok(new { unlinked = true });
|
||||
}
|
||||
|
||||
/// <summary>POST /api/site/v1/account/logout — end the session here.</summary>
|
||||
[HttpPost("api/site/v1/account/logout")]
|
||||
public async Task<IActionResult> Logout()
|
||||
{
|
||||
await HttpContext.SignOutAsync(ExternalSignIn.WebScheme);
|
||||
await HttpContext.SignOutAsync(ExternalSignIn.ExternalScheme);
|
||||
return Ok(new { signedOut = true });
|
||||
}
|
||||
|
||||
/// <summary>(issuer, subject, email) from the provider's claims. The
|
||||
/// subject is the account key; the email is display only.</summary>
|
||||
private (string Issuer, string Subject, string Email)? ExternalIdentity(System.Security.Claims.ClaimsPrincipal? p)
|
||||
{
|
||||
var subject = p?.FindFirst("sub")?.Value;
|
||||
if (string.IsNullOrEmpty(subject)) return null;
|
||||
var issuer = p!.FindFirst("iss")?.Value ?? Options.Authority;
|
||||
return (issuer.TrimEnd('/'), subject, p.FindFirst("email")?.Value ?? string.Empty);
|
||||
}
|
||||
|
||||
private string LocalUrl(string? url) =>
|
||||
!string.IsNullOrEmpty(url) && Url.IsLocalUrl(url) ? url : "/";
|
||||
|
||||
private static string WithQuery(string url, string key, string value) =>
|
||||
url + (url.Contains('?') ? "&" : "?") + key + "=" + Uri.EscapeDataString(value);
|
||||
|
||||
private bool IsAdminHost() =>
|
||||
string.Equals(Request.Host.Host, domain.Sub("admin"), StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
@@ -1,9 +1,10 @@
|
||||
using Microsoft.AspNetCore.DataProtection.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using DorkNet.Server.Data.Entities;
|
||||
|
||||
namespace DorkNet.Server.Data;
|
||||
|
||||
public class DorkNetDbContext(DbContextOptions<DorkNetDbContext> options) : DbContext(options)
|
||||
public class DorkNetDbContext(DbContextOptions<DorkNetDbContext> options) : DbContext(options), IDataProtectionKeyContext
|
||||
{
|
||||
public DbSet<PlayerEntity> Players => Set<PlayerEntity>();
|
||||
public DbSet<AvatarEntity> Avatars => Set<AvatarEntity>();
|
||||
@@ -11,6 +12,7 @@ public class DorkNetDbContext(DbContextOptions<DorkNetDbContext> options) : DbCo
|
||||
public DbSet<RelationshipEntity> Relationships => Set<RelationshipEntity>();
|
||||
public DbSet<RoomEntity> Rooms => Set<RoomEntity>();
|
||||
public DbSet<RoomRoleEntity> RoomRoles => Set<RoomRoleEntity>();
|
||||
public DbSet<RoomRolePermissionEntity> RoomRolePermissions => Set<RoomRolePermissionEntity>();
|
||||
public DbSet<LeaderboardChannelMetaEntity> LeaderboardChannelMeta => Set<LeaderboardChannelMetaEntity>();
|
||||
public DbSet<RoomBookmarkEntity> RoomBookmarks => Set<RoomBookmarkEntity>();
|
||||
public DbSet<RoomDataBlobEntity> RoomDataBlobs => Set<RoomDataBlobEntity>();
|
||||
@@ -87,9 +89,19 @@ public class DorkNetDbContext(DbContextOptions<DorkNetDbContext> options) : DbCo
|
||||
public DbSet<UgcPurchasableEntity> UgcPurchasables => Set<UgcPurchasableEntity>();
|
||||
public DbSet<RoomConsumableEntity> RoomConsumables => Set<RoomConsumableEntity>();
|
||||
public DbSet<RoomConsumableOwnershipEntity> RoomConsumableOwnership => Set<RoomConsumableOwnershipEntity>();
|
||||
public DbSet<ExternalLoginEntity> ExternalLogins => Set<ExternalLoginEntity>();
|
||||
// ASP.NET Core data-protection keys (sign-in cookies), kept in the
|
||||
// database so a redeploy or a second replica doesn't sign everyone out.
|
||||
public DbSet<DataProtectionKey> DataProtectionKeys => Set<DataProtectionKey>();
|
||||
|
||||
protected override void OnModelCreating(ModelBuilder modelBuilder)
|
||||
{
|
||||
modelBuilder.Entity<ExternalLoginEntity>(e =>
|
||||
{
|
||||
e.HasIndex(x => new { x.Issuer, x.Subject }).IsUnique();
|
||||
e.HasIndex(x => x.PlayerId);
|
||||
});
|
||||
|
||||
// SQLite-specific NOCASE collation works via UseCollation; Postgres
|
||||
// doesn't have NOCASE. Provider-conditional helper applied to the
|
||||
// three string columns the wire API matches case-insensitively
|
||||
@@ -134,6 +146,14 @@ public class DorkNetDbContext(DbContextOptions<DorkNetDbContext> options) : DbCo
|
||||
e.HasIndex(p => new { p.ClubId, p.MembershipType }).IsUnique();
|
||||
});
|
||||
|
||||
// One cell per (room, sub-room, role, permission): the sub-room
|
||||
// permissions PUT upserts the cells it was sent.
|
||||
modelBuilder.Entity<RoomRolePermissionEntity>(e =>
|
||||
{
|
||||
e.HasKey(p => p.Id);
|
||||
e.HasIndex(p => new { p.RoomId, p.SubRoomId, p.Role, p.Permission }).IsUnique();
|
||||
});
|
||||
|
||||
modelBuilder.Entity<ClubAdditionalImageEntity>(e =>
|
||||
{
|
||||
e.HasKey(i => i.Id);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
|
||||
namespace DorkNet.Server.Data.Entities;
|
||||
|
||||
/// <summary>
|
||||
/// One cell of a sub-room's role-permission matrix, as written by
|
||||
/// <c>PUT rooms/{roomId}/subrooms/{subRoomId}/permissions</c> and served back
|
||||
/// in the <c>Permissions</c> list of <c>GET photon_access_token</c>.
|
||||
///
|
||||
/// The 2023-03-21 client models a cell as <c>BNLPBMJJOMM</c>
|
||||
/// (formatter <c>RecNet.Runtime/HDOJMEBCAGP.txt</c>, keys
|
||||
/// <c>Permission</c> / <c>Role</c> / <c>Override</c> / <c>Type</c> /
|
||||
/// <c>Value</c>): Permission and Value are strings, Role is the room-role
|
||||
/// enum <c>OMMBGJMJJPN</c> (None 0, Host 10, Moderator 20, CoOwner 30,
|
||||
/// TemporaryCoOwner 31, Creator 255), Type is <c>FEOJFGECKGH</c>
|
||||
/// (Bool 0, Int 1, StringList 2) and Override is a bool.
|
||||
///
|
||||
/// The editor sends only the cells it changed (the room-permissions runtime
|
||||
/// builds a one-element <c>BNLPBMJJOMM[]</c> per edit,
|
||||
/// <c>OMJKHJLFOCO.AFHJLBBFAGP.<LocalRequestModifyRolePermission>g__ChangeAndRefresh</c>),
|
||||
/// so the write is an upsert per (room, sub-room, role, permission) and never
|
||||
/// a replace of the whole matrix.
|
||||
/// </summary>
|
||||
public class RoomRolePermissionEntity
|
||||
{
|
||||
public long Id { get; set; }
|
||||
|
||||
public long RoomId { get; set; }
|
||||
|
||||
/// <summary>The wire sub-room id from the route (the same value the room
|
||||
/// details advertise as <c>SubRooms[].SubRoomId</c>).</summary>
|
||||
public long SubRoomId { get; set; }
|
||||
|
||||
/// <summary>Wire room-role value (OMMBGJMJJPN), e.g. 30 = CoOwner, 255 = Creator.</summary>
|
||||
public int Role { get; set; }
|
||||
|
||||
/// <summary>The permission id exactly as the client sent it.</summary>
|
||||
[MaxLength(128)]
|
||||
public string Permission { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>FEOJFGECKGH: 0 = Bool, 1 = Int, 2 = StringList.</summary>
|
||||
public int Type { get; set; }
|
||||
|
||||
public bool Override { get; set; }
|
||||
|
||||
/// <summary>The value exactly as the client sent it (a string on the wire).</summary>
|
||||
[MaxLength(4096)]
|
||||
public string Value { get; set; } = string.Empty;
|
||||
|
||||
public long UpdatedByPlayerId { get; set; }
|
||||
|
||||
public DateTime UpdatedAt { get; set; } = DateTime.UtcNow;
|
||||
}
|
||||
@@ -1,20 +1,22 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk.Web">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net9.0</TargetFramework>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="9.0.4" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="9.0.15" />
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="9.0.4">
|
||||
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="10.0.13" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.Authentication.OpenIdConnect" Version="10.0.13" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.DataProtection.EntityFrameworkCore" Version="10.0.13" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="10.0.13" />
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="10.0.13">
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
</PackageReference>
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="9.0.4" />
|
||||
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="9.0.4" />
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="10.0.13" />
|
||||
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="10.0.3" />
|
||||
<!-- RenderedCompactJsonFormatter for stdout JSON logs in production
|
||||
(Coolify-friendly). The host pipeline reads stdout line-by-line,
|
||||
and structured JSON lets a future Loki/Datadog ingest parse fields
|
||||
@@ -26,7 +28,7 @@
|
||||
account-creation flow), and PlayerPresenceService heartbeats all
|
||||
need shared state across replicas. -->
|
||||
<PackageReference Include="StackExchange.Redis" Version="2.8.16" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.SignalR.StackExchangeRedis" Version="9.0.4" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.SignalR.StackExchangeRedis" Version="10.0.13" />
|
||||
<!-- S3-compatible object storage. Production target: Garage in
|
||||
Coolify (one-click). Dev fallback: local MinIO for parity
|
||||
testing. The SDK speaks plain S3, so swapping endpoints
|
||||
@@ -37,8 +39,8 @@
|
||||
<!-- Password hashing for the real account auth flow. BCrypt embeds
|
||||
cost+salt in the output string so we only need one column. -->
|
||||
<PackageReference Include="BCrypt.Net-Next" Version="4.0.3" />
|
||||
<PackageReference Include="Serilog.AspNetCore" Version="9.0.0" />
|
||||
<PackageReference Include="Swashbuckle.AspNetCore" Version="7.3.1" />
|
||||
<PackageReference Include="Serilog.AspNetCore" Version="10.0.0" />
|
||||
<PackageReference Include="Swashbuckle.AspNetCore" Version="10.3.1" />
|
||||
<!-- Server-side crop/resize for img.* CDN transforms (?cropSquare=1&width=N
|
||||
on profile thumbnails). The 2020 client treats this as part of the
|
||||
image CDN contract — it uploads a full-frame capture and expects the
|
||||
|
||||
@@ -0,0 +1,3718 @@
|
||||
// <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("20261024201104_RoomRolePermissions")]
|
||||
partial class RoomRolePermissions
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void BuildTargetModel(ModelBuilder modelBuilder)
|
||||
{
|
||||
#pragma warning disable 612, 618
|
||||
modelBuilder.HasAnnotation("ProductVersion", "9.0.4");
|
||||
|
||||
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.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("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,49 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace DorkNet.Server.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class RoomRolePermissions : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.CreateTable(
|
||||
name: "RoomRolePermissions",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<long>(type: "INTEGER", nullable: false)
|
||||
.Annotation("Sqlite:Autoincrement", true),
|
||||
RoomId = table.Column<long>(type: "INTEGER", nullable: false),
|
||||
SubRoomId = table.Column<long>(type: "INTEGER", nullable: false),
|
||||
Role = table.Column<int>(type: "INTEGER", nullable: false),
|
||||
Permission = table.Column<string>(type: "TEXT", maxLength: 128, nullable: false),
|
||||
Type = table.Column<int>(type: "INTEGER", nullable: false),
|
||||
Override = table.Column<bool>(type: "INTEGER", nullable: false),
|
||||
Value = table.Column<string>(type: "TEXT", maxLength: 4096, nullable: false),
|
||||
UpdatedByPlayerId = table.Column<long>(type: "INTEGER", nullable: false),
|
||||
UpdatedAt = table.Column<DateTime>(type: "TEXT", nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_RoomRolePermissions", x => x.Id);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_RoomRolePermissions_RoomId_SubRoomId_Role_Permission",
|
||||
table: "RoomRolePermissions",
|
||||
columns: new[] { "RoomId", "SubRoomId", "Role", "Permission" },
|
||||
unique: true);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropTable(
|
||||
name: "RoomRolePermissions");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3772 @@
|
||||
// <auto-generated />
|
||||
using System;
|
||||
using DorkNet.Server.Data;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace DorkNet.Server.Migrations
|
||||
{
|
||||
[DbContext(typeof(DorkNetDbContext))]
|
||||
[Migration("20261025134141_ExternalSignIn")]
|
||||
partial class ExternalSignIn
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void BuildTargetModel(ModelBuilder modelBuilder)
|
||||
{
|
||||
#pragma warning disable 612, 618
|
||||
modelBuilder.HasAnnotation("ProductVersion", "10.0.13");
|
||||
|
||||
modelBuilder.Entity("DorkNet.Server.Data.Entities.AdminActionEntity", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Action")
|
||||
.IsRequired()
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long>("AdminPlayerId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Reason")
|
||||
.IsRequired()
|
||||
.HasMaxLength(512)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long>("TargetId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("TargetType")
|
||||
.IsRequired()
|
||||
.HasMaxLength(32)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTime>("Timestamp")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("AdminPlayerId");
|
||||
|
||||
b.HasIndex("Timestamp");
|
||||
|
||||
b.HasIndex("TargetType", "TargetId");
|
||||
|
||||
b.ToTable("AdminActions");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DorkNet.Server.Data.Entities.AvatarEntity", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("CustomAvatarItemsJson")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("EquippedItemsJson")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("FaceFeatures")
|
||||
.IsRequired()
|
||||
.HasMaxLength(8192)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("HairColor")
|
||||
.IsRequired()
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("InventoryJson")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("OutfitSelections")
|
||||
.IsRequired()
|
||||
.HasMaxLength(2048)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("OutfitSelectionsV2")
|
||||
.IsRequired()
|
||||
.HasMaxLength(8192)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long>("PlayerId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("SavedOutfitsJson")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("SkinColor")
|
||||
.IsRequired()
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTime>("UpdatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("PlayerId")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("Avatars");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DorkNet.Server.Data.Entities.BugReportEntity", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Body")
|
||||
.IsRequired()
|
||||
.HasMaxLength(4000)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Category")
|
||||
.IsRequired()
|
||||
.HasMaxLength(32)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("ClientVersion")
|
||||
.IsRequired()
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long>("GameSessionId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Platform")
|
||||
.IsRequired()
|
||||
.HasMaxLength(32)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTime?>("ReadAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long>("ReporterPlayerId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Title")
|
||||
.IsRequired()
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("ReadAt");
|
||||
|
||||
b.HasIndex("ReporterPlayerId");
|
||||
|
||||
b.ToTable("BugReports");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DorkNet.Server.Data.Entities.CardEntity", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("ActionUrl")
|
||||
.IsRequired()
|
||||
.HasMaxLength(512)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Body")
|
||||
.IsRequired()
|
||||
.HasMaxLength(2000)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Category")
|
||||
.IsRequired()
|
||||
.HasMaxLength(32)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTime?>("ExpiresAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("ImageName")
|
||||
.IsRequired()
|
||||
.HasMaxLength(256)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long?>("PlayerId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("Priority")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Title")
|
||||
.IsRequired()
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("ExpiresAt");
|
||||
|
||||
b.HasIndex("PlayerId");
|
||||
|
||||
b.ToTable("Cards");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DorkNet.Server.Data.Entities.CharadesWordListEntity", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<bool>("IsBuiltIn")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTime>("UpdatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("WordsJson")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.ToTable("CharadesWordLists");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DorkNet.Server.Data.Entities.ChatMessageEntity", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Body")
|
||||
.IsRequired()
|
||||
.HasMaxLength(2000)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("ModerationState")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<long>("SenderPlayerId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTime>("SentAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("ThreadKey")
|
||||
.IsRequired()
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("ThreadKey", "SentAt");
|
||||
|
||||
b.ToTable("ChatMessages");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DorkNet.Server.Data.Entities.ChatThreadEntity", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long>("CreatorPlayerId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("ThreadKey")
|
||||
.IsRequired()
|
||||
.HasMaxLength(96)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTime>("UpdatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("ThreadKey")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("ChatThreads");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DorkNet.Server.Data.Entities.ChatThreadMemberEntity", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTime>("JoinedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long?>("LastReadMessageId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<long>("PlayerId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTime?>("SnoozeUntil")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("ThreadKey")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("PlayerId");
|
||||
|
||||
b.HasIndex("ThreadKey", "PlayerId")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("ChatThreadMembers");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DorkNet.Server.Data.Entities.CheerEntity", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTime>("CheeredAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long>("FromPlayerId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<long>("TargetInventionId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<long>("TargetPhotoId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<long>("TargetPlayerId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<long>("TargetRoomId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("Type")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("TargetPhotoId");
|
||||
|
||||
b.HasIndex("TargetPlayerId");
|
||||
|
||||
b.HasIndex("TargetRoomId");
|
||||
|
||||
b.HasIndex("FromPlayerId", "TargetPlayerId", "TargetRoomId", "TargetPhotoId", "Type")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("Cheers");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DorkNet.Server.Data.Entities.ClubAdditionalImageEntity", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<long>("ClubId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("ImageName")
|
||||
.IsRequired()
|
||||
.HasMaxLength(256)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("Slot")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTime>("UpdatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("ClubId", "Slot")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("ClubAdditionalImages");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DorkNet.Server.Data.Entities.ClubAnnouncementEntity", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<long>("AuthorPlayerId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Body")
|
||||
.IsRequired()
|
||||
.HasMaxLength(4000)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long>("ClubId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("ImageName")
|
||||
.IsRequired()
|
||||
.HasMaxLength(256)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Title")
|
||||
.IsRequired()
|
||||
.HasMaxLength(200)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTime>("UpdatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("ClubId", "CreatedAt");
|
||||
|
||||
b.ToTable("ClubAnnouncements");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DorkNet.Server.Data.Entities.ClubAnnouncementReadEntity", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<long>("AnnouncementId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<long>("PlayerId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTime>("ReadAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("PlayerId");
|
||||
|
||||
b.HasIndex("AnnouncementId", "PlayerId")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("ClubAnnouncementReads");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DorkNet.Server.Data.Entities.ClubCategoryAssignmentEntity", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<long>("CategoryTagId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<long>("ClubId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("CategoryTagId");
|
||||
|
||||
b.HasIndex("ClubId", "CategoryTagId")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("ClubCategoryAssignments");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DorkNet.Server.Data.Entities.ClubCategoryTagEntity", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<bool>("Active")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("OrderIndex")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("Name");
|
||||
|
||||
b.ToTable("ClubCategoryTags");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DorkNet.Server.Data.Entities.ClubEntity", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<bool>("AllowJuniors")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("BanStatus")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Category")
|
||||
.IsRequired()
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<bool>("ClubChatEnabled")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("ClubType")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<long?>("ClubhouseRoomId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long>("CreatorPlayerId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Description")
|
||||
.IsRequired()
|
||||
.HasMaxLength(2000)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("ImageName")
|
||||
.IsRequired()
|
||||
.HasMaxLength(256)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<bool>("IsRRO")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("Joinability")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("MinLevel")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("TEXT")
|
||||
.UseCollation("NOCASE");
|
||||
|
||||
b.Property<int>("State")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTime>("UpdatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("Visibility")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("CreatorPlayerId");
|
||||
|
||||
b.HasIndex("Name")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("Clubs");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DorkNet.Server.Data.Entities.ClubMembershipEntity", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<long>("ClubId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTime>("JoinedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("Permissions")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<long>("PlayerId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("PlayerId");
|
||||
|
||||
b.HasIndex("ClubId", "PlayerId")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("ClubMemberships");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DorkNet.Server.Data.Entities.ClubRolePermissionEntity", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<bool>("ApproveMember")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<bool>("BanUnban")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<long>("ClubId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<bool>("CreateEvent")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<bool>("EditDetails")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<bool>("EditPermissionSettings")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("MembershipType")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<bool>("PostAnnouncement")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTime>("UpdatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("ClubId", "MembershipType")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("ClubRolePermissions");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DorkNet.Server.Data.Entities.ClubSubscriptionEntity", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<long>("ClubId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<long>("PlayerId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTime>("SubscribedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("ClubId");
|
||||
|
||||
b.HasIndex("PlayerId", "ClubId")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("ClubSubscriptions");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DorkNet.Server.Data.Entities.CohortAssignmentEntity", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTime>("AssignedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("CohortKey")
|
||||
.IsRequired()
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long>("PlayerId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Variant")
|
||||
.IsRequired()
|
||||
.HasMaxLength(32)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("PlayerId", "CohortKey")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("CohortAssignments");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DorkNet.Server.Data.Entities.CommunityBoardEntity", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Json")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTime>("UpdatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.ToTable("CommunityBoardRows");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DorkNet.Server.Data.Entities.CouponEntity", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Code")
|
||||
.IsRequired()
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Description")
|
||||
.IsRequired()
|
||||
.HasMaxLength(256)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTime?>("ExpiresAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<bool>("IsActive")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("MaxRedemptions")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("RedemptionCount")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<long>("RewardAmount")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("RewardCurrencyType")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("RewardItemSlug")
|
||||
.IsRequired()
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("RewardType")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("Code")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("Coupons");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DorkNet.Server.Data.Entities.CouponRedemptionEntity", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<long>("CouponId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<long>("PlayerId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTime>("RedeemedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("CouponId", "PlayerId")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("CouponRedemptions");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DorkNet.Server.Data.Entities.CurrencyBalanceEntity", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<long>("Balance")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("CurrencyType")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<long>("PlayerId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTime>("UpdatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("PlayerId", "CurrencyType")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("CurrencyBalances");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DorkNet.Server.Data.Entities.CustomAvatarItemEntity", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("AssetName")
|
||||
.IsRequired()
|
||||
.HasMaxLength(256)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("BaseAvatarItemId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<long>("CheerCount")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Color")
|
||||
.IsRequired()
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long>("CreatorPlayerId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Description")
|
||||
.IsRequired()
|
||||
.HasMaxLength(1024)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("ImageName")
|
||||
.IsRequired()
|
||||
.HasMaxLength(256)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<bool>("IsFeatured")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<bool>("IsPublic")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("ItemType")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("Price")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<Guid>("PublicId")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long>("PurchaseCount")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTime>("UpdatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("CreatorPlayerId");
|
||||
|
||||
b.HasIndex("IsFeatured");
|
||||
|
||||
b.HasIndex("IsPublic");
|
||||
|
||||
b.HasIndex("PublicId")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("CustomAvatarItems");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DorkNet.Server.Data.Entities.CustomAvatarItemOwnershipEntity", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTime>("AcquiredAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long>("CustomAvatarItemId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<long>("PlayerId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("CustomAvatarItemId");
|
||||
|
||||
b.HasIndex("PlayerId", "CustomAvatarItemId")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("CustomAvatarItemOwnership");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DorkNet.Server.Data.Entities.DormStateEntity", b =>
|
||||
{
|
||||
b.Property<long>("PlayerId")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("CurrentDataBlobName")
|
||||
.IsRequired()
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTime>("UpdatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("PlayerId");
|
||||
|
||||
b.ToTable("DormStates");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DorkNet.Server.Data.Entities.ExternalLoginEntity", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Email")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Issuer")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTime>("LastUsedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long>("PlayerId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Subject")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("PlayerId");
|
||||
|
||||
b.HasIndex("Issuer", "Subject")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("ExternalLogins");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DorkNet.Server.Data.Entities.GameRewardSelectionEntity", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("GiftContext")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<long>("GrantedItemId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Message")
|
||||
.IsRequired()
|
||||
.HasMaxLength(256)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long>("Offer1ItemId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<long>("Offer2ItemId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<long>("Offer3ItemId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<long>("PlayerId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("RewardType")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTime?>("SelectedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int?>("SelectedGiftDropId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("PlayerId", "SelectedAt");
|
||||
|
||||
b.ToTable("GameRewardSelections");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DorkNet.Server.Data.Entities.GameSessionEntity", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("ActivityLevelId")
|
||||
.IsRequired()
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("MaxCapacity")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("PhotonRoomName")
|
||||
.IsRequired()
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("PlayerCount")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Region")
|
||||
.IsRequired()
|
||||
.HasMaxLength(16)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("RoomId")
|
||||
.IsRequired()
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("RoomId", "Region");
|
||||
|
||||
b.ToTable("GameSessions");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DorkNet.Server.Data.Entities.GiftPackageEntity", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("AvatarItemDescOrHairDyeDesc")
|
||||
.IsRequired()
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int?>("AvatarItemType")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("ConsumableItemDesc")
|
||||
.IsRequired()
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<bool>("Consumed")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTime?>("ConsumedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("Currency")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("CurrencyType")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("EquipmentModificationGuid")
|
||||
.IsRequired()
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("EquipmentPrefabName")
|
||||
.IsRequired()
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("ErrorMessage")
|
||||
.IsRequired()
|
||||
.HasMaxLength(512)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int?>("FromPlayerId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("GiftContext")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("GiftRarity")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<bool>("IsGifted")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<bool>("IsValid")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("Level")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Message")
|
||||
.IsRequired()
|
||||
.HasMaxLength(512)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("PackageMaterial")
|
||||
.IsRequired()
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("PackageVariant")
|
||||
.IsRequired()
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("Platform")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<long>("RecipientPlayerId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<long>("SourceStoreItemId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<bool>("SupportsCurrentPlatform")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("Xp")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("RecipientPlayerId", "Consumed");
|
||||
|
||||
b.ToTable("GiftPackages");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DorkNet.Server.Data.Entities.InventionEntity", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("CheerCount")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long?>("CreationRoomId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("CreatorPermission")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<long>("CreatorPlayerId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("CurrentBlobName")
|
||||
.IsRequired()
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("CurrentVersionNumber")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Description")
|
||||
.IsRequired()
|
||||
.HasMaxLength(2000)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTime?>("FirstPublishedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("GeneralPermission")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("ImageName")
|
||||
.IsRequired()
|
||||
.HasMaxLength(256)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<bool>("IsAgInvention")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<bool>("IsDeleted")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<bool>("IsPublished")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("NumPlayersHaveUsedInRoom")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("Permission")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("Price")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("ReplicationId")
|
||||
.IsRequired()
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("SpawnCount")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("TagsCsv")
|
||||
.IsRequired()
|
||||
.HasMaxLength(1024)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTime>("UpdatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("CheerCount");
|
||||
|
||||
b.HasIndex("CreatorPlayerId");
|
||||
|
||||
b.ToTable("Inventions");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DorkNet.Server.Data.Entities.InventionVersionEntity", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("BlobName")
|
||||
.IsRequired()
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("InstantiationCost")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<long>("InventionId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("LightsCost")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("ReplicationId")
|
||||
.IsRequired()
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("VersionNumber")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("InventionId", "VersionNumber")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("InventionVersions");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DorkNet.Server.Data.Entities.IpBanEntity", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTime>("BannedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long>("BannedByAdminId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Cidr")
|
||||
.IsRequired()
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Reason")
|
||||
.IsRequired()
|
||||
.HasMaxLength(512)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTime?>("Until")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("Cidr");
|
||||
|
||||
b.ToTable("IpBans");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DorkNet.Server.Data.Entities.ItemWishlistEntity", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("ItemKey")
|
||||
.IsRequired()
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("ItemType")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<long>("PlayerId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("PlayerId", "ItemKey", "ItemType")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("ItemWishlists");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DorkNet.Server.Data.Entities.KeepsakeEntity", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Category")
|
||||
.IsRequired()
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Description")
|
||||
.IsRequired()
|
||||
.HasMaxLength(1024)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTime>("EarnedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("EventKey")
|
||||
.IsRequired()
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("ImageName")
|
||||
.IsRequired()
|
||||
.HasMaxLength(256)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long>("PlayerId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Title")
|
||||
.IsRequired()
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("PlayerId");
|
||||
|
||||
b.HasIndex("PlayerId", "EventKey");
|
||||
|
||||
b.ToTable("Keepsakes");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DorkNet.Server.Data.Entities.LeaderboardChannelMetaEntity", b =>
|
||||
{
|
||||
b.Property<long>("RoomId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("Channel")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<bool>("LowerIsBetter")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTime>("UpdatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("ValueFormat")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("RoomId", "Channel");
|
||||
|
||||
b.HasIndex("RoomId");
|
||||
|
||||
b.ToTable("LeaderboardChannelMeta");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DorkNet.Server.Data.Entities.LeaderboardStatEntity", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<long>("PlayerId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<long>("RoomId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("StatChannel")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTime>("UpdatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long>("Value")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("RoomId", "PlayerId", "StatChannel")
|
||||
.IsUnique();
|
||||
|
||||
b.HasIndex("RoomId", "StatChannel", "Value");
|
||||
|
||||
b.ToTable("LeaderboardStats");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DorkNet.Server.Data.Entities.LoadingScreenTipEntity", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("Context")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("ImageName")
|
||||
.IsRequired()
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<bool>("IsActive")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Message")
|
||||
.IsRequired()
|
||||
.HasMaxLength(512)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("PlatformMask")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("RoomNamesCsv")
|
||||
.IsRequired()
|
||||
.HasMaxLength(512)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("SortOrder")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Title")
|
||||
.IsRequired()
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTime>("UpdatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.ToTable("LoadingScreenTips");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DorkNet.Server.Data.Entities.MessageEntity", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Body")
|
||||
.IsRequired()
|
||||
.HasMaxLength(2000)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTime?>("ReadAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long>("RecipientPlayerId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<long?>("RoomId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<long>("SenderPlayerId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTime>("SentAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("Type")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("RecipientPlayerId", "SentAt");
|
||||
|
||||
b.HasIndex("SenderPlayerId", "SentAt");
|
||||
|
||||
b.ToTable("Messages");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DorkNet.Server.Data.Entities.NotificationPrefsEntity", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<bool>("AllowAll")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<bool>("AllowAnnouncements")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<bool>("AllowEventInvite")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<bool>("AllowFriendRequest")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<bool>("AllowMessage")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Platform")
|
||||
.IsRequired()
|
||||
.HasMaxLength(32)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long>("PlayerId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTime>("UpdatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("PlayerId", "Platform")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("NotificationPrefs");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DorkNet.Server.Data.Entities.ObjectiveProgressEntity", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTime?>("ClearedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<bool>("IsCompleted")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Key")
|
||||
.IsRequired()
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long>("PlayerId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("PlayerId", "Key")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("ObjectiveProgress");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DorkNet.Server.Data.Entities.PendingDeviceEntity", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("DeviceId")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTime>("FirstSeenAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("LastIp")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTime>("LastSeenAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("Platform")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("PlatformId")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.ToTable("PendingDevices");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DorkNet.Server.Data.Entities.PhotoEntity", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("BlobName")
|
||||
.IsRequired()
|
||||
.HasMaxLength(256)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Caption")
|
||||
.IsRequired()
|
||||
.HasMaxLength(2048)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("CheerCount")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTime?>("DeletedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<bool>("IsPublic")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<long>("RoomId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("TaggedPlayerIdsCsv")
|
||||
.IsRequired()
|
||||
.HasMaxLength(2048)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long>("UploaderPlayerId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("ViewCount")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("BlobName");
|
||||
|
||||
b.HasIndex("CreatedAt");
|
||||
|
||||
b.HasIndex("RoomId");
|
||||
|
||||
b.HasIndex("UploaderPlayerId");
|
||||
|
||||
b.ToTable("Photos");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DorkNet.Server.Data.Entities.PlatformIgnoreEntity", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("Platform")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("PlatformUserId")
|
||||
.IsRequired()
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long>("PlayerId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("PlayerId", "Platform", "PlatformUserId")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("PlatformIgnores");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DorkNet.Server.Data.Entities.PlayerDeviceEntity", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("DeviceId")
|
||||
.IsRequired()
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTime>("FirstSeen")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<bool>("IsBanned")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTime>("LastSeen")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Platform")
|
||||
.IsRequired()
|
||||
.HasMaxLength(32)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long>("PlayerId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("DeviceId");
|
||||
|
||||
b.HasIndex("PlayerId");
|
||||
|
||||
b.ToTable("PlayerDevices");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DorkNet.Server.Data.Entities.PlayerEloEntity", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("Elo")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("GameMode")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("Losses")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("MatchesPlayed")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<long>("PlayerId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTime>("UpdatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("Wins")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("GameMode", "Elo");
|
||||
|
||||
b.HasIndex("PlayerId", "GameMode")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("PlayerElo");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DorkNet.Server.Data.Entities.PlayerEntity", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTime?>("BannedUntil")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("BannerImageName")
|
||||
.HasMaxLength(256)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Bio")
|
||||
.IsRequired()
|
||||
.HasMaxLength(512)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTime?>("Birthday")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<bool>("CanReceiveInvites")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("CreatedFromDeviceId")
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("DeviceId")
|
||||
.IsRequired()
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("DisplayEmoji")
|
||||
.IsRequired()
|
||||
.HasMaxLength(32)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("DisplayName")
|
||||
.IsRequired()
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Email")
|
||||
.HasMaxLength(256)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("IdentityFlags")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<bool>("IsAdmin")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<bool>("IsCommunityTeam")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<bool>("IsDeveloper")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<bool>("IsJunior")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<bool>("IsVerified")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("LastIp")
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("LastPlatform")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("LastPlatformId")
|
||||
.IsRequired()
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTime>("LastSeenAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("Level")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Locale")
|
||||
.HasMaxLength(16)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("PasswordHash")
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("PersonalPronouns")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Phone")
|
||||
.HasMaxLength(32)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("ProfileImageName")
|
||||
.HasMaxLength(256)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("Reputation")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Username")
|
||||
.IsRequired()
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("XP")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("Username")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("Players");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DorkNet.Server.Data.Entities.PlayerEventEntity", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("Capacity")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long>("CreatorPlayerId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Description")
|
||||
.IsRequired()
|
||||
.HasMaxLength(2000)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTime>("EndsAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long>("RoomId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTime>("StartsAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Title")
|
||||
.IsRequired()
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTime>("UpdatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("CreatorPlayerId");
|
||||
|
||||
b.HasIndex("StartsAt");
|
||||
|
||||
b.ToTable("PlayerEvents");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DorkNet.Server.Data.Entities.PlayerEventResponseEntity", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long>("EventId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<long>("PlayerId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("Response")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("PlayerId");
|
||||
|
||||
b.HasIndex("EventId", "PlayerId")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("PlayerEventResponses");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DorkNet.Server.Data.Entities.PlayerInventoryEntity", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTime>("AcquiredAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<bool>("IsActive")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("ItemSlug")
|
||||
.IsRequired()
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long>("PlayerId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("Quantity")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("PlayerId");
|
||||
|
||||
b.HasIndex("PlayerId", "ItemSlug")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("PlayerInventory");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DorkNet.Server.Data.Entities.PlayerSettingEntity", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Key")
|
||||
.IsRequired()
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long>("PlayerId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Value")
|
||||
.IsRequired()
|
||||
.HasMaxLength(1024)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("PlayerId", "Key")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("PlayerSettings");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DorkNet.Server.Data.Entities.PlaylistEntity", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("Accessibility")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("CheerCount")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long>("CreatorPlayerId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("CustomWarning")
|
||||
.IsRequired()
|
||||
.HasMaxLength(512)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Description")
|
||||
.IsRequired()
|
||||
.HasMaxLength(2000)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("FavoriteCount")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("ImageName")
|
||||
.IsRequired()
|
||||
.HasMaxLength(256)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<bool>("IsCurated")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("TEXT")
|
||||
.UseCollation("NOCASE");
|
||||
|
||||
b.Property<int>("OrderIndex")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<bool>("SupportsJuniors")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<bool>("SupportsLevelVoting")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<bool>("SupportsScreens")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<bool>("SupportsTeleportVR")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<bool>("SupportsWalkVR")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("TagsCsv")
|
||||
.IsRequired()
|
||||
.HasMaxLength(1024)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTime>("UpdatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("VisitCount")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("VisitorCount")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("WarningMask")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("CreatorPlayerId");
|
||||
|
||||
b.HasIndex("IsCurated");
|
||||
|
||||
b.HasIndex("Name");
|
||||
|
||||
b.ToTable("Playlists");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DorkNet.Server.Data.Entities.PlaylistInteractionEntity", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<bool>("Cheered")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<bool>("Favorited")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<long>("PlayerId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<long>("PlaylistId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTime>("UpdatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("PlayerId");
|
||||
|
||||
b.HasIndex("PlaylistId", "PlayerId")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("PlaylistInteractions");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DorkNet.Server.Data.Entities.PlaylistRoomEntity", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("OrderIndex")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<long>("PlaylistId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<long>("RoomId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("PlaylistId");
|
||||
|
||||
b.HasIndex("RoomId");
|
||||
|
||||
b.HasIndex("PlaylistId", "OrderIndex");
|
||||
|
||||
b.ToTable("PlaylistRooms");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DorkNet.Server.Data.Entities.PrivateInstanceEntity", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("DataBlob")
|
||||
.IsRequired()
|
||||
.HasMaxLength(256)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Location")
|
||||
.IsRequired()
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("MaxCapacity")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long>("OwnerPlayerId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("PhotonRegion")
|
||||
.IsRequired()
|
||||
.HasMaxLength(16)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("PhotonRoomId")
|
||||
.IsRequired()
|
||||
.HasMaxLength(256)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("RoomCode")
|
||||
.IsRequired()
|
||||
.HasMaxLength(16)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long>("RoomId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<long>("SubRoomId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("OwnerPlayerId");
|
||||
|
||||
b.HasIndex("RoomId", "SubRoomId");
|
||||
|
||||
b.ToTable("PrivateInstances");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DorkNet.Server.Data.Entities.PrivateInstanceInviteeEntity", b =>
|
||||
{
|
||||
b.Property<long>("PrivateInstanceId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<long>("PlayerId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTime>("InvitedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long?>("LatestInviteMessageId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.HasKey("PrivateInstanceId", "PlayerId");
|
||||
|
||||
b.HasIndex("PlayerId");
|
||||
|
||||
b.ToTable("PrivateInstanceInvitees");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DorkNet.Server.Data.Entities.PushTokenEntity", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Platform")
|
||||
.IsRequired()
|
||||
.HasMaxLength(16)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long>("PlayerId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Token")
|
||||
.IsRequired()
|
||||
.HasMaxLength(256)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTime>("UpdatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("Token");
|
||||
|
||||
b.HasIndex("PlayerId", "Platform")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("PushTokens");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DorkNet.Server.Data.Entities.RelationshipEntity", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<bool>("Favorited")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<bool>("Ignored")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<bool>("Muted")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<long>("RequesterId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("Status")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<long>("TargetId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTime>("UpdatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("RequesterId", "TargetId")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("Relationships");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DorkNet.Server.Data.Entities.ReportEntity", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("Category")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long>("GameSessionId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Message")
|
||||
.IsRequired()
|
||||
.HasMaxLength(1000)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long>("ReporterPlayerId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("ResolutionNote")
|
||||
.IsRequired()
|
||||
.HasMaxLength(512)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTime?>("ResolvedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long?>("ResolverAdminId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<long>("RoomId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<long>("TargetEventId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<long>("TargetInventionId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<long>("TargetPlayerId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<long>("TargetRoomId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("ReporterPlayerId");
|
||||
|
||||
b.HasIndex("ResolvedAt");
|
||||
|
||||
b.HasIndex("TargetPlayerId");
|
||||
|
||||
b.ToTable("Reports");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DorkNet.Server.Data.Entities.RoomBanEntity", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("BanType")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<long>("BannedByPlayerId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<long>("BannedPlayerId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Reason")
|
||||
.IsRequired()
|
||||
.HasMaxLength(512)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long>("RoomId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTime?>("Until")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("BannedPlayerId");
|
||||
|
||||
b.HasIndex("RoomId", "BannedPlayerId");
|
||||
|
||||
b.ToTable("RoomBans");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DorkNet.Server.Data.Entities.RoomBookmarkEntity", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long>("PlayerId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<long>("RoomId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("PlayerId");
|
||||
|
||||
b.HasIndex("PlayerId", "RoomId")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("RoomBookmarks");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DorkNet.Server.Data.Entities.RoomConsumableEntity", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long>("CreatorPlayerId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<Guid?>("CurrencyId")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Description")
|
||||
.IsRequired()
|
||||
.HasMaxLength(1024)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("ImageName")
|
||||
.IsRequired()
|
||||
.HasMaxLength(256)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<bool>("IsDeleted")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long>("Price")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<Guid>("PublicId")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long>("RoomId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTime>("UpdatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("CreatorPlayerId");
|
||||
|
||||
b.HasIndex("PublicId")
|
||||
.IsUnique();
|
||||
|
||||
b.HasIndex("RoomId");
|
||||
|
||||
b.ToTable("RoomConsumables");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DorkNet.Server.Data.Entities.RoomConsumableOwnershipEntity", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<Guid>("ConcurrencyCode")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("Count")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTime>("ModifiedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long>("PlayerId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<long>("RoomConsumableId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("RoomConsumableId");
|
||||
|
||||
b.HasIndex("PlayerId", "RoomConsumableId")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("RoomConsumableOwnership");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DorkNet.Server.Data.Entities.RoomCurrencyBalanceEntity", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<long>("Balance")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<long>("PlayerId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<long>("RoomCurrencyId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTime>("UpdatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("RoomCurrencyId");
|
||||
|
||||
b.HasIndex("PlayerId", "RoomCurrencyId")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("RoomCurrencyBalances");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DorkNet.Server.Data.Entities.RoomCurrencyEntity", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long>("CreatorPlayerId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("DailyLimit")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Description")
|
||||
.IsRequired()
|
||||
.HasMaxLength(512)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("ImageName")
|
||||
.IsRequired()
|
||||
.HasMaxLength(256)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<bool>("IsDeleted")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<Guid>("PublicId")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long>("RoomId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTime>("UpdatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("CreatorPlayerId");
|
||||
|
||||
b.HasIndex("PublicId")
|
||||
.IsUnique();
|
||||
|
||||
b.HasIndex("RoomId");
|
||||
|
||||
b.ToTable("RoomCurrencies");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DorkNet.Server.Data.Entities.RoomCurrencyPurchaseOfferEntity", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("Amount")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("CurrencyType")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<bool>("IsDeleted")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("Price")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<Guid>("PublicId")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long>("RoomCurrencyId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTime>("UpdatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("PublicId")
|
||||
.IsUnique();
|
||||
|
||||
b.HasIndex("RoomCurrencyId");
|
||||
|
||||
b.ToTable("RoomCurrencyPurchaseOffers");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DorkNet.Server.Data.Entities.RoomDataBlobEntity", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("BlobName")
|
||||
.IsRequired()
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<byte[]>("Bytes")
|
||||
.HasColumnType("BLOB");
|
||||
|
||||
b.Property<string>("ReferencedFilenamesCsv")
|
||||
.IsRequired()
|
||||
.HasMaxLength(2048)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long>("RoomId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<long?>("SubRoomId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTime>("UploadedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long>("UploadedByPlayerId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("BlobName")
|
||||
.IsUnique();
|
||||
|
||||
b.HasIndex("RoomId");
|
||||
|
||||
b.ToTable("RoomDataBlobs");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DorkNet.Server.Data.Entities.RoomEntity", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("Accessibility")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<bool>("AllowNewUsers")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<bool>("AllowsJuniors")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("CheerCount")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<bool>("CloningAllowed")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long>("CreatorPlayerId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("CurrentDataBlobName")
|
||||
.IsRequired()
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("CustomRoomWarning")
|
||||
.IsRequired()
|
||||
.HasMaxLength(512)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Description")
|
||||
.IsRequired()
|
||||
.HasMaxLength(2000)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<bool>("DisableMicAutoMute")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("FavoriteCount")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<bool>("HiddenFromBrowse")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<double>("HotScore")
|
||||
.HasColumnType("REAL");
|
||||
|
||||
b.Property<string>("ImageName")
|
||||
.IsRequired()
|
||||
.HasMaxLength(256)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<bool>("IsAGRoom")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<bool>("IsDormRoom")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<bool>("IsRoomLinkedToRecRoomStudio")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<bool>("IsStudioRoom")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("LoadScreensJson")
|
||||
.IsRequired()
|
||||
.HasMaxLength(4096)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("LocationReplicationId")
|
||||
.IsRequired()
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("MaxCapacity")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("MaxPlayerCalculationMode")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("MinLevel")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("TEXT")
|
||||
.UseCollation("NOCASE");
|
||||
|
||||
b.Property<string>("PromoExternalContentJson")
|
||||
.IsRequired()
|
||||
.HasMaxLength(4096)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("PromoImagesJson")
|
||||
.IsRequired()
|
||||
.HasMaxLength(4096)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("RoomWarningMask")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("State")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("StudioSessionId")
|
||||
.IsRequired()
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<bool>("SupportsLevelVoting")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<bool>("SupportsMobile")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<bool>("SupportsScreens")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<bool>("SupportsTeleportVR")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<bool>("SupportsVRLow")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<bool>("SupportsWalkVR")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("TagsCsv")
|
||||
.IsRequired()
|
||||
.HasMaxLength(1024)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTime>("UpdatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("VisitCount")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("VisitorCount")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("CreatorPlayerId");
|
||||
|
||||
b.HasIndex("HotScore");
|
||||
|
||||
b.HasIndex("Name")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("Rooms");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DorkNet.Server.Data.Entities.RoomKeyEntity", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long>("CreatorPlayerId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Description")
|
||||
.IsRequired()
|
||||
.HasMaxLength(174)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<bool>("IsDeleted")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasMaxLength(40)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("Price")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("ReplicationId")
|
||||
.IsRequired()
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long>("RoomId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTime>("UpdatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("CreatorPlayerId");
|
||||
|
||||
b.HasIndex("RoomId");
|
||||
|
||||
b.HasIndex("RoomId", "Name");
|
||||
|
||||
b.ToTable("RoomKeys");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DorkNet.Server.Data.Entities.RoomKeyPurchaseEntity", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("PaidPrice")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<long>("PlayerId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTime>("PurchasedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long>("RoomKeyId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("PlayerId");
|
||||
|
||||
b.HasIndex("RoomKeyId", "PlayerId")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("RoomKeyPurchases");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DorkNet.Server.Data.Entities.RoomRoleEntity", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<bool>("Accepted")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTime>("GrantedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long?>("GrantedByPlayerId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<long>("PlayerId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("Role")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<long>("RoomId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("RoomId");
|
||||
|
||||
b.HasIndex("RoomId", "PlayerId", "Role")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("RoomRoles");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DorkNet.Server.Data.Entities.RoomRolePermissionEntity", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<bool>("Override")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Permission")
|
||||
.IsRequired()
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("Role")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<long>("RoomId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<long>("SubRoomId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("Type")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTime>("UpdatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long>("UpdatedByPlayerId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Value")
|
||||
.IsRequired()
|
||||
.HasMaxLength(4096)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("RoomId", "SubRoomId", "Role", "Permission")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("RoomRolePermissions");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DorkNet.Server.Data.Entities.RoomSceneEntity", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<bool>("CanMatchmakeInto")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("DataBlobName")
|
||||
.IsRequired()
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTime>("DataModifiedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<bool>("IsSandbox")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("MaxPlayers")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("TEXT")
|
||||
.UseCollation("NOCASE");
|
||||
|
||||
b.Property<int>("OrderIndex")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<long>("RoomId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("RoomSceneLocationId")
|
||||
.IsRequired()
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("StudioAssetBundleNamesCsv")
|
||||
.IsRequired()
|
||||
.HasMaxLength(4096)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long?>("StudioSubRoomDataSaveId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("StudioUnityAssetId")
|
||||
.IsRequired()
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("RoomId", "Name");
|
||||
|
||||
b.HasIndex("RoomId", "OrderIndex")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("RoomScenes");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DorkNet.Server.Data.Entities.RoomVisitEntity", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTime>("FirstVisitAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTime>("LastVisitAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long>("PlayerId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<long>("RoomId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("VisitCount")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("PlayerId");
|
||||
|
||||
b.HasIndex("RoomId", "LastVisitAt");
|
||||
|
||||
b.HasIndex("RoomId", "PlayerId")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("RoomVisits");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DorkNet.Server.Data.Entities.RoyaleMatchEntity", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("ChestsOpened")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTime>("CompletedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<bool>("CustomGame")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("HealthPotionsConsumed")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("NumEliminations")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("Rank")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("SecondsAlive")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("SecondsInAir")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("ShieldPotionsConsumed")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<bool>("WalkGame")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("CompletedAt");
|
||||
|
||||
b.ToTable("RoyaleMatches");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DorkNet.Server.Data.Entities.RoyaleMatchPlayerEntity", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<long>("MatchId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("NumEliminations")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<long>("PlayerId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("Rank")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("SecondsAlive")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("PlayerId");
|
||||
|
||||
b.HasIndex("MatchId", "PlayerId");
|
||||
|
||||
b.ToTable("RoyaleMatchPlayers");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DorkNet.Server.Data.Entities.RoyalePlayerProgressEntity", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<long>("CurrentLevelXPThreshold")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("Level")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("NextLevelAcornReward")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<long>("NextLevelXPThreshold")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<long>("PlayerId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("RankIdx")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("RankName")
|
||||
.IsRequired()
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long>("TotalXP")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTime>("UpdatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("PlayerId")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("RoyalePlayerProgress");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DorkNet.Server.Data.Entities.ServerSettingsEntity", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<bool>("AllAvatarItemsOwned")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("BaseRoomNamesJson")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("CharadesSlotBindingsJson")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("CircuitsV2LimitsJson")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("DiscoveredGameConfigsJson")
|
||||
.IsRequired()
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("TEXT")
|
||||
.HasDefaultValue("");
|
||||
|
||||
b.Property<bool>("GlobalFriendsEnabled")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("PlayMenuTagsJson")
|
||||
.IsRequired()
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("TEXT")
|
||||
.HasDefaultValue("");
|
||||
|
||||
b.Property<bool>("ProfanityFilterDisabled")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("RecCenterDoorsJson")
|
||||
.IsRequired()
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("TEXT")
|
||||
.HasDefaultValue("");
|
||||
|
||||
b.Property<bool>("RoomBlobVersionClampDisabled")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<bool>("SignupsDisabled")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTime>("UpdatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("WeeklyChallengeRewardJson")
|
||||
.IsRequired()
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("TEXT")
|
||||
.HasDefaultValue("");
|
||||
|
||||
b.Property<bool>("WeeklyChallengesCompletedRequired")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER")
|
||||
.HasDefaultValue(true);
|
||||
|
||||
b.Property<string>("WeeklyChallengesJson")
|
||||
.IsRequired()
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("TEXT")
|
||||
.HasDefaultValue("");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.ToTable("ServerSettings");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DorkNet.Server.Data.Entities.SignupCodeEntity", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Code")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long>("CreatedByPlayerId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Descriptor")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTime?>("ExpiresAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTime?>("RedeemedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long?>("RedeemedByPlayerId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<bool>("Revoked")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.ToTable("SignupCodes");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DorkNet.Server.Data.Entities.StoreItemEntity", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTime?>("AvailableUntil")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Category")
|
||||
.IsRequired()
|
||||
.HasMaxLength(32)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("CurrencyType")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Description")
|
||||
.IsRequired()
|
||||
.HasMaxLength(1024)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("DisplayName")
|
||||
.IsRequired()
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("ImageName")
|
||||
.IsRequired()
|
||||
.HasMaxLength(256)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<bool>("IsActive")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<bool>("IsLimitedTime")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<long>("Price")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Slug")
|
||||
.IsRequired()
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Storefront")
|
||||
.IsRequired()
|
||||
.HasMaxLength(32)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTime>("UpdatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("Slug")
|
||||
.IsUnique();
|
||||
|
||||
b.HasIndex("Storefront");
|
||||
|
||||
b.HasIndex("IsActive", "Category");
|
||||
|
||||
b.ToTable("StoreItems");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DorkNet.Server.Data.Entities.SubscriptionEntity", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long>("SubscriberPlayerId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<long>("TargetPlayerId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("SubscriberPlayerId");
|
||||
|
||||
b.HasIndex("TargetPlayerId");
|
||||
|
||||
b.HasIndex("SubscriberPlayerId", "TargetPlayerId")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("Subscriptions");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DorkNet.Server.Data.Entities.TestCaseEntity", b =>
|
||||
{
|
||||
b.Property<long>("Pk")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("AssignedPlayerIdsJson")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("AssignedPlayerNamesJson")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("CommentsJson")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Description")
|
||||
.IsRequired()
|
||||
.HasMaxLength(2000)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Id")
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("JiraBugUrl")
|
||||
.IsRequired()
|
||||
.HasMaxLength(512)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("JiraUrl")
|
||||
.IsRequired()
|
||||
.HasMaxLength(512)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Key")
|
||||
.IsRequired()
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("MinNumAssignedPlayers")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("RoomName")
|
||||
.IsRequired()
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("Status")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("TagsJson")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<uint?>("TestPassId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Title")
|
||||
.IsRequired()
|
||||
.HasMaxLength(256)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTime>("UpdatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Pk");
|
||||
|
||||
b.HasIndex("Id")
|
||||
.IsUnique();
|
||||
|
||||
b.HasIndex("Status");
|
||||
|
||||
b.HasIndex("TestPassId");
|
||||
|
||||
b.ToTable("TestCases");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DorkNet.Server.Data.Entities.TestPassEntity", b =>
|
||||
{
|
||||
b.Property<uint>("Id")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Description")
|
||||
.IsRequired()
|
||||
.HasMaxLength(2000)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTime?>("EndDate")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTime>("StartDate")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("TagsJson")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<bool>("WasManuallyClosed")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.ToTable("TestPasses");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DorkNet.Server.Data.Entities.UgcPurchasableEntity", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long>("CreatorPlayerId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("CurrencyType")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Description")
|
||||
.IsRequired()
|
||||
.HasMaxLength(1024)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("ImageName")
|
||||
.IsRequired()
|
||||
.HasMaxLength(256)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<bool>("IsDeleted")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<bool>("IsFeatured")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("ItemType")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("Price")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<Guid>("PublicId")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long>("RoomId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("SortOrder")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTime>("UpdatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("CreatorPlayerId");
|
||||
|
||||
b.HasIndex("PublicId")
|
||||
.IsUnique();
|
||||
|
||||
b.HasIndex("RoomId");
|
||||
|
||||
b.ToTable("UgcPurchasables");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Microsoft.AspNetCore.DataProtection.EntityFrameworkCore.DataProtectionKey", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("FriendlyName")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Xml")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.ToTable("DataProtectionKeys");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DorkNet.Server.Data.Entities.AvatarEntity", b =>
|
||||
{
|
||||
b.HasOne("DorkNet.Server.Data.Entities.PlayerEntity", "Player")
|
||||
.WithOne("Avatar")
|
||||
.HasForeignKey("DorkNet.Server.Data.Entities.AvatarEntity", "PlayerId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("Player");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DorkNet.Server.Data.Entities.PlayerSettingEntity", b =>
|
||||
{
|
||||
b.HasOne("DorkNet.Server.Data.Entities.PlayerEntity", "Player")
|
||||
.WithMany("Settings")
|
||||
.HasForeignKey("PlayerId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("Player");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DorkNet.Server.Data.Entities.RelationshipEntity", b =>
|
||||
{
|
||||
b.HasOne("DorkNet.Server.Data.Entities.PlayerEntity", "Requester")
|
||||
.WithMany("Relationships")
|
||||
.HasForeignKey("RequesterId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("Requester");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DorkNet.Server.Data.Entities.PlayerEntity", b =>
|
||||
{
|
||||
b.Navigation("Avatar");
|
||||
|
||||
b.Navigation("Relationships");
|
||||
|
||||
b.Navigation("Settings");
|
||||
});
|
||||
#pragma warning restore 612, 618
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace DorkNet.Server.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class ExternalSignIn : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.CreateTable(
|
||||
name: "DataProtectionKeys",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<int>(type: "INTEGER", nullable: false)
|
||||
.Annotation("Sqlite:Autoincrement", true),
|
||||
FriendlyName = table.Column<string>(type: "TEXT", nullable: true),
|
||||
Xml = table.Column<string>(type: "TEXT", nullable: true)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_DataProtectionKeys", x => x.Id);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "ExternalLogins",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<long>(type: "INTEGER", nullable: false)
|
||||
.Annotation("Sqlite:Autoincrement", true),
|
||||
PlayerId = table.Column<long>(type: "INTEGER", nullable: false),
|
||||
Issuer = table.Column<string>(type: "TEXT", nullable: false),
|
||||
Subject = table.Column<string>(type: "TEXT", nullable: false),
|
||||
Email = table.Column<string>(type: "TEXT", nullable: false),
|
||||
CreatedAt = table.Column<DateTime>(type: "TEXT", nullable: false),
|
||||
LastUsedAt = table.Column<DateTime>(type: "TEXT", nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_ExternalLogins", x => x.Id);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_ExternalLogins_Issuer_Subject",
|
||||
table: "ExternalLogins",
|
||||
columns: new[] { "Issuer", "Subject" },
|
||||
unique: true);
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_ExternalLogins_PlayerId",
|
||||
table: "ExternalLogins",
|
||||
column: "PlayerId");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropTable(
|
||||
name: "DataProtectionKeys");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "ExternalLogins");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -15,7 +15,7 @@ namespace DorkNet.Server.Migrations
|
||||
protected override void BuildModel(ModelBuilder modelBuilder)
|
||||
{
|
||||
#pragma warning disable 612, 618
|
||||
modelBuilder.HasAnnotation("ProductVersion", "9.0.4");
|
||||
modelBuilder.HasAnnotation("ProductVersion", "10.0.13");
|
||||
|
||||
modelBuilder.Entity("DorkNet.Server.Data.Entities.AdminActionEntity", b =>
|
||||
{
|
||||
@@ -972,6 +972,43 @@ namespace DorkNet.Server.Migrations
|
||||
b.ToTable("DormStates");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DorkNet.Server.Data.Entities.ExternalLoginEntity", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Email")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Issuer")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTime>("LastUsedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long>("PlayerId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Subject")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("PlayerId");
|
||||
|
||||
b.HasIndex("Issuer", "Subject")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("ExternalLogins");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DorkNet.Server.Data.Entities.GameRewardSelectionEntity", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
@@ -3021,6 +3058,51 @@ namespace DorkNet.Server.Migrations
|
||||
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")
|
||||
@@ -3623,6 +3705,23 @@ namespace DorkNet.Server.Migrations
|
||||
b.ToTable("UgcPurchasables");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Microsoft.AspNetCore.DataProtection.EntityFrameworkCore.DataProtectionKey", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("FriendlyName")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Xml")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.ToTable("DataProtectionKeys");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DorkNet.Server.Data.Entities.AvatarEntity", b =>
|
||||
{
|
||||
b.HasOne("DorkNet.Server.Data.Entities.PlayerEntity", "Player")
|
||||
|
||||
@@ -19,13 +19,18 @@ public class AuthService(IConfiguration config, DorkNetDbContext db)
|
||||
// configs) and leave Jwt:Secret as the appsettings.json placeholder;
|
||||
// the mismatched-secret bug shows up only there.
|
||||
private string SecretKey =>
|
||||
Environment.GetEnvironmentVariable("DORKNET_JWT_SECRET")
|
||||
?? Environment.GetEnvironmentVariable("RECNET_JWT_SECRET")
|
||||
?? config["Jwt:Secret"]
|
||||
ConfiguredSecret(config)
|
||||
?? throw new InvalidOperationException(
|
||||
"JWT secret not configured. Set DORKNET_JWT_SECRET env var or " +
|
||||
"Jwt:Secret in appsettings.Local.json.");
|
||||
|
||||
/// <summary>The server secret, in the one resolution order every user of
|
||||
/// it shares (token signing, token validation, key encryption).</summary>
|
||||
public static string? ConfiguredSecret(IConfiguration config) =>
|
||||
Environment.GetEnvironmentVariable("DORKNET_JWT_SECRET")
|
||||
?? Environment.GetEnvironmentVariable("RECNET_JWT_SECRET")
|
||||
?? config["Jwt:Secret"];
|
||||
|
||||
public string GenerateToken(long playerId) => CreateJwt(playerId, TimeSpan.FromHours(12), "access");
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Xml.Linq;
|
||||
using Microsoft.AspNetCore.DataProtection.XmlEncryption;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
|
||||
namespace DorkNet.Server.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Encrypts the data-protection key ring (the keys behind the sign-in
|
||||
/// cookies) before it is stored in the <c>DataProtectionKeys</c> table:
|
||||
/// AES-256-GCM under a key derived (HKDF-SHA256) from the server secret.
|
||||
/// The secret comes from the environment, not the database, so a database
|
||||
/// backup alone holds no usable keys. Changing the secret makes the stored
|
||||
/// keys unreadable: the server then creates a new key and everyone signs in
|
||||
/// again.
|
||||
/// </summary>
|
||||
public sealed class DataProtectionKeyEncryption : IXmlEncryptor
|
||||
{
|
||||
private const int NonceSize = 12;
|
||||
private const int TagSize = 16;
|
||||
private static readonly byte[] Info = Encoding.UTF8.GetBytes("DorkNet.DataProtectionKeys.v1");
|
||||
private static readonly XName Root = "dorknetEncryptedKey";
|
||||
private readonly byte[] _key;
|
||||
|
||||
public DataProtectionKeyEncryption(string secret)
|
||||
{
|
||||
if (string.IsNullOrEmpty(secret))
|
||||
throw new InvalidOperationException("The server secret is required to encrypt the data-protection keys.");
|
||||
_key = HKDF.DeriveKey(HashAlgorithmName.SHA256, Encoding.UTF8.GetBytes(secret), 32, salt: null, info: Info);
|
||||
}
|
||||
|
||||
public EncryptedXmlInfo Encrypt(XElement plaintextElement)
|
||||
{
|
||||
var plaintext = Encoding.UTF8.GetBytes(plaintextElement.ToString(SaveOptions.DisableFormatting));
|
||||
var payload = new byte[NonceSize + TagSize + plaintext.Length];
|
||||
var nonce = payload.AsSpan(0, NonceSize);
|
||||
RandomNumberGenerator.Fill(nonce);
|
||||
using (var aes = new AesGcm(_key, TagSize))
|
||||
aes.Encrypt(nonce, plaintext, payload.AsSpan(NonceSize + TagSize), payload.AsSpan(NonceSize, TagSize));
|
||||
return new EncryptedXmlInfo(
|
||||
new XElement(Root, new XAttribute("version", "1"), Convert.ToBase64String(payload)),
|
||||
typeof(Decryptor));
|
||||
}
|
||||
|
||||
public XElement DecryptElement(XElement encrypted)
|
||||
{
|
||||
if (encrypted.Name != Root || (string?)encrypted.Attribute("version") != "1")
|
||||
throw new CryptographicException("Unrecognised encrypted data-protection key.");
|
||||
var payload = Convert.FromBase64String(encrypted.Value);
|
||||
if (payload.Length < NonceSize + TagSize)
|
||||
throw new CryptographicException("Encrypted data-protection key is truncated.");
|
||||
var plaintext = new byte[payload.Length - NonceSize - TagSize];
|
||||
using (var aes = new AesGcm(_key, TagSize))
|
||||
aes.Decrypt(payload.AsSpan(0, NonceSize), payload.AsSpan(NonceSize + TagSize),
|
||||
payload.AsSpan(NonceSize, TagSize), plaintext);
|
||||
return XElement.Parse(Encoding.UTF8.GetString(plaintext));
|
||||
}
|
||||
|
||||
/// <summary>Created by the data-protection system from the stored type
|
||||
/// name, with the app's service provider.</summary>
|
||||
public sealed class Decryptor(IServiceProvider services) : IXmlDecryptor
|
||||
{
|
||||
public XElement Decrypt(XElement encryptedElement) =>
|
||||
services.GetRequiredService<DataProtectionKeyEncryption>().DecryptElement(encryptedElement);
|
||||
}
|
||||
}
|
||||
@@ -39,6 +39,7 @@ public sealed class DownloadsService
|
||||
}
|
||||
var host = config["DorkNet:Downloads:Host"]?.Trim();
|
||||
_host = string.IsNullOrEmpty(host) ? null : host;
|
||||
RequireSignIn = config.GetValue<bool>("DorkNet:Downloads:RequireSignIn");
|
||||
|
||||
if (_baseUri is null)
|
||||
log.LogInformation("[downloads] no DorkNet:Downloads:BaseUrl; the Downloads page and Quest latest-build install are off");
|
||||
@@ -48,6 +49,10 @@ public sealed class DownloadsService
|
||||
|
||||
public bool IsConfigured => _baseUri is not null;
|
||||
|
||||
/// <summary><c>DorkNet:Downloads:RequireSignIn</c>: serve the manifest and
|
||||
/// files only to a signed-in, linked player (website sign-in).</summary>
|
||||
public bool RequireSignIn { get; }
|
||||
|
||||
/// <summary>The published manifest, or null when none has been published yet.</summary>
|
||||
public async Task<JsonObject?> GetManifestAsync(CancellationToken ct)
|
||||
{
|
||||
|
||||
@@ -0,0 +1,157 @@
|
||||
using System.Collections.Concurrent;
|
||||
using System.Security.Claims;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using DorkNet.Server.Data;
|
||||
using DorkNet.Server.Data.Entities;
|
||||
|
||||
namespace DorkNet.Server.Services;
|
||||
|
||||
/// <summary>
|
||||
/// External sign-in settings (<c>DorkNet:SignIn:*</c>): an OpenID Connect
|
||||
/// provider players sign in with on the website and, for admins, the admin
|
||||
/// panel. Any standard provider works (e.g. a Cloudflare Access SaaS app,
|
||||
/// Google, Authentik). Unset, the feature is off and only the existing
|
||||
/// username/password logins exist.
|
||||
/// </summary>
|
||||
public sealed class ExternalSignInOptions
|
||||
{
|
||||
/// <summary>The provider's issuer URL; discovery is read from
|
||||
/// <c>{Authority}/.well-known/openid-configuration</c>.</summary>
|
||||
public string Authority { get; set; } = string.Empty;
|
||||
public string ClientId { get; set; } = string.Empty;
|
||||
public string ClientSecret { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>The button label: "Sign in with {DisplayName}".</summary>
|
||||
public string DisplayName { get; set; } = "single sign-on";
|
||||
|
||||
public string Scopes { get; set; } = "openid email profile";
|
||||
|
||||
/// <summary>Also read the provider's userinfo endpoint after sign-in. Off by
|
||||
/// default: the ID token already carries <c>sub</c> and <c>email</c>, and
|
||||
/// Cloudflare Access answers userinfo without a Content-Type, which the
|
||||
/// OpenID Connect handler rejects.</summary>
|
||||
public bool UseUserInfoEndpoint { get; set; }
|
||||
|
||||
public bool IsConfigured =>
|
||||
Authority.Length > 0 && ClientId.Length > 0 && ClientSecret.Length > 0;
|
||||
}
|
||||
|
||||
/// <summary>Scheme, path and claim names shared by the sign-in pieces.</summary>
|
||||
public static class ExternalSignIn
|
||||
{
|
||||
/// <summary>The signed-in session (a linked DorkNet player), per host.</summary>
|
||||
public const string WebScheme = "DorkNet.Web";
|
||||
|
||||
/// <summary>A provider identity that isn't linked to an account yet. Lives
|
||||
/// only until the player links it (or 15 minutes).</summary>
|
||||
public const string ExternalScheme = "DorkNet.External";
|
||||
|
||||
public const string OidcScheme = "DorkNet.Oidc";
|
||||
|
||||
// Everything lives under /api/: the website and admin hosts answer every
|
||||
// other GET with their SPA's index.html before authentication runs.
|
||||
public const string LoginPath = "/api/auth/external/login";
|
||||
public const string CallbackPath = "/api/auth/external/callback";
|
||||
public const string CompletePath = "/api/auth/external/complete";
|
||||
|
||||
public const string PlayerIdClaim = "dorknet:player_id";
|
||||
public const string IssuerClaim = "dorknet:issuer";
|
||||
public const string SubjectClaim = "dorknet:subject";
|
||||
public const string EmailClaim = "dorknet:email";
|
||||
|
||||
public static ClaimsPrincipal PlayerPrincipal(PlayerEntity player, ExternalLoginEntity link) =>
|
||||
new(new ClaimsIdentity(new[]
|
||||
{
|
||||
new Claim(PlayerIdClaim, player.Id.ToString()),
|
||||
new Claim(ClaimTypes.Name, player.Username),
|
||||
new Claim(IssuerClaim, link.Issuer),
|
||||
new Claim(SubjectClaim, link.Subject),
|
||||
new Claim(EmailClaim, link.Email),
|
||||
}, WebScheme, ClaimTypes.Name, ClaimTypes.Role));
|
||||
|
||||
public static long? PlayerId(ClaimsPrincipal? principal) =>
|
||||
long.TryParse(principal?.FindFirst(PlayerIdClaim)?.Value, out var id) ? id : null;
|
||||
}
|
||||
|
||||
public enum LinkOutcome { Linked, InvalidCredentials, Banned, AlreadyLinkedElsewhere }
|
||||
|
||||
/// <summary>Links provider identities to DorkNet accounts and resolves them
|
||||
/// back. An identity is (issuer, subject); the email is only shown.</summary>
|
||||
public sealed class ExternalSignInService(DorkNetDbContext db)
|
||||
{
|
||||
public Task<ExternalLoginEntity?> FindAsync(string issuer, string subject) =>
|
||||
db.ExternalLogins.FirstOrDefaultAsync(l => l.Issuer == issuer && l.Subject == subject);
|
||||
|
||||
/// <summary>The linked player, unless the link or the player is gone or the
|
||||
/// player is banned.</summary>
|
||||
public async Task<PlayerEntity?> ActivePlayerAsync(ExternalLoginEntity link)
|
||||
{
|
||||
var player = await db.Players.FirstOrDefaultAsync(p => p.Id == link.PlayerId);
|
||||
return player is null || IsBanned(player) ? null : player;
|
||||
}
|
||||
|
||||
public async Task TouchAsync(ExternalLoginEntity link, string email)
|
||||
{
|
||||
link.LastUsedAt = DateTime.UtcNow;
|
||||
if (!string.IsNullOrEmpty(email)) link.Email = email;
|
||||
await db.SaveChangesAsync();
|
||||
}
|
||||
|
||||
/// <summary>Links (issuer, subject) to the account the credentials prove.
|
||||
/// A player has one identity per provider: linking a new one replaces the
|
||||
/// old. An identity already linked to a different account is refused.</summary>
|
||||
public async Task<(LinkOutcome Outcome, PlayerEntity? Player, ExternalLoginEntity? Link)> LinkAsync(
|
||||
string issuer, string subject, string email, string username, string password)
|
||||
{
|
||||
var player = await db.Players.FirstOrDefaultAsync(p => p.Username == username);
|
||||
if (player?.PasswordHash is null || !BCrypt.Net.BCrypt.Verify(password, player.PasswordHash))
|
||||
return (LinkOutcome.InvalidCredentials, null, null);
|
||||
if (IsBanned(player)) return (LinkOutcome.Banned, null, null);
|
||||
|
||||
var existing = await FindAsync(issuer, subject);
|
||||
if (existing is not null && existing.PlayerId != player.Id)
|
||||
return (LinkOutcome.AlreadyLinkedElsewhere, null, null);
|
||||
|
||||
if (existing is null)
|
||||
{
|
||||
db.ExternalLogins.RemoveRange(
|
||||
db.ExternalLogins.Where(l => l.PlayerId == player.Id && l.Issuer == issuer));
|
||||
existing = new ExternalLoginEntity { PlayerId = player.Id, Issuer = issuer, Subject = subject };
|
||||
db.ExternalLogins.Add(existing);
|
||||
}
|
||||
existing.Email = email;
|
||||
existing.LastUsedAt = DateTime.UtcNow;
|
||||
await db.SaveChangesAsync();
|
||||
return (LinkOutcome.Linked, player, existing);
|
||||
}
|
||||
|
||||
public async Task<bool> UnlinkAsync(long playerId, string issuer) =>
|
||||
await db.ExternalLogins.Where(l => l.PlayerId == playerId && l.Issuer == issuer).ExecuteDeleteAsync() > 0;
|
||||
|
||||
private static bool IsBanned(PlayerEntity p) => p.BannedUntil is { } until && until > DateTime.UtcNow;
|
||||
}
|
||||
|
||||
/// <summary>Caps failed link attempts (a password guess each) per provider
|
||||
/// identity and per username: 5 per 15 minutes each. In memory, per process,
|
||||
/// which is enough to stop guessing through the link form.</summary>
|
||||
public sealed class LinkAttemptLimiter
|
||||
{
|
||||
private const int MaxFailures = 5;
|
||||
private static readonly TimeSpan Window = TimeSpan.FromMinutes(15);
|
||||
private readonly ConcurrentDictionary<string, (int Count, DateTime Since)> _failures = new();
|
||||
|
||||
public bool IsBlocked(params string[] keys) => keys.Any(k =>
|
||||
_failures.TryGetValue(k, out var f) && DateTime.UtcNow - f.Since < Window && f.Count >= MaxFailures);
|
||||
|
||||
public void RecordFailure(params string[] keys)
|
||||
{
|
||||
var now = DateTime.UtcNow;
|
||||
foreach (var k in keys)
|
||||
_failures.AddOrUpdate(k, (1, now), (_, f) => now - f.Since >= Window ? (1, now) : (f.Count + 1, f.Since));
|
||||
}
|
||||
|
||||
public void Reset(params string[] keys)
|
||||
{
|
||||
foreach (var k in keys) _failures.TryRemove(k, out _);
|
||||
}
|
||||
}
|
||||
@@ -311,7 +311,7 @@ public class NotificationService(
|
||||
{
|
||||
["roomInstanceId"] = redacted.RoomInstanceId,
|
||||
["roomId"] = redacted.RoomId,
|
||||
["subRoomId"] = redacted.SubRoomId,
|
||||
["subRoomId"] = redacted.WireSubRoomId,
|
||||
["roomInstanceType"] = redacted.RoomInstanceType,
|
||||
["location"] = redacted.Location,
|
||||
["dataBlob"] = redacted.DataBlob,
|
||||
|
||||
@@ -105,6 +105,15 @@ public class PlayerPresenceService
|
||||
var db = mux.GetDatabase();
|
||||
db.StringSet(Key(playerId), json, PresenceTtl);
|
||||
db.KeyDelete(NoRoomKey(playerId));
|
||||
// Per-room membership index for the instance browser
|
||||
// (EnumerateActiveInstances). Members are validated against
|
||||
// the player's live presence on read, so a player who moved
|
||||
// on is pruned lazily instead of needing the old room here.
|
||||
if (room is not null && room.RoomId > 0)
|
||||
{
|
||||
db.SetAdd(RoomIndexKey(room.RoomId), playerId);
|
||||
db.KeyExpire(RoomIndexKey(room.RoomId), PresenceTtl + PresenceTtl);
|
||||
}
|
||||
_log?.LogDebug("[presence] set redis player={PlayerId} room={Room} bytes={Bytes}",
|
||||
playerId, room?.Name ?? "<null>", json.Length);
|
||||
}
|
||||
@@ -132,7 +141,19 @@ public class PlayerPresenceService
|
||||
{
|
||||
try
|
||||
{
|
||||
mux.GetDatabase().KeyExpire(Key(playerId), PresenceTtl);
|
||||
var db = mux.GetDatabase();
|
||||
db.KeyExpire(Key(playerId), PresenceTtl);
|
||||
// Keep the room's membership index alive as long as its
|
||||
// members are: a player refreshed only through here would
|
||||
// otherwise outlive the set (SetRoom's 2 × TTL) and drop out
|
||||
// of the instance browser and room pushes.
|
||||
var json = db.StringGet(Key(playerId));
|
||||
if (json.HasValue &&
|
||||
JsonSerializer.Deserialize<RoomInstanceDto>(json.ToString(), JsonOpts) is { RoomId: > 0 } room)
|
||||
{
|
||||
db.SetAdd(RoomIndexKey(room.RoomId), playerId);
|
||||
db.KeyExpire(RoomIndexKey(room.RoomId), PresenceTtl + PresenceTtl);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
@@ -459,45 +480,97 @@ public class PlayerPresenceService
|
||||
_localNoRoomExpires[playerId] = DateTimeOffset.UtcNow.Add(PresenceTtl);
|
||||
}
|
||||
|
||||
/// <summary>List the currently-active instances of a given room,
|
||||
/// grouped by PhotonRoomId, with the player ids currently presenced
|
||||
/// in each. Drives the watch's RoomInstanceBrowser
|
||||
/// (<c>GET match.*/room/{id}/instances</c>) so players can see
|
||||
/// which instance their friends are in.
|
||||
/// <summary>List the live instances of a given room, grouped by
|
||||
/// PhotonRoomId (or RoomInstanceId when a row has none), with the player
|
||||
/// ids currently presenced in each. Drives the room instance browser
|
||||
/// (<c>GET match.*/room/{id}/instances</c>) and the event instance list.
|
||||
///
|
||||
/// Redis caveat: without a secondary index per room we'd need a
|
||||
/// SCAN over every presence key — too expensive to do on each
|
||||
/// request. For now the Redis path returns empty and the local
|
||||
/// in-process dictionary is the source of truth. Single-instance
|
||||
/// deployments work; multi-replica Redis deployments need a
|
||||
/// per-room set index to be added before the browser is reliable
|
||||
/// there.</summary>
|
||||
public IEnumerable<(RoomInstanceDto Room, List<long> PlayerIds)> EnumerateActiveInstances(long roomId)
|
||||
/// <para>Only players whose presence is still live count: an expired
|
||||
/// room presence (<see cref="PresenceTtl"/>) is dropped, and so is a
|
||||
/// player whose activity marker (<see cref="ActivityTtl"/>, refreshed by
|
||||
/// every heartbeat and join) has lapsed. A client that crashed keeps its
|
||||
/// room presence for up to ten minutes, but it is not in the instance and
|
||||
/// must not inflate the player count or keep an empty instance listed.</para>
|
||||
///
|
||||
/// <para><b>Redis.</b> <see cref="SetRoom"/> records each player in a
|
||||
/// per-room set (<c>presence-room:{roomId}</c>); this reads that set and
|
||||
/// validates every member against their current presence, pruning the
|
||||
/// ones that have moved on or expired. Without the index the Redis path
|
||||
/// had nothing to enumerate and returned an empty list on every
|
||||
/// multi-replica deployment.</para>
|
||||
///
|
||||
/// <para>Private instances are skipped unless
|
||||
/// <paramref name="includePrivate"/> is set; callers that include them
|
||||
/// must apply their own visibility rule.</para></summary>
|
||||
public IEnumerable<(RoomInstanceDto Room, List<long> PlayerIds)> EnumerateActiveInstances(
|
||||
long roomId, bool includePrivate = false)
|
||||
{
|
||||
if (_redis is not null) return Array.Empty<(RoomInstanceDto, List<long>)>();
|
||||
var groups = new Dictionary<string, (RoomInstanceDto Room, List<long> PlayerIds)>(StringComparer.Ordinal);
|
||||
foreach (var kv in _local)
|
||||
var members = new List<(long PlayerId, RoomInstanceDto Room)>();
|
||||
if (_redis is { } mux)
|
||||
{
|
||||
var room = kv.Value;
|
||||
if (room is null || room.RoomId != roomId) continue;
|
||||
// Skip private instances — those are surfaced separately
|
||||
// in the controller via PrivateInstanceService so we never
|
||||
// leak a photonRoomId for an invite-only match into the
|
||||
// public browser.
|
||||
if (room.IsPrivate) continue;
|
||||
var key = string.IsNullOrEmpty(room.PhotonRoomId)
|
||||
? $"<{room.RoomInstanceId}>"
|
||||
: room.PhotonRoomId;
|
||||
try
|
||||
{
|
||||
var db = mux.GetDatabase();
|
||||
var stale = new List<RedisValue>();
|
||||
foreach (var member in db.SetMembers(RoomIndexKey(roomId)))
|
||||
{
|
||||
if (!long.TryParse(member.ToString(), out var playerId))
|
||||
{
|
||||
stale.Add(member);
|
||||
continue;
|
||||
}
|
||||
var room = GetRoom(playerId);
|
||||
if (room is null || room.RoomId != roomId)
|
||||
{
|
||||
stale.Add(member);
|
||||
continue;
|
||||
}
|
||||
members.Add((playerId, room));
|
||||
}
|
||||
if (stale.Count > 0)
|
||||
db.SetRemove(RoomIndexKey(roomId), stale.ToArray());
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_log?.LogWarning(ex, "[presence] redis room index scan failed room={RoomId}", roomId);
|
||||
return Array.Empty<(RoomInstanceDto, List<long>)>();
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
foreach (var playerId in _local.Keys.ToList())
|
||||
{
|
||||
var room = GetRoom(playerId); // honours the room TTL
|
||||
if (room is not null && room.RoomId == roomId)
|
||||
members.Add((playerId, room));
|
||||
}
|
||||
}
|
||||
|
||||
var groups = new Dictionary<string, (RoomInstanceDto Room, List<long> PlayerIds)>(StringComparer.Ordinal);
|
||||
foreach (var (playerId, room) in members)
|
||||
{
|
||||
if (room.IsPrivate && !includePrivate) continue;
|
||||
if (!IsRecentlyActive(playerId)) continue;
|
||||
var key = InstanceKey(room);
|
||||
if (!groups.TryGetValue(key, out var entry))
|
||||
{
|
||||
entry = (room, new List<long>());
|
||||
groups[key] = entry;
|
||||
}
|
||||
entry.PlayerIds.Add(kv.Key);
|
||||
entry.PlayerIds.Add(playerId);
|
||||
}
|
||||
return groups.Values;
|
||||
}
|
||||
|
||||
/// <summary>The identity two presence rows share when they are in the
|
||||
/// same live instance: the Photon room, else the instance id.</summary>
|
||||
public static string InstanceKey(RoomInstanceDto room)
|
||||
=> string.IsNullOrEmpty(room.PhotonRoomId)
|
||||
? "<" + room.RoomInstanceId + ">"
|
||||
: room.PhotonRoomId;
|
||||
|
||||
private static string RoomIndexKey(long roomId) => "presence-room:" + roomId;
|
||||
|
||||
private static string Key(long playerId) => $"presence:{playerId}";
|
||||
private static string ActiveKey(long playerId) => $"presence-active:{playerId}";
|
||||
private static string BootDormGraceKey(long playerId) => $"presence-boot-dorm-grace:{playerId}";
|
||||
|
||||
@@ -0,0 +1,198 @@
|
||||
using System.Text.Json;
|
||||
using DorkNet.Server.Data;
|
||||
using DorkNet.Server.Data.Entities;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace DorkNet.Server.Services;
|
||||
|
||||
/// <summary>
|
||||
/// A sub-room's role-permission matrix: the cells the 2023 client reads from
|
||||
/// <c>GET photon_access_token</c> (<c>ALIIIAGAPND.Permissions</c>, a list of
|
||||
/// <c>BNLPBMJJOMM</c>) and writes with
|
||||
/// <c>PUT rooms/{id}/subrooms/{sub}/permissions</c>.
|
||||
///
|
||||
/// <para>The served list is the built-in defaults with every stored cell for
|
||||
/// the room's sub-room applied on top: a stored cell replaces the default
|
||||
/// with the same (Role, Permission), and cells with no default are appended.
|
||||
/// The list must never be empty — with no Permissions the room-permissions
|
||||
/// runtime (OMJKHJLFOCO) falls into its persisted-role migration path, which
|
||||
/// NREs during first spawn.</para>
|
||||
///
|
||||
/// <para>Cell keys (formatter RecNet.Runtime/HDOJMEBCAGP.txt):
|
||||
/// <c>Permission</c> (string), <c>Role</c> (OMMBGJMJJPN), <c>Override</c>
|
||||
/// (bool), <c>Type</c> (FEOJFGECKGH: Bool 0 / Int 1 / StringList 2),
|
||||
/// <c>Value</c> (string). Role and Type go out as numbers; requests are
|
||||
/// accepted with numbers, numeric strings or enum names.</para>
|
||||
/// </summary>
|
||||
public static class RoomPermissionMatrix
|
||||
{
|
||||
/// <summary>One matrix cell.</summary>
|
||||
public sealed record Cell(string Permission, int Role, bool Override, int Type, string Value);
|
||||
|
||||
/// <summary>The defaults every sub-room starts from: Co-Owner and Creator
|
||||
/// may use the maker pen, spawn inventions and consumables, reset the
|
||||
/// room and toggle play gizmos.</summary>
|
||||
public static IReadOnlyList<Cell> Defaults { get; } = BuildDefaults();
|
||||
|
||||
private static IReadOnlyList<Cell> BuildDefaults()
|
||||
{
|
||||
var permissions = new[]
|
||||
{
|
||||
"13", // Can Use Maker Pen
|
||||
"20", // Can Spawn Inventions
|
||||
"21", // Can Spawn Consumables
|
||||
"22", // Can Use Room Reset Button
|
||||
"23", // Can Use Play Gizmos Toggle
|
||||
};
|
||||
var list = new List<Cell>();
|
||||
foreach (var role in new[] { RoomRoleRules.WireCoOwner, RoomRoleRules.WireCreator })
|
||||
foreach (var permission in permissions)
|
||||
list.Add(new Cell(permission, role, true, 0, "true"));
|
||||
return list;
|
||||
}
|
||||
|
||||
/// <summary>Defaults merged with the stored cells.</summary>
|
||||
public static List<Cell> Merge(IEnumerable<RoomRolePermissionEntity> stored)
|
||||
{
|
||||
var cells = Defaults.ToList();
|
||||
foreach (var row in stored.OrderBy(r => r.Id))
|
||||
{
|
||||
var cell = new Cell(row.Permission, row.Role, row.Override, row.Type, row.Value);
|
||||
var at = cells.FindIndex(c =>
|
||||
c.Role == row.Role
|
||||
&& string.Equals(c.Permission, row.Permission, StringComparison.Ordinal));
|
||||
if (at >= 0) cells[at] = cell;
|
||||
else cells.Add(cell);
|
||||
}
|
||||
return cells;
|
||||
}
|
||||
|
||||
/// <summary>The served matrix for one sub-room (by OrderIndex).</summary>
|
||||
public static async Task<List<Cell>> LoadAsync(DorkNetDbContext db, long roomId, long subRoomOrderIndex)
|
||||
{
|
||||
var stored = await db.RoomRolePermissions.AsNoTracking()
|
||||
.Where(p => p.RoomId == roomId && p.SubRoomId == subRoomOrderIndex)
|
||||
.ToListAsync();
|
||||
return Merge(stored);
|
||||
}
|
||||
|
||||
/// <summary>The <c>ALIIIAGAPND</c> body: <c>{RoomInstanceId,
|
||||
/// PhotonAccessToken, Permissions}</c>. Used both by
|
||||
/// <c>GET photon_access_token</c> and the <c>PhotonAccessToken</c> hub
|
||||
/// push (IBEOONPEELF.txt:519 registers it as Action<ALIIIAGAPND>).</summary>
|
||||
public static Dictionary<string, object> AccessTokenBody(long roomInstanceId, IEnumerable<Cell> cells)
|
||||
=> new()
|
||||
{
|
||||
["RoomInstanceId"] = roomInstanceId,
|
||||
["PhotonAccessToken"] = string.Empty,
|
||||
["Permissions"] = cells.Select(ToWire).ToList(),
|
||||
};
|
||||
|
||||
public static object ToWire(Cell c) => new
|
||||
{
|
||||
c.Permission,
|
||||
c.Role,
|
||||
c.Override,
|
||||
c.Type,
|
||||
c.Value,
|
||||
};
|
||||
|
||||
/// <summary>Parse the PUT body: a JSON array of cells (an object wrapping
|
||||
/// a <c>Permissions</c> array is accepted too). Returns null when the body
|
||||
/// is not JSON or holds no array.</summary>
|
||||
public static List<Cell>? Parse(string body)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(body)) return null;
|
||||
JsonDocument doc;
|
||||
try { doc = JsonDocument.Parse(body); }
|
||||
catch (JsonException) { return null; }
|
||||
using (doc)
|
||||
{
|
||||
var root = doc.RootElement;
|
||||
if (root.ValueKind == JsonValueKind.Object)
|
||||
{
|
||||
root = root.EnumerateObject()
|
||||
.Where(p => p.Name.Equals("Permissions", StringComparison.OrdinalIgnoreCase))
|
||||
.Select(p => p.Value)
|
||||
.FirstOrDefault();
|
||||
}
|
||||
if (root.ValueKind != JsonValueKind.Array) return null;
|
||||
|
||||
var cells = new List<Cell>();
|
||||
foreach (var item in root.EnumerateArray())
|
||||
{
|
||||
if (item.ValueKind != JsonValueKind.Object) continue;
|
||||
string? permission = null;
|
||||
int? role = null;
|
||||
bool @override = false;
|
||||
int type = 0;
|
||||
string value = string.Empty;
|
||||
foreach (var prop in item.EnumerateObject())
|
||||
{
|
||||
switch (prop.Name.ToLowerInvariant())
|
||||
{
|
||||
case "permission":
|
||||
permission = Scalar(prop.Value)?.Trim();
|
||||
break;
|
||||
case "role":
|
||||
role = EnumValue(prop.Value, RoleName);
|
||||
break;
|
||||
case "type":
|
||||
type = EnumValue(prop.Value, TypeName) ?? 0;
|
||||
break;
|
||||
case "override":
|
||||
@override = prop.Value.ValueKind == JsonValueKind.True
|
||||
|| (prop.Value.ValueKind == JsonValueKind.String
|
||||
&& bool.TryParse(prop.Value.GetString(), out var b) && b);
|
||||
break;
|
||||
case "value":
|
||||
value = Scalar(prop.Value) ?? string.Empty;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (string.IsNullOrEmpty(permission) || role is null) continue;
|
||||
if (permission.Length > 128 || value.Length > 4096) continue;
|
||||
cells.Add(new Cell(permission, role.Value, @override, type, value));
|
||||
}
|
||||
return cells;
|
||||
}
|
||||
}
|
||||
|
||||
private static string? Scalar(JsonElement e) => e.ValueKind switch
|
||||
{
|
||||
JsonValueKind.String => e.GetString(),
|
||||
JsonValueKind.Number => e.GetRawText(),
|
||||
JsonValueKind.True => "true",
|
||||
JsonValueKind.False => "false",
|
||||
JsonValueKind.Null or JsonValueKind.Undefined => null,
|
||||
_ => e.GetRawText(),
|
||||
};
|
||||
|
||||
private static int? EnumValue(JsonElement e, Func<string, int?> byName)
|
||||
{
|
||||
if (e.ValueKind == JsonValueKind.Number && e.TryGetInt32(out var n)) return n;
|
||||
if (e.ValueKind != JsonValueKind.String) return null;
|
||||
var s = e.GetString() ?? string.Empty;
|
||||
return int.TryParse(s, out n) ? n : byName(s);
|
||||
}
|
||||
|
||||
private static int? RoleName(string s) => s.Trim().ToLowerInvariant() switch
|
||||
{
|
||||
"none" => RoomRoleRules.WireNone,
|
||||
"banned" => RoomRoleRules.WireBanned,
|
||||
"host" => RoomRoleRules.WireHost,
|
||||
"moderator" => RoomRoleRules.WireModerator,
|
||||
"coowner" => RoomRoleRules.WireCoOwner,
|
||||
"temporarycoowner" => RoomRoleRules.WireTemporaryCoOwner,
|
||||
"creator" => RoomRoleRules.WireCreator,
|
||||
_ => null,
|
||||
};
|
||||
|
||||
private static int? TypeName(string s) => s.Trim().ToLowerInvariant() switch
|
||||
{
|
||||
"bool" => 0,
|
||||
"int" => 1,
|
||||
"stringlist" => 2,
|
||||
_ => null,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,222 @@
|
||||
using DorkNet.Server.Data.Entities;
|
||||
|
||||
namespace DorkNet.Server.Services;
|
||||
|
||||
/// <summary>
|
||||
/// The 2023 room-role ladder and who may change whose role.
|
||||
///
|
||||
/// <para><b>Wire enum.</b> The client's room role is <c>OMMBGJMJJPN : byte</c>
|
||||
/// (recnet-runtime-decomp/OMMBGJMJJPN.cs): None 0, Banned 1, Host 10,
|
||||
/// Moderator 20, CoOwner 30, TemporaryCoOwner 31, Creator 255. It is
|
||||
/// gap-skipping, like the club MembershipType enum, so never treat it as an
|
||||
/// index. <see cref="RoomRoleEntity.Role"/> stores a compact kind instead
|
||||
/// (0 = CoOwner, 1 = Moderator, 2 = Host); <see cref="ToWire"/> and
|
||||
/// <see cref="TryFromWire"/> convert.</para>
|
||||
///
|
||||
/// <para><b>The creator is 255, not 30.</b> The client reads the local
|
||||
/// player's role straight out of the room details <c>Roles</c> list
|
||||
/// (<c>FGCPNAACHIK.ONECLLALJEO</c>: first row whose AccountId matches), and
|
||||
/// the room-permissions runtime only lets a player edit a role strictly
|
||||
/// below their own, with Creator (255) the one unconditional pass
|
||||
/// (<c>OMJKHJLFOCO.DIIMLMKEJLC</c>,
|
||||
/// IsilDump/RecRoom.RoomPermissions.Runtime/OMJKHJLFOCO.txt:2367:
|
||||
/// <c>if role == 255 return true; if target >= role return false</c>).
|
||||
/// Advertising the creator as CoOwner (30) therefore locked the room's own
|
||||
/// creator out of the Co-Owner and Creator permission rows.</para>
|
||||
///
|
||||
/// <para><b>Server rule</b> (<see cref="CheckChange"/>): the creator may set
|
||||
/// any role on anyone but themselves. Otherwise the actor must hold an
|
||||
/// accepted CoOwner role, and both the target's current role (accepted or
|
||||
/// still invited) and the requested role must be strictly below the
|
||||
/// actor's. A player may always drop their own role or decline their own
|
||||
/// invite (role 0), and may accept exactly the role they were invited to.</para>
|
||||
/// </summary>
|
||||
public static class RoomRoleRules
|
||||
{
|
||||
public const int WireNone = 0;
|
||||
public const int WireBanned = 1;
|
||||
public const int WireHost = 10;
|
||||
public const int WireModerator = 20;
|
||||
public const int WireCoOwner = 30;
|
||||
public const int WireTemporaryCoOwner = 31;
|
||||
public const int WireCreator = 255;
|
||||
|
||||
public const int StoredCoOwner = 0;
|
||||
public const int StoredModerator = 1;
|
||||
public const int StoredHost = 2;
|
||||
|
||||
/// <summary>Stored kind → wire role.</summary>
|
||||
public static int ToWire(int storedRole) => storedRole switch
|
||||
{
|
||||
StoredCoOwner => WireCoOwner,
|
||||
StoredModerator => WireModerator,
|
||||
StoredHost => WireHost,
|
||||
_ => WireNone,
|
||||
};
|
||||
|
||||
/// <summary>Wire role → stored kind. Only the three persistable roles
|
||||
/// map; None, Banned, TemporaryCoOwner (an in-instance grant) and Creator
|
||||
/// (ownership, changed through <c>rooms/{id}/creator</c>) do not.</summary>
|
||||
public static bool TryFromWire(int wireRole, out int storedRole)
|
||||
{
|
||||
storedRole = wireRole switch
|
||||
{
|
||||
WireCoOwner => StoredCoOwner,
|
||||
WireModerator => StoredModerator,
|
||||
WireHost => StoredHost,
|
||||
_ => -1,
|
||||
};
|
||||
return storedRole >= 0;
|
||||
}
|
||||
|
||||
/// <summary>The player's accepted wire role in the room: 255 for the
|
||||
/// creator, the highest accepted grant otherwise, 0 for none.</summary>
|
||||
public static int AcceptedRank(RoomEntity room, IEnumerable<RoomRoleEntity> rows, long playerId)
|
||||
{
|
||||
if (room.CreatorPlayerId == playerId) return WireCreator;
|
||||
return rows
|
||||
.Where(r => r.RoomId == room.Id && r.PlayerId == playerId && r.Accepted)
|
||||
.Select(r => ToWire(r.Role))
|
||||
.DefaultIfEmpty(WireNone)
|
||||
.Max();
|
||||
}
|
||||
|
||||
/// <summary>The highest role the player has been invited to and not yet
|
||||
/// accepted, 0 for none.</summary>
|
||||
public static int InvitedRank(RoomEntity room, IEnumerable<RoomRoleEntity> rows, long playerId)
|
||||
=> rows
|
||||
.Where(r => r.RoomId == room.Id && r.PlayerId == playerId && !r.Accepted)
|
||||
.Select(r => ToWire(r.Role))
|
||||
.DefaultIfEmpty(WireNone)
|
||||
.Max();
|
||||
|
||||
/// <summary>True when the player may change room-level settings and the
|
||||
/// sub-room settings that hang off them: the creator or an accepted
|
||||
/// co-owner. This is the same gate as the bare room mutations
|
||||
/// (RoomsModerationController.RequireOwnedRoomAsync) and room saves.</summary>
|
||||
public static bool CanEditRoom(RoomEntity room, IEnumerable<RoomRoleEntity> rows, long playerId)
|
||||
=> AcceptedRank(room, rows, playerId) >= WireCoOwner;
|
||||
|
||||
/// <summary>What a role write will do once <see cref="CheckChange"/> allows it.</summary>
|
||||
public enum ChangeKind
|
||||
{
|
||||
/// <summary>Grant or replace the target's role (accepted at once).</summary>
|
||||
Grant,
|
||||
/// <summary>Record a pending invite the target must accept.</summary>
|
||||
Invite,
|
||||
/// <summary>The target accepts their own pending invite.</summary>
|
||||
AcceptInvite,
|
||||
/// <summary>Remove the target's role and any pending invite.</summary>
|
||||
Clear,
|
||||
}
|
||||
|
||||
/// <summary>Decide whether <paramref name="actorId"/> may set
|
||||
/// <paramref name="targetId"/>'s role to <paramref name="requestedWireRole"/>.
|
||||
/// Returns null when allowed, otherwise an error code for the response.
|
||||
/// <paramref name="kind"/> is what the caller should then do.</summary>
|
||||
public static string? CheckChange(
|
||||
RoomEntity room,
|
||||
IReadOnlyCollection<RoomRoleEntity> rows,
|
||||
long actorId,
|
||||
long targetId,
|
||||
int requestedWireRole,
|
||||
bool isInvite,
|
||||
out ChangeKind kind)
|
||||
{
|
||||
kind = requestedWireRole == WireNone
|
||||
? ChangeKind.Clear
|
||||
: isInvite ? ChangeKind.Invite : ChangeKind.Grant;
|
||||
|
||||
if (targetId == room.CreatorPlayerId)
|
||||
return "cannot_change_creator_role";
|
||||
|
||||
if (requestedWireRole != WireNone && !TryFromWire(requestedWireRole, out _))
|
||||
return "invalid_role";
|
||||
|
||||
var actorRank = AcceptedRank(room, rows, actorId);
|
||||
|
||||
// Self-service: anyone may give up their own role or decline their
|
||||
// own invite, and accept the exact role they were invited to.
|
||||
if (actorId == targetId && actorRank != WireCreator)
|
||||
{
|
||||
if (requestedWireRole == WireNone) return null;
|
||||
if (!isInvite && requestedWireRole == InvitedRank(room, rows, targetId))
|
||||
{
|
||||
kind = ChangeKind.AcceptInvite;
|
||||
return null;
|
||||
}
|
||||
return "forbidden";
|
||||
}
|
||||
|
||||
if (actorRank == WireCreator) return null;
|
||||
if (actorRank < WireCoOwner) return "forbidden";
|
||||
|
||||
var targetRank = Math.Max(
|
||||
AcceptedRank(room, rows, targetId),
|
||||
InvitedRank(room, rows, targetId));
|
||||
if (targetRank >= actorRank) return "forbidden";
|
||||
if (requestedWireRole >= actorRank) return "forbidden";
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <summary>True when <paramref name="playerId"/> may write the
|
||||
/// permission row for <paramref name="wireRole"/>: the creator may edit
|
||||
/// every role (including CoOwner and Creator); a co-owner only the roles
|
||||
/// below their own. Mirrors the client's OMJKHJLFOCO.DIIMLMKEJLC.</summary>
|
||||
public static bool CanEditRolePermissions(
|
||||
RoomEntity room, IEnumerable<RoomRoleEntity> rows, long playerId, int wireRole)
|
||||
{
|
||||
var rank = AcceptedRank(room, rows, playerId);
|
||||
if (rank == WireCreator) return true;
|
||||
if (rank < WireCoOwner) return false;
|
||||
// TemporaryCoOwner collapses to CoOwner on the client (ONECLLALJEO).
|
||||
var target = wireRole == WireTemporaryCoOwner ? WireCoOwner : wireRole;
|
||||
return target < rank;
|
||||
}
|
||||
|
||||
/// <summary>The room details / roles-list rows: one per account, with
|
||||
/// the creator first as 255 and every other account's accepted role in
|
||||
/// <c>Role</c> and pending invite in <c>InvitedRole</c>. Rows the
|
||||
/// creator happens to own in RoomRoles are ignored so the creator is
|
||||
/// never listed twice (the client takes the first match).</summary>
|
||||
public static List<RoomRoleWire> BuildWires(RoomEntity room, IEnumerable<RoomRoleEntity> rows)
|
||||
{
|
||||
var list = new List<RoomRoleWire>
|
||||
{
|
||||
new(ToClientAccountId(room.CreatorPlayerId), WireCreator, WireNone, null),
|
||||
};
|
||||
foreach (var group in rows
|
||||
.Where(r => r.RoomId == room.Id && r.PlayerId != room.CreatorPlayerId)
|
||||
.GroupBy(r => r.PlayerId)
|
||||
.OrderBy(g => g.Key))
|
||||
{
|
||||
var accepted = group.Where(r => r.Accepted).ToList();
|
||||
var invited = group.Where(r => !r.Accepted).ToList();
|
||||
var role = accepted.Select(r => ToWire(r.Role)).DefaultIfEmpty(WireNone).Max();
|
||||
var invitedRole = invited.Select(r => ToWire(r.Role)).DefaultIfEmpty(WireNone).Max();
|
||||
var lastChangedBy = group
|
||||
.OrderByDescending(r => r.GrantedAt)
|
||||
.Select(r => r.GrantedByPlayerId)
|
||||
.FirstOrDefault();
|
||||
list.Add(new RoomRoleWire(
|
||||
ToClientAccountId(group.Key),
|
||||
role,
|
||||
invitedRole,
|
||||
lastChangedBy is long by ? ToClientAccountId(by) : null));
|
||||
}
|
||||
return list;
|
||||
}
|
||||
|
||||
/// <summary>One <c>EFHPLDPNGIM</c> row: <c>{AccountId: Int32, Role,
|
||||
/// InvitedRole, LastChangedByAccountId: Int32?}</c>.</summary>
|
||||
public sealed record RoomRoleWire(int AccountId, int Role, int InvitedRole, int? LastChangedByAccountId);
|
||||
|
||||
/// <summary>Account ids are Int32 in the 2023 role DTO. Clamp rather than
|
||||
/// wrap so an out-of-range id is obviously wrong.</summary>
|
||||
public static int ToClientAccountId(long accountId)
|
||||
=> accountId > int.MaxValue
|
||||
? int.MaxValue
|
||||
: accountId < int.MinValue
|
||||
? int.MinValue
|
||||
: (int)accountId;
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
namespace DorkNet.Server.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Converts between a sub-room's position in the room
|
||||
/// (<see cref="Data.Entities.RoomSceneEntity.OrderIndex"/>, 0-based, what the
|
||||
/// server stores and indexes on) and the <c>SubRoomId</c> the 2023 client
|
||||
/// sees on the wire.
|
||||
///
|
||||
/// <para><b>Why they differ.</b> The client refuses to load a sub-room whose
|
||||
/// id is not positive: <c>SubroomModel.DOKCPLJOKIE</c>
|
||||
/// (IsilDump/Assembly-CSharp/RRUI/Data/SubroomModel.txt:3) is
|
||||
/// <c>RoomId > 0 && SubroomId > 0</c>, and
|
||||
/// <c>Model`1.LNPEMMMGCPC</c> skips the load when it is false. With the
|
||||
/// first sub-room advertised as id 0, its Room Settings > Subrooms row
|
||||
/// never fetched its details (<c>SubroomModel.KBOJBJNCOGK</c> →
|
||||
/// <c>FGCPNAACHIK.OBFPEGLLELP(long?)</c>), so the row showed no name, and
|
||||
/// every per-row action (rename, max players, permissions, duplicate) ran
|
||||
/// against a null sub-room. Rec Room's own ids were large unique numbers;
|
||||
/// the client needs only "positive and stable per room".</para>
|
||||
///
|
||||
/// <para><b>Mapping.</b> Wire id = OrderIndex + 1, so a room's first
|
||||
/// sub-room is 1. Incoming ids go back through <see cref="ToOrderIndex"/>;
|
||||
/// 0 (and anything negative) is accepted as an alias of the first sub-room,
|
||||
/// which keeps cached client state, old presence rows and bare
|
||||
/// <c>matchmake/room/{id}</c> joins working.</para>
|
||||
///
|
||||
/// <para><b>Where the conversion happens.</b> Only at the wire: the room
|
||||
/// details <c>SubRooms[]</c>, the sub-room routes
|
||||
/// (<c>rooms/{id}/subrooms/{sub}/*</c>), <c>matchmake/room/{id}/{sub}</c>,
|
||||
/// the room-instance JSON (<c>RoomInstanceDto.subRoomId</c>), the instance
|
||||
/// browser, sub-room save DTOs and permission rows. Everything stored
|
||||
/// (RoomScenes, PrivateInstances, RoomDataBlobs, presence) keeps the
|
||||
/// OrderIndex.</para>
|
||||
/// </summary>
|
||||
public static class RoomSceneIds
|
||||
{
|
||||
/// <summary>OrderIndex → wire SubRoomId (always ≥ 1).</summary>
|
||||
public static long ToWire(long orderIndex) => Math.Max(0, orderIndex) + 1;
|
||||
|
||||
/// <summary>Wire SubRoomId → OrderIndex. 0 and negatives map to the first
|
||||
/// sub-room.</summary>
|
||||
public static int ToOrderIndex(long wireSubRoomId)
|
||||
=> wireSubRoomId <= 1 ? 0 : (int)Math.Min(int.MaxValue, wireSubRoomId - 1);
|
||||
}
|
||||
@@ -546,6 +546,94 @@ public class RoomService(DorkNetDbContext db)
|
||||
return existing;
|
||||
}
|
||||
|
||||
/// <summary>The name a sub-room gets when it has none: "Home" for the
|
||||
/// entry sub-room, "Subroom N" (1-based, matching its wire id) for the
|
||||
/// rest.</summary>
|
||||
public static string DefaultSubRoomName(int orderIndex)
|
||||
=> orderIndex <= 0 ? "Home" : $"Subroom {orderIndex + 1}";
|
||||
|
||||
/// <summary>A real entry sub-room row for a room that has none, built
|
||||
/// from the room's own location, data blob and capacity. Callers add it
|
||||
/// to the context.</summary>
|
||||
public static RoomSceneEntity NewHomeScene(RoomEntity room) => new()
|
||||
{
|
||||
RoomId = room.Id,
|
||||
OrderIndex = 0,
|
||||
Name = DefaultSubRoomName(0),
|
||||
RoomSceneLocationId = string.IsNullOrWhiteSpace(room.LocationReplicationId)
|
||||
? new RoomSceneEntity().RoomSceneLocationId
|
||||
: room.LocationReplicationId,
|
||||
DataBlobName = room.CurrentDataBlobName ?? string.Empty,
|
||||
MaxPlayers = room.MaxCapacity > 0 ? room.MaxCapacity : 8,
|
||||
IsSandbox = false,
|
||||
CanMatchmakeInto = true,
|
||||
DataModifiedAt = DateTime.UtcNow,
|
||||
};
|
||||
|
||||
/// <summary>Postgres only: moves the RoomScenes id sequence past the
|
||||
/// highest existing id. Rows written with explicit ids (seeds, older
|
||||
/// builds of the clone and repair code) leave it behind, and the next
|
||||
/// insert that relies on it (add / clone sub-room) then collides on the
|
||||
/// primary key. A no-op on SQLite, which assigns max + 1 itself.</summary>
|
||||
public async Task SyncRoomSceneIdSequenceAsync()
|
||||
{
|
||||
if (!db.Database.IsNpgsql()) return;
|
||||
await db.Database.ExecuteSqlRawAsync(
|
||||
"""
|
||||
SELECT setval(pg_get_serial_sequence('"RoomScenes"', 'Id'),
|
||||
GREATEST(COALESCE((SELECT MAX("Id") FROM "RoomScenes"), 0), 1),
|
||||
EXISTS (SELECT 1 FROM "RoomScenes"));
|
||||
""");
|
||||
}
|
||||
|
||||
/// <summary>Startup self-heal for sub-rooms (idempotent).
|
||||
///
|
||||
/// <para>Every non-dorm room must own at least one real RoomScenes row,
|
||||
/// and every row must have a name. A room created by cloning a template
|
||||
/// that had no scene rows (the 2023 "create room" flow clones a base room
|
||||
/// such as MakerRoom) ended up with none, so the details builder served a
|
||||
/// synthesised fallback and every sub-room mutation had to create the row
|
||||
/// on the fly; a blank Name renders as an empty row in Room Settings >
|
||||
/// Subrooms. This inserts the missing entry sub-room (OrderIndex 0, the
|
||||
/// room's own location / blob / capacity) and names unnamed rows. Dorms
|
||||
/// are left alone: their details are shaped per player and their single
|
||||
/// scene is synthesised on purpose.</para>
|
||||
///
|
||||
/// <para>It first moves the Postgres id sequence of RoomScenes past the
|
||||
/// highest id (<see cref="SyncRoomSceneIdSequenceAsync"/>), then lets the
|
||||
/// database assign ids like every other scene insert. Every service that
|
||||
/// runs the database bootstrap calls this at startup, so on Postgres the
|
||||
/// whole repair holds the bootstrap's advisory lock and replicas booting
|
||||
/// together take turns.</para>
|
||||
/// Returns (rooms given an entry scene, scenes renamed).</summary>
|
||||
public async Task<(int ScenesAdded, int ScenesRenamed)> RepairRoomScenesAsync()
|
||||
{
|
||||
await using var tx = await db.Database.BeginTransactionAsync();
|
||||
if (db.Database.IsNpgsql())
|
||||
await db.Database.ExecuteSqlRawAsync("SELECT pg_advisory_xact_lock(1146246987);");
|
||||
|
||||
// The entry sub-room is OrderIndex 0 (its blob is the room's own front
|
||||
// door), so a room missing it is repaired even when it has others.
|
||||
await SyncRoomSceneIdSequenceAsync();
|
||||
|
||||
var roomsWithoutEntry = await db.Rooms
|
||||
.Where(r => !r.IsDormRoom && !db.RoomScenes.Any(s => s.RoomId == r.Id && s.OrderIndex == 0))
|
||||
.ToListAsync();
|
||||
foreach (var room in roomsWithoutEntry)
|
||||
db.RoomScenes.Add(NewHomeScene(room));
|
||||
|
||||
var unnamed = await db.RoomScenes
|
||||
.Where(s => s.Name == null || s.Name.Trim() == string.Empty)
|
||||
.ToListAsync();
|
||||
foreach (var scene in unnamed)
|
||||
scene.Name = DefaultSubRoomName(scene.OrderIndex);
|
||||
|
||||
if (roomsWithoutEntry.Count > 0 || unnamed.Count > 0)
|
||||
await db.SaveChangesAsync();
|
||||
await tx.CommitAsync();
|
||||
return (roomsWithoutEntry.Count, unnamed.Count);
|
||||
}
|
||||
|
||||
/// <summary>Run once at startup to backfill <see cref="RoomEntity"/>
|
||||
/// + <see cref="DormStateEntity"/> rows for every account that
|
||||
/// pre-dates the auto-create-on-signup work. Idempotent — calls
|
||||
@@ -1068,6 +1156,8 @@ public class RoomService(DorkNetDbContext db)
|
||||
};
|
||||
|
||||
db.Rooms.Add(clone);
|
||||
// Every room owns a real entry sub-room (see RepairRoomScenesAsync).
|
||||
db.RoomScenes.Add(NewHomeScene(clone));
|
||||
await db.SaveChangesAsync();
|
||||
return new CloneResult(0 /* Success */, clone);
|
||||
}
|
||||
@@ -1081,6 +1171,25 @@ public class RoomService(DorkNetDbContext db)
|
||||
/// AllowsJuniors, RoomWarningMask, CustomRoomWarning
|
||||
/// Optional: IsDormRoom, SupportsVRLow, SupportsMobile, DisableMicAutoMute.
|
||||
/// </summary>
|
||||
/// <summary>The <c>IsRRO</c> the 2023 client is told: only a first-party
|
||||
/// room (baked/AG and still owned by the system account) is a Rec Room
|
||||
/// Original. A player's room is never one, even when it was cloned from an
|
||||
/// RRO template and keeps <see cref="RoomEntity.IsAGRoom"/> for the baked
|
||||
/// blob handling.
|
||||
///
|
||||
/// <para>The client gates the whole owner UI on it: RoomModel
|
||||
/// get_CanPublishRoom, CanUnpublishRoom, CanDeleteRoom,
|
||||
/// CanChangeRoomCreator, CanChangeRoomSetting, CanChangeCustomWarning,
|
||||
/// CanChangeLoadingScreen and CanCreateNewSubroom all require
|
||||
/// <c>!IsRRO</c> (IsilDump/Assembly-CSharp/RRUI/Data/RoomModel.txt:275,
|
||||
/// 429, 560, 682, 1002, 1473, 1680, 1816), so a clone advertised as RRO
|
||||
/// could never be published ("Cannot publish room") or have its settings
|
||||
/// changed. The room-load pipeline (RecRoom.RoomLoading.Runtime) never
|
||||
/// reads the flag: the scene comes from the sub-room's UnitySceneId and
|
||||
/// the blob step from whether DataBlob is set.</para></summary>
|
||||
public static bool WireIsRRO(RoomEntity r)
|
||||
=> r.IsAGRoom && r.CreatorPlayerId == PlayerService.SystemAccountId;
|
||||
|
||||
public static object ToWireRoom(RoomEntity r) => new
|
||||
{
|
||||
RoomId = r.Id,
|
||||
@@ -1093,7 +1202,7 @@ public class RoomService(DorkNetDbContext db)
|
||||
Accessibility = r.Accessibility,
|
||||
SupportsLevelVoting = r.SupportsLevelVoting,
|
||||
IsAGRoom = r.IsAGRoom,
|
||||
IsRRO = r.IsAGRoom,
|
||||
IsRRO = WireIsRRO(r),
|
||||
// 2020.12 Room.Deserialize (KLCOGEIGEBJ.PPGFHEDFBEA, the Room base)
|
||||
// reads required keys: RoomId, IsDorm, CloningAllowed, DisableMicAutoMute,
|
||||
// DisableRoomComments, EncryptVoiceChat. The 2020.03 key was IsDormRoom;
|
||||
|
||||
@@ -151,6 +151,7 @@ public static class DatabaseBootstrap
|
||||
// idempotent raw SQL that works on a fresh DB too (CREATE TABLE
|
||||
// IF NOT EXISTS no-ops when EnsureCreated already built them).
|
||||
await EnsureSignupCodeTablesAsync(db);
|
||||
await EnsureExternalSignInTablesAsync(db);
|
||||
await EnsureServerSettingsColumnsAsync(db);
|
||||
await EnsureCharadesWordListTableAsync(db);
|
||||
await EnsureInventorySchemaAsync(db);
|
||||
@@ -213,6 +214,11 @@ public static class DatabaseBootstrap
|
||||
await roomService.ApplyCanonicalOverridesAsync(http, imagesDir);
|
||||
}
|
||||
|
||||
// Every non-dorm room needs a real, named entry sub-room (see
|
||||
// RoomService.RepairRoomScenesAsync). Runs after the seed and the
|
||||
// canonical overrides so it only fills what they left missing.
|
||||
await roomService.RepairRoomScenesAsync();
|
||||
|
||||
// Seed default curated playlists, club category tags, store catalog.
|
||||
// Must run after SeedAsync so they can reference RR-Originals.
|
||||
var playlistService = scope.ServiceProvider.GetRequiredService<PlaylistService>();
|
||||
@@ -260,6 +266,66 @@ public static class DatabaseBootstrap
|
||||
/// tables on whichever provider is in use. Safe to run every boot:
|
||||
/// CREATE TABLE / INDEX IF NOT EXISTS no-op once they exist (whether
|
||||
/// created here or by a fresh-DB EnsureCreated).</summary>
|
||||
/// <summary>External sign-in: the identities linked to accounts
|
||||
/// (<c>ExternalLogins</c>) and the data-protection keys that encrypt the
|
||||
/// sign-in cookies (<c>DataProtectionKeys</c>). Both post-date the Initial
|
||||
/// migration, so like the signup-code tables they're created here for
|
||||
/// either provider (a no-op once EnsureCreated or an earlier boot made them).</summary>
|
||||
private static async Task EnsureExternalSignInTablesAsync(DorkNetDbContext db)
|
||||
{
|
||||
var pg = db.Database.IsNpgsql();
|
||||
var statements = pg
|
||||
? new[]
|
||||
{
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS "ExternalLogins" (
|
||||
"Id" bigint GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
|
||||
"PlayerId" bigint NOT NULL,
|
||||
"Issuer" text NOT NULL,
|
||||
"Subject" text NOT NULL,
|
||||
"Email" text NOT NULL DEFAULT '',
|
||||
"CreatedAt" timestamp with time zone NOT NULL DEFAULT now(),
|
||||
"LastUsedAt" timestamp with time zone NOT NULL DEFAULT now()
|
||||
);
|
||||
""",
|
||||
@"CREATE UNIQUE INDEX IF NOT EXISTS ""IX_ExternalLogins_Issuer_Subject"" ON ""ExternalLogins"" (""Issuer"", ""Subject"");",
|
||||
@"CREATE INDEX IF NOT EXISTS ""IX_ExternalLogins_PlayerId"" ON ""ExternalLogins"" (""PlayerId"");",
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS "DataProtectionKeys" (
|
||||
"Id" integer GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
|
||||
"FriendlyName" text NULL,
|
||||
"Xml" text NULL
|
||||
);
|
||||
""",
|
||||
}
|
||||
: new[]
|
||||
{
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS "ExternalLogins" (
|
||||
"Id" INTEGER NOT NULL CONSTRAINT "PK_ExternalLogins" PRIMARY KEY AUTOINCREMENT,
|
||||
"PlayerId" INTEGER NOT NULL,
|
||||
"Issuer" TEXT NOT NULL,
|
||||
"Subject" TEXT NOT NULL,
|
||||
"Email" TEXT NOT NULL DEFAULT '',
|
||||
"CreatedAt" TEXT NOT NULL,
|
||||
"LastUsedAt" TEXT NOT NULL
|
||||
);
|
||||
""",
|
||||
@"CREATE UNIQUE INDEX IF NOT EXISTS ""IX_ExternalLogins_Issuer_Subject"" ON ""ExternalLogins"" (""Issuer"", ""Subject"");",
|
||||
@"CREATE INDEX IF NOT EXISTS ""IX_ExternalLogins_PlayerId"" ON ""ExternalLogins"" (""PlayerId"");",
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS "DataProtectionKeys" (
|
||||
"Id" INTEGER NOT NULL CONSTRAINT "PK_DataProtectionKeys" PRIMARY KEY AUTOINCREMENT,
|
||||
"FriendlyName" TEXT NULL,
|
||||
"Xml" TEXT NULL
|
||||
);
|
||||
""",
|
||||
};
|
||||
|
||||
foreach (var sql in statements)
|
||||
await db.Database.ExecuteSqlRawAsync(sql);
|
||||
}
|
||||
|
||||
private static async Task EnsureSignupCodeTablesAsync(DorkNetDbContext db)
|
||||
{
|
||||
var pg = db.Database.IsNpgsql();
|
||||
@@ -948,6 +1014,23 @@ public static class DatabaseBootstrap
|
||||
);
|
||||
""",
|
||||
@"CREATE UNIQUE INDEX IF NOT EXISTS ""IX_ClubAdditionalImages_ClubId_Slot"" ON ""ClubAdditionalImages"" (""ClubId"", ""Slot"");",
|
||||
// 2023 sub-room role-permission matrix
|
||||
// (PUT rooms/{id}/subrooms/{sub}/permissions → photon_access_token).
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS "RoomRolePermissions" (
|
||||
"Id" bigint GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
|
||||
"RoomId" bigint NOT NULL,
|
||||
"SubRoomId" bigint NOT NULL,
|
||||
"Role" integer NOT NULL,
|
||||
"Permission" character varying(128) NOT NULL DEFAULT '',
|
||||
"Type" integer NOT NULL DEFAULT 0,
|
||||
"Override" boolean NOT NULL DEFAULT false,
|
||||
"Value" character varying(4096) NOT NULL DEFAULT '',
|
||||
"UpdatedByPlayerId" bigint NOT NULL DEFAULT 0,
|
||||
"UpdatedAt" timestamp with time zone NOT NULL DEFAULT now()
|
||||
);
|
||||
""",
|
||||
@"CREATE UNIQUE INDEX IF NOT EXISTS ""IX_RoomRolePermissions_RoomId_SubRoomId_Role_Permission"" ON ""RoomRolePermissions"" (""RoomId"", ""SubRoomId"", ""Role"", ""Permission"");",
|
||||
};
|
||||
foreach (var sql in statements)
|
||||
await db.Database.ExecuteSqlRawAsync(sql);
|
||||
@@ -1025,6 +1108,21 @@ public static class DatabaseBootstrap
|
||||
);
|
||||
""",
|
||||
@"CREATE UNIQUE INDEX IF NOT EXISTS ""IX_ClubAdditionalImages_ClubId_Slot"" ON ""ClubAdditionalImages"" (""ClubId"", ""Slot"");",
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS "RoomRolePermissions" (
|
||||
"Id" INTEGER NOT NULL CONSTRAINT "PK_RoomRolePermissions" PRIMARY KEY AUTOINCREMENT,
|
||||
"RoomId" INTEGER NOT NULL,
|
||||
"SubRoomId" INTEGER NOT NULL,
|
||||
"Role" INTEGER NOT NULL,
|
||||
"Permission" TEXT NOT NULL DEFAULT '',
|
||||
"Type" INTEGER NOT NULL DEFAULT 0,
|
||||
"Override" INTEGER NOT NULL DEFAULT 0,
|
||||
"Value" TEXT NOT NULL DEFAULT '',
|
||||
"UpdatedByPlayerId" INTEGER NOT NULL DEFAULT 0,
|
||||
"UpdatedAt" TEXT NOT NULL DEFAULT ''
|
||||
);
|
||||
""",
|
||||
@"CREATE UNIQUE INDEX IF NOT EXISTS ""IX_RoomRolePermissions_RoomId_SubRoomId_Role_Permission"" ON ""RoomRolePermissions"" (""RoomId"", ""SubRoomId"", ""Role"", ""Permission"");",
|
||||
};
|
||||
// CREATE ... IF NOT EXISTS is already idempotent; a failure here is a
|
||||
// real problem and must fail startup.
|
||||
|
||||
@@ -1,4 +1,9 @@
|
||||
using Microsoft.AspNetCore.Authentication;
|
||||
using Microsoft.AspNetCore.Authentication.Cookies;
|
||||
using Microsoft.AspNetCore.Authentication.JwtBearer;
|
||||
using Microsoft.AspNetCore.Authentication.OpenIdConnect;
|
||||
using Microsoft.AspNetCore.DataProtection;
|
||||
using Microsoft.AspNetCore.DataProtection.KeyManagement;
|
||||
using Microsoft.Data.Sqlite;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.IdentityModel.Tokens;
|
||||
@@ -29,6 +34,7 @@ public static class ServiceCollectionExtensions
|
||||
AddVersionCompatibility(builder);
|
||||
var redisConn = AddRedis(builder);
|
||||
AddAuthentication(builder);
|
||||
AddExternalSignIn(builder);
|
||||
AddKestrelAndSignalR(builder, redisConn);
|
||||
}
|
||||
|
||||
@@ -340,9 +346,7 @@ public static class ServiceCollectionExtensions
|
||||
// older Coolify configs.
|
||||
private static void AddAuthentication(WebApplicationBuilder builder)
|
||||
{
|
||||
var jwtSecret = Environment.GetEnvironmentVariable("DORKNET_JWT_SECRET")
|
||||
?? Environment.GetEnvironmentVariable("RECNET_JWT_SECRET")
|
||||
?? builder.Configuration["Jwt:Secret"]
|
||||
var jwtSecret = AuthService.ConfiguredSecret(builder.Configuration)
|
||||
?? throw new InvalidOperationException(
|
||||
"JWT secret not configured. Set the DORKNET_JWT_SECRET env var or " +
|
||||
"add `Jwt:Secret` to appsettings.Local.json (the install-plugin.ps1 " +
|
||||
@@ -397,6 +401,119 @@ public static class ServiceCollectionExtensions
|
||||
opt.JsonSerializerOptions.PropertyNamingPolicy = null);
|
||||
}
|
||||
|
||||
// ── External sign-in (OpenID Connect) ─────────────────────────────────────
|
||||
// Players sign in to the website (and admins to the admin panel) with an
|
||||
// OpenID Connect provider, configured under DorkNet:SignIn. The game
|
||||
// client's own token flow is untouched. Two cookie schemes, both per host
|
||||
// (the hyphen host style puts the admin panel outside the apex's cookie
|
||||
// domain): DorkNet.Web is the signed-in player, DorkNet.External a
|
||||
// provider identity waiting to be linked to an account.
|
||||
private static void AddExternalSignIn(WebApplicationBuilder builder)
|
||||
{
|
||||
var services = builder.Services;
|
||||
var section = builder.Configuration.GetSection("DorkNet:SignIn");
|
||||
services.Configure<ExternalSignInOptions>(section);
|
||||
var signIn = section.Get<ExternalSignInOptions>() ?? new ExternalSignInOptions();
|
||||
var https = !string.Equals(builder.Configuration["Domain:Scheme"] ?? "https", "http", StringComparison.OrdinalIgnoreCase);
|
||||
var secure = https ? CookieSecurePolicy.Always : CookieSecurePolicy.SameAsRequest;
|
||||
|
||||
services.AddScoped<ExternalSignInService>();
|
||||
services.AddSingleton<LinkAttemptLimiter>();
|
||||
|
||||
// Cookie encryption keys live in the database, so a redeploy or a
|
||||
// second replica doesn't invalidate everyone's session. They're
|
||||
// stored encrypted with a key derived from the server secret, which
|
||||
// lives outside the database, so a database backup alone can't forge
|
||||
// sessions.
|
||||
services.AddSingleton(new DataProtectionKeyEncryption(
|
||||
AuthService.ConfiguredSecret(builder.Configuration) ?? string.Empty));
|
||||
services.AddDataProtection()
|
||||
.SetApplicationName("DorkNet")
|
||||
.PersistKeysToDbContext<DorkNetDbContext>();
|
||||
services.AddOptions<KeyManagementOptions>()
|
||||
.Configure<DataProtectionKeyEncryption>((o, enc) => o.XmlEncryptor = enc);
|
||||
|
||||
var auth = services.AddAuthentication()
|
||||
.AddCookie(ExternalSignIn.WebScheme, o =>
|
||||
{
|
||||
o.Cookie.Name = "dorknet_session";
|
||||
o.Cookie.HttpOnly = true;
|
||||
o.Cookie.SameSite = SameSiteMode.Lax;
|
||||
o.Cookie.SecurePolicy = secure;
|
||||
o.ExpireTimeSpan = TimeSpan.FromDays(30);
|
||||
o.SlidingExpiration = true;
|
||||
// API endpoints answer 401/403 instead of redirecting.
|
||||
o.Events.OnRedirectToLogin = ctx => { ctx.Response.StatusCode = StatusCodes.Status401Unauthorized; return Task.CompletedTask; };
|
||||
o.Events.OnRedirectToAccessDenied = ctx => { ctx.Response.StatusCode = StatusCodes.Status403Forbidden; return Task.CompletedTask; };
|
||||
// An unlinked identity, a deleted or banned player ends the session.
|
||||
o.Events.OnValidatePrincipal = async ctx =>
|
||||
{
|
||||
var issuer = ctx.Principal?.FindFirst(ExternalSignIn.IssuerClaim)?.Value;
|
||||
var subject = ctx.Principal?.FindFirst(ExternalSignIn.SubjectClaim)?.Value;
|
||||
var svc = ctx.HttpContext.RequestServices.GetRequiredService<ExternalSignInService>();
|
||||
var link = issuer is null || subject is null ? null : await svc.FindAsync(issuer, subject);
|
||||
if (link is null || link.PlayerId != ExternalSignIn.PlayerId(ctx.Principal)
|
||||
|| await svc.ActivePlayerAsync(link) is null)
|
||||
{
|
||||
ctx.RejectPrincipal();
|
||||
await ctx.HttpContext.SignOutAsync(ExternalSignIn.WebScheme);
|
||||
}
|
||||
};
|
||||
})
|
||||
.AddCookie(ExternalSignIn.ExternalScheme, o =>
|
||||
{
|
||||
o.Cookie.Name = "dorknet_external";
|
||||
o.Cookie.HttpOnly = true;
|
||||
o.Cookie.SameSite = SameSiteMode.Lax;
|
||||
o.Cookie.SecurePolicy = secure;
|
||||
o.ExpireTimeSpan = TimeSpan.FromMinutes(15);
|
||||
o.Events.OnRedirectToLogin = ctx => { ctx.Response.StatusCode = StatusCodes.Status401Unauthorized; return Task.CompletedTask; };
|
||||
});
|
||||
|
||||
if (!signIn.IsConfigured) return;
|
||||
|
||||
auth.AddOpenIdConnect(ExternalSignIn.OidcScheme, o =>
|
||||
{
|
||||
o.Authority = signIn.Authority;
|
||||
o.ClientId = signIn.ClientId;
|
||||
o.ClientSecret = signIn.ClientSecret;
|
||||
o.ResponseType = "code";
|
||||
o.UsePkce = true;
|
||||
o.Scope.Clear();
|
||||
foreach (var scope in signIn.Scopes.Split(' ', StringSplitOptions.RemoveEmptyEntries))
|
||||
o.Scope.Add(scope);
|
||||
o.SignInScheme = ExternalSignIn.ExternalScheme;
|
||||
o.CallbackPath = ExternalSignIn.CallbackPath;
|
||||
o.GetClaimsFromUserInfoEndpoint = signIn.UseUserInfoEndpoint;
|
||||
o.MapInboundClaims = false;
|
||||
o.SaveTokens = false;
|
||||
o.CorrelationCookie.SecurePolicy = secure;
|
||||
o.NonceCookie.SecurePolicy = secure;
|
||||
o.Events.OnRedirectToIdentityProvider = ctx =>
|
||||
{
|
||||
// Behind TLS-terminating proxies the request arrives as http;
|
||||
// the provider must redirect back to the public URL of the
|
||||
// host the sign-in started on (website or admin panel).
|
||||
var domain = ctx.HttpContext.RequestServices.GetRequiredService<DomainConfig>();
|
||||
ctx.ProtocolMessage.RedirectUri = $"{domain.Scheme}://{ctx.Request.Host}{ExternalSignIn.CallbackPath}";
|
||||
return Task.CompletedTask;
|
||||
};
|
||||
o.Events.OnRemoteFailure = ctx =>
|
||||
{
|
||||
// e.g. the player cancelled at the provider: back to the login
|
||||
// page (admin panel) or the site with an error instead of an
|
||||
// exception page. The message is logged, not shown.
|
||||
var domain = ctx.HttpContext.RequestServices.GetRequiredService<DomainConfig>();
|
||||
var admin = string.Equals(ctx.Request.Host.Host, domain.Sub("admin"), StringComparison.OrdinalIgnoreCase);
|
||||
ctx.HttpContext.RequestServices.GetRequiredService<ILoggerFactory>()
|
||||
.CreateLogger("DorkNet.SignIn").LogWarning(ctx.Failure, "[signin] provider sign-in failed");
|
||||
ctx.Response.Redirect((admin ? "/login" : "/") + "?signin_error=provider_failed");
|
||||
ctx.HandleResponse();
|
||||
return Task.CompletedTask;
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
// ── Kestrel + SignalR ────────────────────────────────────────────────────
|
||||
// Kestrel-level limits: defaults trim long-lived sockets earlier than our
|
||||
// SignalR ping interval allows.
|
||||
|
||||
@@ -49,21 +49,28 @@ export function assetCdnUrl(path: string | null | undefined): string | null {
|
||||
return subdomainUrl('cdn', path, undefined, true);
|
||||
}
|
||||
|
||||
export function adminApex(): string {
|
||||
if (typeof window === 'undefined') return 'rec.net';
|
||||
// The server mounts this SPA on its own `admin` subdomain, so the admin
|
||||
// host tells us the deployment's apex and subdomain style: `admin.<apex>`
|
||||
// (dot style, `img.<apex>`) or `admin-<apex>` (hyphen style, `img-<apex>`).
|
||||
function adminHost(): { apex: string; hyphen: boolean } {
|
||||
if (typeof window === 'undefined') return { apex: 'rec.net', hyphen: false };
|
||||
|
||||
const host = window.location.hostname.toLowerCase();
|
||||
if (host === 'localhost' || host.endsWith('.localhost')) return 'localhost';
|
||||
if (host.endsWith('-dev.example.com')) return 'dev.example.com';
|
||||
if (host === 'localhost' || host.endsWith('.localhost')) return { apex: 'localhost', hyphen: false };
|
||||
|
||||
const parts = host.split('.');
|
||||
return parts.length > 2 ? parts.slice(1).join('.') : host;
|
||||
const [first, ...rest] = host.split('.');
|
||||
if (first.startsWith('admin-') && rest.length > 0)
|
||||
return { apex: [first.slice('admin-'.length), ...rest].join('.'), hyphen: true };
|
||||
return { apex: rest.length >= 2 ? rest.join('.') : host, hyphen: false };
|
||||
}
|
||||
|
||||
function serviceHost(subdomain: string, apex: string): string {
|
||||
return apex === 'dev.example.com'
|
||||
? `${subdomain}-${apex}`
|
||||
: `${subdomain}.${apex}`;
|
||||
export function adminApex(): string {
|
||||
return adminHost().apex;
|
||||
}
|
||||
|
||||
function serviceHost(subdomain: string): string {
|
||||
const { apex, hyphen } = adminHost();
|
||||
return hyphen ? `${subdomain}-${apex}` : `${subdomain}.${apex}`;
|
||||
}
|
||||
|
||||
function subdomainUrl(subdomain: string, path: string | null | undefined, query?: string, preserveSlashes = false): string | null {
|
||||
@@ -74,7 +81,7 @@ function subdomainUrl(subdomain: string, path: string | null | undefined, query?
|
||||
const encodedPath = preserveSlashes
|
||||
? path.split('/').map(encodeURIComponent).join('/')
|
||||
: encodeURIComponent(path);
|
||||
return `${scheme}://${serviceHost(subdomain, apex)}/${encodedPath}${suffix}`;
|
||||
return `${scheme}://${serviceHost(subdomain)}/${encodedPath}${suffix}`;
|
||||
}
|
||||
|
||||
export interface Room {
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { useState } from 'react';
|
||||
import { useNavigate, useLocation } from 'react-router';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { useNavigate, useLocation, useSearchParams } from 'react-router';
|
||||
import { api, ApiError } from '../lib/api';
|
||||
import { notifyAuthChange, setSession } from '../lib/auth';
|
||||
import { adminApex } from '../lib/types';
|
||||
import { BrandMark } from '../components/BrandMark';
|
||||
|
||||
interface LoginResponse {
|
||||
@@ -12,14 +13,82 @@ interface LoginResponse {
|
||||
display_name: string;
|
||||
}
|
||||
|
||||
// The website, where an identity from the sign-in provider is linked to an
|
||||
// account (the admin panel only signs in identities that are linked already).
|
||||
function siteUrl(path: string): string {
|
||||
const apex = adminApex();
|
||||
return `${apex === 'localhost' ? 'http' : 'https'}://${apex}${path}`;
|
||||
}
|
||||
|
||||
const SSO_ERRORS: Record<string, React.ReactNode> = {
|
||||
not_admin: 'That account is not an admin.',
|
||||
not_signed_in: "Single sign-on didn't finish. Try again.",
|
||||
account_unavailable: 'The account linked to that sign-in is unavailable (deleted or banned).',
|
||||
no_identity: "The sign-in provider didn't return an identity. Try again.",
|
||||
provider_failed: 'The sign-in was cancelled or failed at the provider. Try again.',
|
||||
};
|
||||
|
||||
export function Login() {
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const [params, setParams] = useSearchParams();
|
||||
const from = (location.state as { from?: { pathname: string } } | null)?.from?.pathname ?? '/';
|
||||
const [username, setUsername] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [err, setErr] = useState<string | null>(null);
|
||||
const [err, setErr] = useState<React.ReactNode | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
// Single sign-on through the server's OpenID Connect provider, if set up.
|
||||
const [sso, setSso] = useState<{ provider: string } | null>(null);
|
||||
const ssoHandled = useRef(false);
|
||||
|
||||
const finish = (res: LoginResponse, to: string) => {
|
||||
setSession(res.access_token, {
|
||||
id: res.account_id,
|
||||
username: res.username,
|
||||
displayName: res.display_name,
|
||||
});
|
||||
notifyAuthChange();
|
||||
navigate(to, { replace: true });
|
||||
};
|
||||
|
||||
const errorText = (e: unknown, codes: Record<string, React.ReactNode>): React.ReactNode => {
|
||||
if (e instanceof ApiError && e.body && typeof e.body === 'object' && 'error' in e.body) {
|
||||
const code = (e.body as { error: string }).error;
|
||||
return Object.hasOwn(codes, code) ? codes[code] : e.message;
|
||||
}
|
||||
return e instanceof Error ? e.message : String(e);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetch('/api/site/v1/account/me', { cache: 'no-store' })
|
||||
.then((r) => (r.ok ? r.json() : null))
|
||||
.then((me) => { if (me?.signInAvailable) setSso({ provider: me.provider }); })
|
||||
.catch(() => { /* no single sign-on: password only */ });
|
||||
}, []);
|
||||
|
||||
// Back from the provider: ?sso=1 swaps the session for the admin token;
|
||||
// ?signin_error=… explains why it didn't work.
|
||||
useEffect(() => {
|
||||
if (ssoHandled.current) return;
|
||||
const signinError = params.get('signin_error');
|
||||
if (signinError) {
|
||||
ssoHandled.current = true;
|
||||
setErr(signinError === 'not_linked'
|
||||
? <>This sign-in isn't linked to a DorkNet account yet. Link it on the <a className="underline" href={siteUrl('/api/auth/external/login?returnUrl=%2Faccount')}>website</a> first, then sign in here again.</>
|
||||
// Only known codes are shown: the query string is attacker-controllable.
|
||||
: Object.hasOwn(SSO_ERRORS, signinError) ? SSO_ERRORS[signinError] : 'Single sign-on failed. Try again.');
|
||||
setParams({}, { replace: true });
|
||||
return;
|
||||
}
|
||||
if (params.get('sso') !== '1') return;
|
||||
ssoHandled.current = true;
|
||||
setLoading(true);
|
||||
api<LoginResponse>('/sso')
|
||||
.then((res) => finish(res, params.get('to') || '/'))
|
||||
.catch((e) => { setErr(errorText(e, SSO_ERRORS)); setParams({}, { replace: true }); })
|
||||
.finally(() => setLoading(false));
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
const onSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
@@ -30,27 +99,19 @@ export function Login() {
|
||||
method: 'POST',
|
||||
body: { username, password },
|
||||
});
|
||||
setSession(res.access_token, {
|
||||
id: res.account_id,
|
||||
username: res.username,
|
||||
displayName: res.display_name,
|
||||
});
|
||||
notifyAuthChange();
|
||||
navigate(from, { replace: true });
|
||||
finish(res, from);
|
||||
} catch (e) {
|
||||
if (e instanceof ApiError) {
|
||||
if (e.body && typeof e.body === 'object' && 'error' in e.body) {
|
||||
const code = (e.body as { error: string }).error;
|
||||
if (code === 'not_admin') setErr('That account is not an admin.');
|
||||
else if (code === 'invalid_credentials') setErr('Invalid username or password.');
|
||||
else setErr(e.message);
|
||||
} else setErr(e.message);
|
||||
} else setErr(String(e));
|
||||
setErr(errorText(e, {
|
||||
not_admin: 'That account is not an admin.',
|
||||
invalid_credentials: 'Invalid username or password.',
|
||||
}));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const ssoHref = `/api/auth/external/login?returnUrl=${encodeURIComponent(`/login?sso=1&to=${encodeURIComponent(from)}`)}`;
|
||||
|
||||
return (
|
||||
<div className="min-h-full flex items-center justify-center px-4 py-12 bg-[linear-gradient(135deg,#0a0b0e_0%,#0d1d25_48%,#131418_100%)]">
|
||||
<div className="w-full max-w-sm">
|
||||
@@ -92,6 +153,14 @@ export function Login() {
|
||||
<button type="submit" disabled={loading} className="btn-primary mt-1">
|
||||
{loading ? 'Signing in…' : 'Sign in'}
|
||||
</button>
|
||||
{sso && (
|
||||
<>
|
||||
<div className="flex items-center gap-3 text-[11px] uppercase tracking-wide text-ink-500">
|
||||
<span className="h-px flex-1 bg-ink-800" />or<span className="h-px flex-1 bg-ink-800" />
|
||||
</div>
|
||||
<a href={ssoHref} className="btn-secondary text-center">Sign in with {sso.provider}</a>
|
||||
</>
|
||||
)}
|
||||
</form>
|
||||
|
||||
<p className="mt-6 text-center text-[11px] text-ink-500">DorkNet private server · admin console</p>
|
||||
|
||||
@@ -9,6 +9,8 @@ import { PlayerProfile } from './pages/PlayerProfile';
|
||||
import { Rooms } from './pages/Rooms';
|
||||
import { Join } from './pages/Join';
|
||||
import { Downloads } from './pages/Downloads';
|
||||
import { Link as LinkAccount } from './pages/Link';
|
||||
import { Account } from './pages/Account';
|
||||
import { NotFound } from './pages/NotFound';
|
||||
|
||||
// The Quest installer pulls in the WebUSB/ADB stack; load it only on /quest.
|
||||
@@ -29,6 +31,8 @@ export function App() {
|
||||
<Route path="/join" element={<Join />} />
|
||||
<Route path="/quest" element={<Suspense fallback={null}><Quest /></Suspense>} />
|
||||
<Route path="/downloads" element={<Downloads />} />
|
||||
<Route path="/link" element={<LinkAccount />} />
|
||||
<Route path="/account" element={<Account />} />
|
||||
{/* Legacy paths from the vanilla-JS feed.localhost site */}
|
||||
<Route path="/p/:id" element={<RedirectPhoto />} />
|
||||
<Route path="/u/:id" element={<RedirectPlayer />} />
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { NavLink, Link, useLocation, useNavigate } from 'react-router';
|
||||
import { NavLink, Link, useLocation, useNavigate, useSearchParams } from 'react-router';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { BrandMark } from './BrandMark';
|
||||
import { getDownloads } from '../lib/downloads';
|
||||
import { getDownloads, SignInRequiredError } from '../lib/downloads';
|
||||
import { errorCopy, getMe, signInUrl, signOut, SIGNIN_ERRORS, type AccountMe } from '../lib/account';
|
||||
|
||||
const NAV = [
|
||||
{ to: '/', label: 'Home', exact: true },
|
||||
@@ -23,10 +24,37 @@ export function Header() {
|
||||
const [hasDownloads, setHasDownloads] = useState(false);
|
||||
const navItems = hasDownloads ? [...NAV, DOWNLOADS] : NAV;
|
||||
|
||||
const [me, setMe] = useState<AccountMe | null>(null);
|
||||
const [params, setParams] = useSearchParams();
|
||||
const signinError = params.get('signin_error');
|
||||
|
||||
useEffect(() => {
|
||||
getDownloads().then((m) => setHasDownloads(m !== null)).catch(() => {});
|
||||
getDownloads().then((m) => setHasDownloads(m !== null))
|
||||
.catch((e) => setHasDownloads(e instanceof SignInRequiredError));
|
||||
getMe().then(setMe).catch(() => { /* site works without sign-in */ });
|
||||
}, []);
|
||||
|
||||
const [signOutError, setSignOutError] = useState<string | null>(null);
|
||||
const doSignOut = async () => {
|
||||
setSignOutError(null);
|
||||
try {
|
||||
await signOut();
|
||||
window.location.assign('/');
|
||||
} catch (e) {
|
||||
setSignOutError((e as Error).message);
|
||||
}
|
||||
};
|
||||
|
||||
// Sign in / account, shown only when the server has a sign-in provider.
|
||||
const account = !me?.signInAvailable ? null : me.signedIn && me.player ? (
|
||||
<span className="flex items-center gap-2 text-sm">
|
||||
<Link to="/account" className="text-ink-200 hover:text-ink-50">{me.player.username}</Link>
|
||||
<button type="button" onClick={doSignOut} className="btn-ghost !px-2 !py-1 text-xs">Sign out</button>
|
||||
</span>
|
||||
) : (
|
||||
<a href={signInUrl()} className="btn-secondary !px-3 !py-1.5 text-sm">Sign in</a>
|
||||
);
|
||||
|
||||
// Close the mobile drawer on route change so tapping a nav item
|
||||
// doesn't leave a half-open overlay covering the page.
|
||||
useEffect(() => { setOpen(false); }, [loc.pathname]);
|
||||
@@ -72,6 +100,8 @@ export function Header() {
|
||||
/>
|
||||
</form>
|
||||
|
||||
<div className="hidden md:flex shrink-0">{account}</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen(o => !o)}
|
||||
@@ -107,6 +137,25 @@ export function Header() {
|
||||
</NavLink>
|
||||
))}
|
||||
</nav>
|
||||
{account && <div className="pt-1">{account}</div>}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{signOutError && (
|
||||
<div className="border-t border-danger/30 bg-danger/10">
|
||||
<div className="mx-auto max-w-6xl px-4 sm:px-6 py-2 flex items-center justify-between gap-3 text-sm text-danger">
|
||||
<span>Couldn't sign out ({signOutError}). You're still signed in; try again.</span>
|
||||
<button type="button" className="btn-ghost !px-2 !py-1 text-xs" onClick={() => setSignOutError(null)}>Dismiss</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{signinError && (
|
||||
<div className="border-t border-danger/30 bg-danger/10">
|
||||
<div className="mx-auto max-w-6xl px-4 sm:px-6 py-2 flex items-center justify-between gap-3 text-sm text-danger">
|
||||
<span>Sign-in didn't work: {errorCopy(SIGNIN_ERRORS, signinError, 'something went wrong. Try again.')}</span>
|
||||
<button type="button" className="btn-ghost !px-2 !py-1 text-xs" onClick={() => { params.delete('signin_error'); setParams(params, { replace: true }); }}>Dismiss</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
// Website sign-in through the server's OpenID Connect provider
|
||||
// (ExternalSignInController). The session is an HttpOnly cookie on this host,
|
||||
// so there is no token to keep here: same-origin fetches carry it.
|
||||
import { get, post } from './api';
|
||||
|
||||
export interface AccountMe {
|
||||
signInAvailable: boolean;
|
||||
provider: string;
|
||||
signedIn: boolean;
|
||||
player: { id: string; username: string; email: string } | null;
|
||||
pendingLink: { email: string } | null;
|
||||
}
|
||||
|
||||
let cached: Promise<AccountMe> | null = null;
|
||||
|
||||
/** Who is signed in. Fetched once per page load; `fresh` re-reads it. */
|
||||
export function getMe(fresh = false): Promise<AccountMe> {
|
||||
if (!cached || fresh) cached = get<AccountMe>('/account/me').catch((e) => { cached = null; throw e; });
|
||||
return cached;
|
||||
}
|
||||
|
||||
/** `url` if it is a path on this site, else `/` (the server applies the same
|
||||
* rule): never navigate to an address taken from the query string otherwise. */
|
||||
export function localPath(url: string | null | undefined): string {
|
||||
if (!url || !url.startsWith('/')) return '/';
|
||||
// Parse it the way the browser will (it drops tabs and newlines and reads
|
||||
// \ as /) and keep it only if it stays on this origin.
|
||||
try {
|
||||
const u = new URL(url, window.location.origin);
|
||||
return u.origin === window.location.origin ? u.pathname + u.search + u.hash : '/';
|
||||
} catch {
|
||||
return '/';
|
||||
}
|
||||
}
|
||||
|
||||
/** Starts sign-in at the provider; comes back to `returnUrl` on this site. */
|
||||
export function signInUrl(returnUrl = window.location.pathname + window.location.search): string {
|
||||
return `/api/auth/external/login?returnUrl=${encodeURIComponent(returnUrl)}`;
|
||||
}
|
||||
|
||||
// Each of these changes who is signed in, so the cached answer is dropped.
|
||||
async function changesAccount<T>(request: Promise<T>): Promise<T> {
|
||||
const result = await request;
|
||||
cached = null;
|
||||
return result;
|
||||
}
|
||||
|
||||
export const link = (username: string, password: string) =>
|
||||
changesAccount(post<{ linked: boolean; username: string }>('/account/link', { username, password }));
|
||||
export const signOut = () => changesAccount(post('/account/logout', {}));
|
||||
export const unlink = () => changesAccount(post('/account/unlink', {}));
|
||||
|
||||
// Server error code -> friendly copy.
|
||||
export const LINK_ERRORS: Record<string, string> = {
|
||||
invalid_credentials: "That username and password don't match a DorkNet account.",
|
||||
missing_credentials: 'Enter your DorkNet username and password.',
|
||||
too_many_attempts: 'Too many wrong attempts. Wait 15 minutes and try again.',
|
||||
banned: 'That account is banned.',
|
||||
identity_linked_to_another_account: 'This sign-in is already linked to a different DorkNet account.',
|
||||
no_pending_identity: 'Your sign-in expired. Start again with "Sign in".',
|
||||
};
|
||||
|
||||
/** Friendly copy for a known error code, else `fallback`. For codes read from
|
||||
* the query string, pass a fixed fallback: anyone can put text in a link. */
|
||||
export function errorCopy(map: Record<string, string>, code: string | null | undefined, fallback: string): string {
|
||||
return code && Object.hasOwn(map, code) ? map[code] : fallback;
|
||||
}
|
||||
|
||||
export const SIGNIN_ERRORS: Record<string, string> = {
|
||||
account_unavailable: 'The account linked to this sign-in is unavailable (deleted or banned).',
|
||||
no_identity: "The sign-in provider didn't return an identity. Try again.",
|
||||
provider_failed: 'The sign-in was cancelled or failed at the provider. Try again.',
|
||||
};
|
||||
@@ -1,7 +1,7 @@
|
||||
// Lightweight fetch wrapper for the public-facing site. Talks to the
|
||||
// apex-hosted PublicSiteController at /api/site/v1/*. Anonymous-only —
|
||||
// the public site has no login flow, so there's no Authorization
|
||||
// header or token plumbing.
|
||||
// apex-hosted PublicSiteController at /api/site/v1/*. No Authorization
|
||||
// header or token plumbing: the optional sign-in (lib/account.ts) is an
|
||||
// HttpOnly cookie that same-origin fetches send on their own.
|
||||
|
||||
const BASE = '/api/site/v1';
|
||||
|
||||
|
||||
@@ -28,6 +28,12 @@ export interface DownloadsManifest {
|
||||
updated?: string;
|
||||
}
|
||||
|
||||
/** The server only serves downloads to signed-in players
|
||||
* (DorkNet:Downloads:RequireSignIn) and nobody is signed in. */
|
||||
export class SignInRequiredError extends Error {
|
||||
constructor() { super('sign_in_required'); this.name = 'SignInRequiredError'; }
|
||||
}
|
||||
|
||||
let cached: Promise<DownloadsManifest | null> | null = null;
|
||||
|
||||
/** The manifest, or null when this server doesn't offer downloads. Fetched
|
||||
@@ -36,6 +42,7 @@ export function getDownloads(fresh = false): Promise<DownloadsManifest | null> {
|
||||
if (!cached || fresh) {
|
||||
cached = get<DownloadsManifest>('/downloads').catch((e) => {
|
||||
if (e instanceof ApiError && e.status === 404) return null;
|
||||
if (e instanceof ApiError && e.status === 401) { cached = null; throw new SignInRequiredError(); }
|
||||
cached = null;
|
||||
throw e;
|
||||
});
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Link as RouterLink } from 'react-router';
|
||||
import { getMe, signInUrl, signOut, unlink, type AccountMe } from '../lib/account';
|
||||
|
||||
// The signed-in account: which DorkNet account and provider email are
|
||||
// linked, plus sign out and unlink.
|
||||
export function Account() {
|
||||
const [me, setMe] = useState<AccountMe | null>(null);
|
||||
const [err, setErr] = useState<string | null>(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
getMe(true).then(setMe).catch((e) => setErr((e as Error).message));
|
||||
}, []);
|
||||
|
||||
const run = async (action: () => Promise<unknown>) => {
|
||||
setBusy(true);
|
||||
try {
|
||||
await action();
|
||||
window.location.assign('/');
|
||||
} catch (e) {
|
||||
setErr((e as Error).message);
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (err) return <div className="max-w-lg mx-auto rounded-lg border border-danger/30 bg-danger/5 px-4 py-3 text-sm text-danger">{err}</div>;
|
||||
if (!me) return <p className="max-w-lg mx-auto text-sm text-ink-400">Loading…</p>;
|
||||
|
||||
if (!me.signedIn || !me.player) {
|
||||
return (
|
||||
<div className="max-w-lg mx-auto card !p-6 space-y-3 text-sm text-ink-300">
|
||||
<h1 className="text-xl font-semibold text-ink-50">Account</h1>
|
||||
<p>You're not signed in.</p>
|
||||
{me.signInAvailable
|
||||
? <a className="btn-primary inline-block" href={signInUrl('/account')}>Sign in with {me.provider}</a>
|
||||
: <p className="text-ink-400">Sign-in isn't set up on this server.</p>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="max-w-lg mx-auto space-y-4">
|
||||
<h1 className="text-2xl font-semibold text-ink-50">Account</h1>
|
||||
<div className="card !p-6 space-y-3 text-sm">
|
||||
<div className="flex justify-between gap-3">
|
||||
<span className="text-ink-400">DorkNet account</span>
|
||||
<RouterLink className="text-brand-300 hover:text-brand-200" to={`/players/${me.player.id}`}>{me.player.username}</RouterLink>
|
||||
</div>
|
||||
<div className="flex justify-between gap-3">
|
||||
<span className="text-ink-400">Signed in with {me.provider}</span>
|
||||
<span className="text-ink-200">{me.player.email || '—'}</span>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2 pt-2">
|
||||
<button type="button" className="btn-secondary" disabled={busy} onClick={() => run(signOut)}>Sign out</button>
|
||||
<button type="button" className="btn-ghost text-danger" disabled={busy}
|
||||
onClick={() => { if (window.confirm(`Unlink this sign-in from ${me.player!.username}? You'll need the account's password to link it again.`)) void run(unlink); }}>
|
||||
Unlink
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Link } from 'react-router';
|
||||
import { fileUrl, getDownloads, type DownloadFile, type DownloadRelease, type DownloadsManifest } from '../lib/downloads';
|
||||
import { fileUrl, getDownloads, SignInRequiredError, type DownloadFile, type DownloadRelease, type DownloadsManifest } from '../lib/downloads';
|
||||
import { signInUrl } from '../lib/account';
|
||||
|
||||
const fmtSize = (n: number) =>
|
||||
n >= 2 ** 30 ? `${(n / 2 ** 30).toFixed(2)} GB` : n >= 2 ** 20 ? `${(n / 2 ** 20).toFixed(1)} MB` : `${Math.max(1, Math.round(n / 2 ** 10))} KB`;
|
||||
@@ -96,9 +97,13 @@ function FileCard({ f, rel }: { f: DownloadFile; rel: DownloadRelease }) {
|
||||
export function Downloads() {
|
||||
const [m, setM] = useState<DownloadsManifest | null | undefined>(undefined);
|
||||
const [err, setErr] = useState<string | null>(null);
|
||||
const [signInNeeded, setSignInNeeded] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
getDownloads(true).then(setM).catch((e) => setErr((e as Error).message));
|
||||
getDownloads(true).then(setM).catch((e) => {
|
||||
if (e instanceof SignInRequiredError) setSignInNeeded(true);
|
||||
else setErr((e as Error).message);
|
||||
});
|
||||
}, []);
|
||||
|
||||
const releases = (m?.releases ?? []).filter((r) => r.files.length);
|
||||
@@ -114,7 +119,13 @@ export function Downloads() {
|
||||
</div>
|
||||
|
||||
{err && <div className="rounded-lg border border-danger/30 bg-danger/5 px-4 py-3 text-sm text-danger">Couldn't load the builds: {err}</div>}
|
||||
{m === undefined && !err && <p className="text-sm text-ink-400">Loading builds…</p>}
|
||||
{signInNeeded && (
|
||||
<div className="card !p-5 flex flex-wrap items-center justify-between gap-3 text-sm text-ink-300">
|
||||
<span>Sign in with your DorkNet account to see and download the builds.</span>
|
||||
<a className="btn-primary" href={signInUrl('/downloads')}>Sign in</a>
|
||||
</div>
|
||||
)}
|
||||
{m === undefined && !err && !signInNeeded && <p className="text-sm text-ink-400">Loading builds…</p>}
|
||||
{m === null && <p className="text-sm text-ink-400">This server doesn't offer downloads.</p>}
|
||||
{m && !releases.length && <p className="text-sm text-ink-400">No builds have been published yet.</p>}
|
||||
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Link as RouterLink, useNavigate, useSearchParams } from 'react-router';
|
||||
import { errorCopy, getMe, link, LINK_ERRORS, localPath, signInUrl, type AccountMe } from '../lib/account';
|
||||
|
||||
// After a first sign-in with the provider: prove which DorkNet account this
|
||||
// identity belongs to, once, with that account's password.
|
||||
export function Link() {
|
||||
const [params] = useSearchParams();
|
||||
const returnUrl = localPath(params.get('returnUrl'));
|
||||
const navigate = useNavigate();
|
||||
const [me, setMe] = useState<AccountMe | null>(null);
|
||||
const [username, setUsername] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [err, setErr] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
getMe(true).then((m) => {
|
||||
// Signed in with nothing waiting to be linked: nothing to do here. (A
|
||||
// signed-in visitor can still link a new identity, e.g. a second
|
||||
// account's.)
|
||||
if (m.signedIn && !m.pendingLink) navigate(returnUrl, { replace: true });
|
||||
else setMe(m);
|
||||
}).catch((e) => setErr((e as Error).message));
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
const submit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setBusy(true);
|
||||
setErr(null);
|
||||
try {
|
||||
await link(username.trim(), password);
|
||||
await getMe(true);
|
||||
window.location.assign(returnUrl); // the header re-reads who is signed in
|
||||
} catch (e) {
|
||||
const key = (e as Error).message;
|
||||
setErr(errorCopy(LINK_ERRORS, key, key));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (me && !me.pendingLink) {
|
||||
return (
|
||||
<div className="max-w-lg mx-auto card !p-6 space-y-3 text-sm text-ink-300">
|
||||
<h1 className="text-xl font-semibold text-ink-50">Link your account</h1>
|
||||
<p>Your sign-in expired before it was linked.</p>
|
||||
{me.signInAvailable && <a className="btn-primary inline-block" href={signInUrl(returnUrl)}>Sign in again</a>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="max-w-lg mx-auto space-y-4">
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold text-ink-50">Link your DorkNet account</h1>
|
||||
<p className="text-sm text-ink-400">
|
||||
You signed in{me?.pendingLink?.email ? <> as <span className="text-ink-200">{me.pendingLink.email}</span></> : null}.
|
||||
Enter your DorkNet username and password once to connect them; after that, signing in is enough.
|
||||
</p>
|
||||
</div>
|
||||
<form onSubmit={submit} className="card !p-6 space-y-4">
|
||||
<div className="space-y-1.5">
|
||||
<label className="label" htmlFor="username">DorkNet username</label>
|
||||
<input id="username" className="input" autoComplete="username" required
|
||||
value={username} onChange={(e) => setUsername(e.target.value)} />
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<label className="label" htmlFor="password">Password</label>
|
||||
<input id="password" type="password" className="input" autoComplete="current-password" required
|
||||
value={password} onChange={(e) => setPassword(e.target.value)} />
|
||||
</div>
|
||||
{err && <div className="rounded-lg border border-danger/30 bg-danger/5 px-3 py-2 text-sm text-danger">{err}</div>}
|
||||
<button type="submit" disabled={busy} className="btn-primary w-full">{busy ? 'Linking…' : 'Link account'}</button>
|
||||
<p className="text-xs text-ink-500">
|
||||
No account yet? <RouterLink className="text-brand-300 hover:text-brand-200" to="/join">Join with a signup code</RouterLink>, then come back here.
|
||||
</p>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -2,7 +2,8 @@ import { useEffect, useRef, useState } from 'react';
|
||||
import { useSearchParams } from 'react-router';
|
||||
import { fileSource, openXapk, urlSource, type Xapk } from '../lib/xapk';
|
||||
import { connectHeadset, installXapk, webUsbSupported, type Headset, type Progress } from '../lib/questInstall';
|
||||
import { fileUrl, getDownloads, questBuild, type QuestBuild } from '../lib/downloads';
|
||||
import { fileUrl, getDownloads, questBuild, SignInRequiredError, type QuestBuild } from '../lib/downloads';
|
||||
import { signInUrl } from '../lib/account';
|
||||
|
||||
const fmtSize = (n: number) =>
|
||||
n >= 1e9 ? `${(n / 1e9).toFixed(2)} GB` : n >= 1e6 ? `${(n / 1e6).toFixed(1)} MB` : `${Math.max(1, Math.round(n / 1e3))} KB`;
|
||||
@@ -47,6 +48,8 @@ export function Quest() {
|
||||
const fileRef = useRef<HTMLInputElement>(null);
|
||||
// The server's latest published Quest build, if it offers downloads.
|
||||
const [latest, setLatest] = useState<QuestBuild | null>(null);
|
||||
// Downloads exist but need a signed-in player (DorkNet:Downloads:RequireSignIn).
|
||||
const [signInForLatest, setSignInForLatest] = useState(false);
|
||||
const [params] = useSearchParams();
|
||||
|
||||
const open = async (load: () => Promise<Xapk>) => {
|
||||
@@ -71,7 +74,10 @@ export function Quest() {
|
||||
// that card's own file, which may not be the newest release's.
|
||||
const wanted = params.get('file');
|
||||
if (wanted) void pickBuild(questBuild(m, wanted));
|
||||
}).catch(() => { /* no downloads: only the file picker */ });
|
||||
}).catch((e) => {
|
||||
// No downloads: only the file picker. Sign-in required: offer it.
|
||||
if (e instanceof SignInRequiredError) setSignInForLatest(true);
|
||||
});
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
@@ -140,6 +146,11 @@ export function Quest() {
|
||||
) : (
|
||||
<p className="text-sm text-ink-400">Pick the <code className="text-ink-200">DorkNet-….xapk</code> you were given. It stays on your computer; nothing is uploaded.</p>
|
||||
)}
|
||||
{signInForLatest && !latest && (
|
||||
<p className="text-sm text-ink-400">
|
||||
<a className="text-brand-300 hover:text-brand-200" href={signInUrl('/quest')}>Sign in</a> to install the latest build straight from this server.
|
||||
</p>
|
||||
)}
|
||||
<input ref={fileRef} type="file" accept=".xapk,.zip" className="hidden" onChange={(e) => pickFile(e.target.files?.[0])} />
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
{latest && (
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net9.0</TargetFramework>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
</PropertyGroup>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk.Web">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net9.0</TargetFramework>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
</PropertyGroup>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk.Web">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net9.0</TargetFramework>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
</PropertyGroup>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk.Web">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net9.0</TargetFramework>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
</PropertyGroup>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk.Web">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net9.0</TargetFramework>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
</PropertyGroup>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk.Web">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net9.0</TargetFramework>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
</PropertyGroup>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk.Web">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net9.0</TargetFramework>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
</PropertyGroup>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk.Web">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net9.0</TargetFramework>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
</PropertyGroup>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk.Web">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net9.0</TargetFramework>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
</PropertyGroup>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk.Web">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net9.0</TargetFramework>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
</PropertyGroup>
|
||||
|
||||
Vendored
+21
-10
@@ -9,8 +9,9 @@
|
||||
// from the agent's environment (ARCHIVE_PC_REPO / ARCHIVE_QUEST_REPO), each
|
||||
// overridable per run. A build is picked by snapshot tags.
|
||||
//
|
||||
// On a push, only the platforms whose code changed are rebuilt. A manual run
|
||||
// picks the platforms with PLATFORMS. See ci/jenkins/README.md.
|
||||
// A push builds only the platforms whose client code changed (never for
|
||||
// docs or CI-only changes). A manual run picks the platforms with PLATFORMS.
|
||||
// See ci/jenkins/README.md.
|
||||
//
|
||||
// Finished builds are renamed for players and published to the downloads bucket
|
||||
// (ci/downloads) unless PUBLISH_DOWNLOADS is off. The build scripts keep their
|
||||
@@ -76,10 +77,15 @@ pipeline {
|
||||
for (cs in currentBuild.changeSets) {
|
||||
for (entry in cs.items) changed.addAll(entry.affectedPaths)
|
||||
}
|
||||
def touches = { prefixes -> changed.any { p -> prefixes.any { p.startsWith(it) } } }
|
||||
def common = ['Jenkinsfile', 'ci/jenkins/']
|
||||
buildPc = touches(common + ['DorkNet.ClientMod/', 'tools/build_pc.py'])
|
||||
buildQuest = touches(common + ['DorkNet.Quest/'])
|
||||
// Only client-facing changes rebuild (and so republish) a
|
||||
// platform; Markdown (.md, .markdown) doesn't count. The job's Git path filter
|
||||
// uses the same paths, so other pushes don't start a run at all.
|
||||
// A CI-only change (Jenkinsfile, ci/) takes a manual run.
|
||||
def touches = { prefixes ->
|
||||
changed.any { p -> !(p.toLowerCase() ==~ /.*\.(md|markdown)/) && prefixes.any { p.startsWith(it) } }
|
||||
}
|
||||
buildPc = touches(['DorkNet.ClientMod/', 'tools/build_pc.py'])
|
||||
buildQuest = touches(['DorkNet.Quest/'])
|
||||
echo "Push build; ${changed.size()} changed file(s)"
|
||||
} else {
|
||||
buildPc = params.PLATFORMS in ['both', 'pc']
|
||||
@@ -220,10 +226,15 @@ pipeline {
|
||||
archiveArtifacts artifacts: 'out/downloads/**', allowEmptyArchive: true, fingerprint: true
|
||||
}
|
||||
cleanup {
|
||||
// Runs after archiving. Keeps the restic cache and restored stock
|
||||
// builds ($DORKNET_CACHE on the agent, outside the workspace); drops
|
||||
// the workspace's build output and scratch.
|
||||
cleanWs(deleteDirs: true, notFailBuild: true)
|
||||
// Runs after archiving. Drops the build output and scratch (several
|
||||
// GB) but keeps the Git checkout: the job's path filter compares
|
||||
// pushes against it, and without a workspace Jenkins treats every
|
||||
// push as a change and starts a run. The restic cache and restored
|
||||
// stock builds live outside the workspace ($DORKNET_CACHE).
|
||||
cleanWs(deleteDirs: true, notFailBuild: true, patterns: [
|
||||
[pattern: 'out/**', type: 'INCLUDE'],
|
||||
[pattern: 'quest-work/**', type: 'INCLUDE'],
|
||||
])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -64,7 +64,7 @@ What the 2023 client gets here that the 2020 branches don't have:
|
||||
|
||||
## Quickstart
|
||||
|
||||
**1. Install .NET 9 SDK** and (optionally) `dotnet-ef`:
|
||||
**1. Install .NET 10 SDK** and (optionally) `dotnet-ef`:
|
||||
|
||||
```pwsh
|
||||
dotnet tool install --global dotnet-ef
|
||||
@@ -101,7 +101,7 @@ cd DorkNet
|
||||
dotnet run --project DorkNet.Server
|
||||
```
|
||||
|
||||
First boot creates `bin/Debug/net9.0/data/dorknet.db`, runs every
|
||||
First boot creates `bin/Debug/net10.0/data/dorknet.db`, runs every
|
||||
`LegacyUpgrades` pass, seeds canonical rooms / store catalog / club
|
||||
categories / playlists, and starts listening. You should see
|
||||
`Application started.` in the log; `curl http://localhost:8080/healthz`
|
||||
@@ -431,6 +431,16 @@ hand it to the player, and they redeem it on the site's **`/join`** page —
|
||||
which creates their account bound to the device their game client
|
||||
reported, so the next launch logs straight in.
|
||||
|
||||
### Website and admin sign-in
|
||||
|
||||
Optionally, players sign in to the website (and admins to the admin panel)
|
||||
with an OpenID Connect provider such as a Cloudflare Access SaaS app. Each
|
||||
player links the provider identity to their DorkNet account once, with the
|
||||
account's password, on the site's **`/link`** page; the Quest page's direct
|
||||
install and the Downloads page can then be limited to signed-in players. The
|
||||
game client's login is unchanged. Setup:
|
||||
[`docs/deploy.md`](docs/deploy.md#external-sign-in-openid-connect).
|
||||
|
||||
### Everyone-is-friends toggle
|
||||
|
||||
For small servers where searching + friend-requesting each other is
|
||||
|
||||
@@ -28,7 +28,7 @@ RUN apt-get update \
|
||||
&& wget -q "https://packages.microsoft.com/config/debian/${VERSION_ID}/packages-microsoft-prod.deb" -O /tmp/ms.deb \
|
||||
&& dpkg -i /tmp/ms.deb && rm /tmp/ms.deb \
|
||||
&& apt-get update \
|
||||
&& apt-get install -y --no-install-recommends dotnet-sdk-8.0 \
|
||||
&& apt-get install -y --no-install-recommends dotnet-sdk-10.0 \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# ---- Python with LIEF (DT_NEEDED injection into libmain.so) ---------------
|
||||
|
||||
+14
-3
@@ -60,10 +60,21 @@ Resolve a table for another build locally with `build.py build --dump-cs`.
|
||||
|
||||
## Triggers
|
||||
|
||||
- **Push** to the branch (GitHub webhook) rebuilds only what changed:
|
||||
- `DorkNet.ClientMod/` and the PC scripts rebuild PC;
|
||||
- **Push** to the branch (GitHub webhook) builds only for client-facing
|
||||
changes, and only the platform they touch:
|
||||
- `DorkNet.ClientMod/` and `tools/build_pc.py` rebuild PC;
|
||||
- `DorkNet.Quest/` rebuilds Quest;
|
||||
- `Jenkinsfile` and `ci/jenkins/` rebuild both.
|
||||
- Markdown files (`.md`, `.markdown`) never count; server, docs and CI-only pushes (including
|
||||
the `Jenkinsfile` and `ci/`) build nothing. Run those manually.
|
||||
|
||||
Set the same filter on the job so other pushes don't start a run at all:
|
||||
under the job's **Git** source, add **Additional Behaviours → Polling
|
||||
ignores commits in certain paths** with included regions
|
||||
`DorkNet[.]ClientMod/.*`, `DorkNet[.]Quest/.*`, `tools/build_pc[.]py` (one
|
||||
per line) and excluded region `.*[.](md|markdown)`. The filter compares against the
|
||||
last build's checkout, so the pipeline keeps it and deletes only `out/`
|
||||
and its scratch folders after each run; without a checkout, Jenkins treats
|
||||
every push as a change.
|
||||
- **Build with Parameters** runs a manual build:
|
||||
- `PLATFORMS` (both / pc / quest), and `PC_FULL_CLIENT` for the full PC client;
|
||||
- snapshot tags, and per-run repository overrides;
|
||||
|
||||
@@ -41,6 +41,13 @@ there is always a bootstrap operator. The login form posts to
|
||||
| `dorknet.admin.token` | DorkNet admin JWT |
|
||||
| `dorknet.admin.me` | Admin identity object used by the layout |
|
||||
|
||||
With [external sign-in](deploy.md#external-sign-in-openid-connect)
|
||||
configured, the login page also shows **Sign in with …**. It signs in with
|
||||
the provider on the admin host, then `GET /api/admin/v1/sso` swaps that
|
||||
session for the same token pair the password login returns. The identity
|
||||
must already be linked to an account on the website, and the account still
|
||||
needs `IsAdmin = true`.
|
||||
|
||||
Every admin API request sends `Authorization: Bearer <token>`. Protected
|
||||
actions are guarded by `AdminOnlyAttribute`, which checks the resolved
|
||||
player row still has `IsAdmin = true`. A `401` clears the local session
|
||||
|
||||
+14
-14
@@ -35,20 +35,20 @@ DorkNet.sln
|
||||
|
||||
| Project | Responsibility | Target |
|
||||
|---|---|---|
|
||||
| `DorkNet.Server` | Shared REST/controller/service stack, database bootstrap, standalone fallback, monolith fallback | .NET 9 |
|
||||
| `DorkNet.Models` | DTOs the wire protocol speaks. No project dependencies. | .NET 9 |
|
||||
| `DorkNet.Contracts` | Service names, route ownership, service-map options, health responses, probe responses | .NET 9 |
|
||||
| `DorkNet.ServiceDefaults` | Common service health checks, JSON defaults, HTTP clients, service identity, route guard | .NET 9 |
|
||||
| `DorkNet.Gateway` | Edge reverse proxy; routes host/path slices to services and exposes service health probes | .NET 9 |
|
||||
| `DorkNet.Services.Identity` | Accounts, auth, platform login, JWT issuance route slice | .NET 9 |
|
||||
| `DorkNet.Services.Rooms` | Rooms, room keys, matchmaking, discovery route slice | .NET 9 |
|
||||
| `DorkNet.Services.Notify` | SignalR edge, messages/chat, notification fan-out route slice | .NET 9 |
|
||||
| `DorkNet.Services.Content` | CDN paths, image/photo APIs, uploads, S3-backed blobs | .NET 9 |
|
||||
| `DorkNet.Services.Social` | Clubs, groups, announcements, player events, subscriptions | .NET 9 |
|
||||
| `DorkNet.Services.Commerce` | Catalog, storefronts, econ, inventory, inventions | .NET 9 |
|
||||
| `DorkNet.Services.Platform` | Service URLs, config, version checks, geo, strings, telemetry-style surfaces | .NET 9 |
|
||||
| `DorkNet.Services.Moderation` | Bug reporting, player reporting, sanitize, path-routed admin API, testcase routes | .NET 9 |
|
||||
| `DorkNet.Services.Web` | Public site, admin static host, same-origin admin browser API, feed static host, site API | .NET 9 |
|
||||
| `DorkNet.Server` | Shared REST/controller/service stack, database bootstrap, standalone fallback, monolith fallback | .NET 10 |
|
||||
| `DorkNet.Models` | DTOs the wire protocol speaks. No project dependencies. | .NET 10 |
|
||||
| `DorkNet.Contracts` | Service names, route ownership, service-map options, health responses, probe responses | .NET 10 |
|
||||
| `DorkNet.ServiceDefaults` | Common service health checks, JSON defaults, HTTP clients, service identity, route guard | .NET 10 |
|
||||
| `DorkNet.Gateway` | Edge reverse proxy; routes host/path slices to services and exposes service health probes | .NET 10 |
|
||||
| `DorkNet.Services.Identity` | Accounts, auth, platform login, JWT issuance route slice | .NET 10 |
|
||||
| `DorkNet.Services.Rooms` | Rooms, room keys, matchmaking, discovery route slice | .NET 10 |
|
||||
| `DorkNet.Services.Notify` | SignalR edge, messages/chat, notification fan-out route slice | .NET 10 |
|
||||
| `DorkNet.Services.Content` | CDN paths, image/photo APIs, uploads, S3-backed blobs | .NET 10 |
|
||||
| `DorkNet.Services.Social` | Clubs, groups, announcements, player events, subscriptions | .NET 10 |
|
||||
| `DorkNet.Services.Commerce` | Catalog, storefronts, econ, inventory, inventions | .NET 10 |
|
||||
| `DorkNet.Services.Platform` | Service URLs, config, version checks, geo, strings, telemetry-style surfaces | .NET 10 |
|
||||
| `DorkNet.Services.Moderation` | Bug reporting, player reporting, sanitize, path-routed admin API, testcase routes | .NET 10 |
|
||||
| `DorkNet.Services.Web` | Public site, admin static host, same-origin admin browser API, feed static host, site API | .NET 10 |
|
||||
| `DorkNet.ClientMod` | MelonLoader 0.6.x IL2CPP mod — the client patcher, JSON config | .NET 6 |
|
||||
| `DorkNet.Quest` | native hook library + build scripts for the Meta Quest client | C++/arm64 |
|
||||
|
||||
|
||||
@@ -443,6 +443,74 @@ answer only on the apex host, where the site lives, and return 404 on every
|
||||
other host the server accepts, so leaving the game's own subdomains open for
|
||||
the client doesn't expose the files. Put the apex behind the proxy.
|
||||
|
||||
To serve the builds only to players signed in with a linked DorkNet account
|
||||
(see [External sign-in](#external-sign-in-openid-connect)), also set:
|
||||
|
||||
```env
|
||||
DorkNet__Downloads__RequireSignIn=true
|
||||
```
|
||||
|
||||
The manifest and files then answer `401` until the visitor signs in, and the
|
||||
Downloads and Quest pages offer **Sign in** instead.
|
||||
|
||||
---
|
||||
|
||||
## External sign-in (OpenID Connect)
|
||||
|
||||
Optional. Players sign in to the website, and admins to the admin panel,
|
||||
with any OpenID Connect provider: a Cloudflare Access SaaS application,
|
||||
Google, Authentik, Keycloak and so on. The game client keeps its own login.
|
||||
|
||||
The first time someone signs in, the website asks for their DorkNet username
|
||||
and password, once, to link the provider identity to that account. After that
|
||||
the provider sign-in is enough. The link is keyed on the provider's issuer and
|
||||
subject (`iss` + `sub`), never on the email address, so a changed or reused
|
||||
email can't take over an account. A player has one linked identity per
|
||||
provider; **Account** (`/account`) shows it and can unlink it.
|
||||
|
||||
```env
|
||||
# The provider's issuer URL (discovery at {Authority}/.well-known/openid-configuration).
|
||||
DorkNet__SignIn__Authority=https://<provider>/...
|
||||
DorkNet__SignIn__ClientId=<client id>
|
||||
DorkNet__SignIn__ClientSecret=<client secret>
|
||||
# Optional: button label ("Sign in with ..."), default "single sign-on".
|
||||
DorkNet__SignIn__DisplayName=Cloudflare
|
||||
# Optional: scopes, default "openid email profile".
|
||||
DorkNet__SignIn__Scopes=openid email profile
|
||||
# Optional: also read the userinfo endpoint (default false; the ID token is
|
||||
# enough for most providers, and Cloudflare Access's userinfo can't be read).
|
||||
DorkNet__SignIn__UseUserInfoEndpoint=false
|
||||
```
|
||||
|
||||
Register these redirect URIs at the provider, one per host people sign in on:
|
||||
|
||||
```text
|
||||
https://<site host>/api/auth/external/callback
|
||||
https://<admin host>/api/auth/external/callback
|
||||
```
|
||||
|
||||
The server builds the redirect URI from `Domain:Scheme` and the request's
|
||||
host, so a TLS-terminating proxy in front doesn't turn it into `http://`.
|
||||
|
||||
**Cloudflare Access as the provider.** In Zero Trust, add an application of
|
||||
type **SaaS**, choose **OIDC**, and enter the redirect URIs above. Give it the
|
||||
same Access policies as the site, so the people who can open the site can
|
||||
sign in. Copy the issuer, client ID and client secret into the settings
|
||||
above.
|
||||
|
||||
Sessions are HttpOnly cookies per host (`dorknet_session`, 30 days, sliding).
|
||||
The keys that encrypt them are stored in the database (`DataProtectionKeys`),
|
||||
so redeploys and extra replicas keep everyone signed in. They're stored
|
||||
encrypted with a key derived from the server secret (`DORKNET_JWT_SECRET`),
|
||||
so a database backup alone can't forge sessions; changing that secret signs
|
||||
everyone out. Unlinking the
|
||||
identity, deleting the account or banning the player ends the session on the
|
||||
next request. Wrong passwords on the link form are limited to 5 per 15
|
||||
minutes per provider identity and per username.
|
||||
|
||||
Unset, the website shows no sign-in, and the admin panel only the password
|
||||
form.
|
||||
|
||||
---
|
||||
|
||||
## Photon Cloud
|
||||
|
||||
@@ -170,7 +170,7 @@ the player to their dorm, so bans go out as `Logout` (6) with Reason 4
|
||||
|
||||
Ids the client handles that this server never sends (dead client features,
|
||||
available if wanted): 3, 5, 23, 32, 60, 110, 120, 121, plus a long tail of
|
||||
string channels — `GoToFailure`, `PhotonAccessToken`, `AppVersionUpdate`,
|
||||
string channels — `GoToFailure`, `AppVersionUpdate`,
|
||||
`GameConfig.Refresh`, `ReputationUpdate`, `KeepsakeInstanceAdded/Removed`,
|
||||
`RoomCurrencyCreated/Modified/Deleted`, `RoomCommentDeleted` and others.
|
||||
|
||||
@@ -622,6 +622,83 @@ Diffed all 45 real HTTP routes of the 2023-03-21 client's rooms subsystem agains
|
||||
|
||||
8. UNKNOWNS: rooms/requiring/{mode} response element type could not be resolved from the ISIL metadata token (stored internally as a restricted-rooms list; server already implements rooms/requiring/{restriction}). The exact wire type of BNLPBMJJOMM.Value (subroom permissions) is a variant read through the shared value reader — keys are certain, scalar type is not.
|
||||
|
||||
#### Sub-room ids, IsRRO and the instance browser
|
||||
|
||||
These cut across the whole rooms surface.
|
||||
|
||||
- **Every room and playlist mutation answers `{success, value, error}`.**
|
||||
They all go through one generic helper,
|
||||
`NLDBPDCNNCF.BDBHCEGKGDC<TResult>(opName, verb, route, …)` (RVA 0x35E50A0,
|
||||
`Call 0x1835E50A0` at 47 call sites in NLDBPDCNNCF.txt). It is the same
|
||||
helper and the same `BDBHCEGKGDC<FGCPNAACHIK>` instantiation token
|
||||
(`[0x1884C1948]`) that "clone room" uses (NLDBPDCNNCF.txt:4815-4823), and
|
||||
clone is proven in game to need the envelope. A bare room-details body has
|
||||
no `success`; the helper rejects it with no message, and the player sees
|
||||
"Exception of type 'CEMNLBKJABA' was thrown." (CEMNLBKJABA is the RecNet
|
||||
request exception, base BCPHBJHOIEM : Exception, built with a null
|
||||
message) although the server applied the change. This is what broke the
|
||||
co-owner invite in game after the role fixes. Routes: rooms/{0}/{accessibility,
|
||||
allow_new_users, automute, cloning, comments, creator, description, image,
|
||||
loadscreen, max_player_calculation_mode, min_level, modify, name,
|
||||
promo_external, promo_images, restrictions, tags, voice_chat_encryption,
|
||||
warning}, rooms/{0}/roles/{1} and /invite, rooms/{0}/subrooms (POST),
|
||||
rooms/{0}/subrooms/{1} (DELETE) and its accessibility, clone, maxplayers,
|
||||
modify, move, name, permissions, publish_save, plus playlists (POST) and
|
||||
playlists/{0}/{accessibility, description, image, levelvoting, name,
|
||||
restrictions, rooms/{1}, tags, warning}. `value` is the full FGCPNAACHIK
|
||||
(playlist DTO for playlists). Refusals are HTTP 200 with `success:false`
|
||||
and a message in `error`, since a 403/404 also shows only the message-less
|
||||
exception. Server: `Controllers/RecNetEnvelopeAttribute.cs` on each handler.
|
||||
Wherever a row below says "FGCPNAACHIK full room details" for one of these
|
||||
routes, read it as the envelope's `value`.
|
||||
|
||||
- **Sub-room ids are OrderIndex + 1 on the wire.** The client refuses to load
|
||||
a sub-room whose id is not positive: `SubroomModel.DOKCPLJOKIE`
|
||||
(Assembly-CSharp/RRUI/Data/SubroomModel.txt:3) is
|
||||
`RoomId > 0 && SubroomId > 0`, and `Model`1.LNPEMMMGCPC` skips the load when
|
||||
it fails. With the first sub-room sent as 0, its Room Settings > Subrooms row
|
||||
never fetched its details and showed no name, and every row action ran
|
||||
against a null sub-room. The wire id is now `RoomSceneEntity.OrderIndex + 1`
|
||||
in the room details `SubRooms[]`, the sub-room routes
|
||||
(`rooms/{0}/subrooms/{1}/*`), `matchmake/room/{0}/{1}`, the room-instance
|
||||
JSON (`RoomInstanceDto.subRoomId`), the instance browser and the sub-room
|
||||
save DTOs; incoming 0 is accepted as the first sub-room. Storage (RoomScenes,
|
||||
PrivateInstances, RoomDataBlobs, presence) keeps the OrderIndex.
|
||||
See `DorkNet.Server/Services/RoomSceneIds.cs`.
|
||||
- **Every room owns a real, named entry sub-room.** Cloning a template that
|
||||
has no RoomScenes rows now creates a "Home" row from the clone's own
|
||||
location, blob and capacity, and a startup pass
|
||||
(`RoomService.RepairRoomScenesAsync`) adds the row to existing rooms that
|
||||
lack one and names blank rows ("Home", "Subroom N").
|
||||
- **`IsRRO` is false for every player-owned room.** RoomModel's owner UI —
|
||||
get_CanPublishRoom, CanUnpublishRoom, CanDeleteRoom, CanChangeRoomCreator,
|
||||
CanChangeRoomSetting, CanChangeCustomWarning, CanChangeLoadingScreen,
|
||||
CanCreateNewSubroom (RoomModel.txt:275, 429, 560, 682, 1002, 1473, 1680,
|
||||
1816) — requires `!IsRRO`, so a clone of a Rec Room Original template
|
||||
advertised as RRO could not be published ("Cannot publish room") or have its
|
||||
settings changed. The load pipeline (RecRoom.RoomLoading.Runtime) never
|
||||
reads the flag. `RoomService.WireIsRRO` = AG room still owned by the system
|
||||
account; `RoomEntity.IsAGRoom` keeps its server-side meaning.
|
||||
- **Publish preconditions are client-side.** Besides `!IsRRO`, the client
|
||||
requires role ≥ CoOwner, not a dorm, a valid name (2–128 chars) and a room
|
||||
image other than `DefaultRoomImage.jpg` ("Cannot publish room: Missing room
|
||||
photo") before it sends `PUT rooms/{0}/accessibility accessibility=1`.
|
||||
- **Room settings are creator or accepted co-owner.** Sub-room mutations
|
||||
(name, max players, accessibility, modify, add, delete, duplicate, restore,
|
||||
permissions) used to be creator-only and the room and sub-room renames also
|
||||
let moderators in; all now use the same creator-or-co-owner gate as the
|
||||
bare room mutations.
|
||||
- **`room/{0}/instances`** (Matchmaking.KAIPGDGACGG, Matchmaking.txt:13966,
|
||||
verb 0) is shown unfiltered by the client (RoomInstancePagedGrid, current
|
||||
instance first, then oldest CreatedAt), which derives PlayerCount from
|
||||
PlayerIds and SubroomName from the room's SubRooms by SubRoomId. The server
|
||||
used to return nothing at all on Redis deployments (no per-room index),
|
||||
dropped private instances from presence and re-added only the caller's own
|
||||
with no players, counted crashed clients, and always sent IsFull false. It
|
||||
now lists live instances from a per-room presence index, with the real
|
||||
players (live activity marker only), IsFull from capacity, and private
|
||||
instances for the people allowed into them.
|
||||
|
||||
#### Endpoints
|
||||
|
||||
| Verb | Route | Request | Response |
|
||||
@@ -630,7 +707,7 @@ Diffed all 45 real HTTP routes of the 2023-03-21 client's rooms subsystem agains
|
||||
| POST | `api/rooms/v1/verifyRole` | x-www-form-urlencoded fields: roomId (Int64, current room from Matchmaking ObscuredLong), role (Int32 enum), context (String). Keys are camelCase. | none parsed — client ignores the body; return 200 |
|
||||
| POST | `api/rooms/v2/report` | x-www-form-urlencoded fields (PascalCase!): RoomId (Int64), RoomKeyId (Nullable<Int64>), Details (String), ReportCategory (Int32 enum FPIBGPIAOBI/BDOGOIGCKMK) | none parsed — logged as 'Failed to report room' on error; return 200 |
|
||||
| GET | `featuredrooms/current` | none | JBHJKCCFCIO: {"FeaturedRoomGroupId": Int64, "Name": String, "Rooms": [COGJIOGPNGD {"ImageName": String, "RoomId": Int64, "RoomName": String}]} (casing-tolerant) |
|
||||
| GET | `room/{0}/instances` | none | JSON array: [PNDCMIMEJLD {"RoomInstanceId": Int64, "RoomId": Int64, "SubRoomId": Int64, "PlayerIds": [Int32], "IsFull": Boolean, "CreatedAt": DateTime string}] (casing-tolerant) |
|
||||
| GET | `room/{0}/instances` | none | JSON array: [PNDCMIMEJLD {"RoomInstanceId": Int64, "RoomId": Int64, "SubRoomId": Int64 (wire id, see "Sub-room ids" below), "PlayerIds": [Int32], "IsFull": Boolean, "CreatedAt": UTC DateTime string}] (casing-tolerant). Live instances only (at least one player with a live activity marker); private instances only for the creator, accepted role holders, the instance owner, its invitees and players inside |
|
||||
| PUT | `roominstance/{0}/inprogress` | x-www-form-urlencoded field: inProgress (Boolean) | none parsed ('Failed to modify current room's game-in-progress state' on error); return 200 |
|
||||
| PUT | `roominstance/{0}/markprivate` | none (empty body) | none parsed ('Failed to mark room instance {0} as private'); return 200 |
|
||||
| POST | `roominstance/{0}/reportjoinresult` | x-www-form-urlencoded field: result (Int32 enum MEIJCIADDHJ) | none parsed ('Failed to report room join result'); return 200 |
|
||||
@@ -657,7 +734,7 @@ Diffed all 45 real HTTP routes of the 2023-03-21 client's rooms subsystem agains
|
||||
| GET | `rooms/visitedby/me` | query params: skip (Int32), take (Int32) | [NEMINAEBALC] |
|
||||
| GET | `rooms/visitedby/{0}` | query params: skip (Int32), take (Int32) | [NEMINAEBALC] |
|
||||
| GET (2 overloads) + DELETE | `rooms/{0}` | GET summary: none. GET details: query params include (Byte bitmask), unityAssetTarget (String), unityAssetVersion (Int32) from closure CAEBODLFAMA. DELETE: none. | GET summary → NEMINAEBALC. GET details → FGCPNAACHIK = NEMINAEBALC keys PLUS: "DataBlob" String, "DataBlobHash" String, "LoadScreens": [{"ImageName","Title","Subtitle" all String}], "PromoImages": [String], "PromoExterna |
|
||||
| PUT | `rooms/{0}/accessibility` | x-www-form-urlencoded field: accessibility (Int32 enum JHAOFLCJNAL) | FGCPNAACHIK full room details (see rooms/{0}) — client re-parses the whole object; missing keys NRE |
|
||||
| PUT | `rooms/{0}/accessibility` | x-www-form-urlencoded field: accessibility (Int32 enum JHAOFLCJNAL: Private 0, Public 1, Unlisted 2). This is the "Publish room" button (RoomModel.TryPublishRoom → DBKKLDIPCHP(1)); unpublish sends 0 | FGCPNAACHIK full room details (see rooms/{0}) — client re-parses the whole object; missing keys NRE |
|
||||
| PUT | `rooms/{0}/allow_new_users` | form field: allowNewUsers (Boolean) | FGCPNAACHIK full room details |
|
||||
| PUT | `rooms/{0}/automute` | form field: disable (Boolean) — NOTE key is 'disable', maps to DisableMicAutoMute | FGCPNAACHIK full room details |
|
||||
| GET + POST | `rooms/{0}/bans` | GET: none. POST 'ban players': form fields id (repeated Int32 account ids) + banMask (Int32 enum DCDPJPHBHOA) | GET → [IBHAKOOKEEE {"AccountId": Int32, "BannedByAccountId": Int32, "BanStartTime": DateTime string}]. POST → none parsed. |
|
||||
@@ -681,9 +758,9 @@ Diffed all 45 real HTTP routes of the 2023-03-21 client's rooms subsystem agains
|
||||
| PUT + DELETE | `rooms/{0}/promo_external` | form fields: type (Int32 enum OJGHPAELGBO), reference (String) — same keys on both verbs | FGCPNAACHIK full room details |
|
||||
| PUT + DELETE | `rooms/{0}/promo_images` | form field: imageName (String) on both verbs | FGCPNAACHIK full room details |
|
||||
| PUT | `rooms/{0}/restrictions` | form fields: supportsJuniors, supportsScreens, supportsTeleportVR, supportsWalkVR (Booleans) | FGCPNAACHIK full room details |
|
||||
| GET | `rooms/{0}/roles` | none | [EFHPLDPNGIM {"AccountId": Int32, "Role": Int32 enum, "InvitedRole": Int32 enum, "LastChangedByAccountId": Int32}] (casing-tolerant) |
|
||||
| GET + PUT | `rooms/{0}/roles/{1}` | GET: none. PUT: form field role (Int32 enum OMMBGJMJJPN). | GET → EFHPLDPNGIM single object. PUT → FGCPNAACHIK full room details. |
|
||||
| PUT | `rooms/{0}/roles/{1}/invite` | form field: role (Int32 enum) | FGCPNAACHIK full room details |
|
||||
| GET | `rooms/{0}/roles` | none | [EFHPLDPNGIM {"AccountId": Int32, "Role": Int32 enum, "InvitedRole": Int32 enum, "LastChangedByAccountId": Int32}] (casing-tolerant). One row per account; the creator is Role 255 (Creator) |
|
||||
| GET + PUT | `rooms/{0}/roles/{1}` | GET: none. PUT: form field role (Int32 enum OMMBGJMJJPN: None 0, Host 10, Moderator 20, CoOwner 30, TemporaryCoOwner 31, Creator 255). Also how an invitee ACCEPTS (own id, role 30) and how anyone leaves their role (own id, role 0) | GET → EFHPLDPNGIM single object. PUT → FGCPNAACHIK full room details. |
|
||||
| PUT | `rooms/{0}/roles/{1}/invite` | form field: role (Int32 enum). Owner invites with 30; the invitee REJECTS by sending role 0 here on their own id | FGCPNAACHIK full room details |
|
||||
| GET | `rooms/{0}/similar` | none | single OPGDCAELKMJ: {"SeedRoom": NEMINAEBALC, "Rooms": [NEMINAEBALC]} |
|
||||
| POST | `rooms/{0}/subrooms` | form field: name (String) — new subroom name | FGCPNAACHIK full room details (with the new SubRooms entry) |
|
||||
| DELETE | `rooms/{0}/subrooms/{1}` | none | FGCPNAACHIK full room details |
|
||||
@@ -694,7 +771,7 @@ Diffed all 45 real HTTP routes of the 2023-03-21 client's rooms subsystem agains
|
||||
| PUT | `rooms/{0}/subrooms/{1}/modify` | form fields: name (String), accessibility (Int32 enum), maxPlayers (Int32) | FGCPNAACHIK full room details |
|
||||
| POST | `rooms/{0}/subrooms/{1}/move` | form field: newRoomId (Nullable<Int64> — target room) | FGCPNAACHIK full room details |
|
||||
| PUT | `rooms/{0}/subrooms/{1}/name` | form field: name (String) | FGCPNAACHIK full room details |
|
||||
| PUT | `rooms/{0}/subrooms/{1}/permissions` | serialized list of BNLPBMJJOMM permission entries; per-entry keys (from its serializer): Role (Int32), Type (Int32), Permission (Int32), Override (Boolean), Value (variant) | FGCPNAACHIK full room details |
|
||||
| PUT | `rooms/{0}/subrooms/{1}/permissions` | raw JSON array (BNDIAONDFFF.FJLLPHFOOJJ, not form) of BNLPBMJJOMM cells, keys from formatter HDOJMEBCAGP: Permission (String), Role (OMMBGJMJJPN), Override (Boolean), Type (FEOJFGECKGH: Bool 0, Int 1, StringList 2), Value (String). The editor sends only the changed cell | FGCPNAACHIK full room details |
|
||||
| POST | `rooms/{0}/subrooms/{1}/publish_save` | form field: subRoomDataSaveId (Int64) | FGCPNAACHIK full room details |
|
||||
| GET | `rooms/{0}/subrooms/{1}/saves` | query params: skip (Int32), take (Int32) | paged object GJDIKIMLCHA: {"Results": [JKIFFPPAJNK {"SubRoomDataSaveId" Int64, "SubRoomId" Int64, "UnityAssetId" String, "DataBlob" String, "DataBlobHash" String, "Description" String, "SavedByAccountId" Int64, "SavedOnP |
|
||||
| PUT | `rooms/{0}/tags` | form fields: tag (repeated String), autoTag (repeated String) | FGCPNAACHIK full room details |
|
||||
@@ -948,6 +1025,8 @@ Handler: `DorkNet.Server/Controllers/API/Rooms/V2/RoomsModerationController.cs:4
|
||||
|
||||
**Fix.** Change ApplyAndReturn to return RoomsController.BuildRoomServerDetails (load scenes+roles like BuildRoomServerDetailsWithRolesAsync) instead of ToWireRoom — this one fix repairs the response of every bare-path room mutation.
|
||||
|
||||
**Fixed.** ApplyAndReturn returns the full details. This route is also the "Publish room" button; publishing a cloned room still failed because the clone was advertised as `IsRRO`, which hides and refuses Publish client-side. Player-owned rooms are now never RRO on the wire (see "Sub-room ids, IsRRO and the instance browser" above).
|
||||
|
||||
##### `PUT rooms/{0}/allow_new_users` — SHAPE_MISMATCH (degraded)
|
||||
|
||||
Twins + form key allowNewUsers OK; response is ToWireRoom (same ApplyAndReturn defect as accessibility).
|
||||
@@ -1036,29 +1115,33 @@ Handler: `DorkNet.Server/Controllers/API/Rooms/V2/RoomsModerationController.cs:8
|
||||
|
||||
**Fix.** Same ApplyAndReturn fix.
|
||||
|
||||
##### `GET rooms/{0}/roles` — MISSING (degraded)
|
||||
##### `GET rooms/{0}/roles`, `GET + PUT rooms/{0}/roles/{1}`, `PUT rooms/{0}/roles/{1}/invite` — FIXED
|
||||
|
||||
Handler + wire shape are correct ([{AccountId,Role,InvitedRole,LastChangedByAccountId}], BuildRoomAccountRoleWire :4003-4014) but there is no roomserver twin → the permissions screen's list fetch 404s under the 2023 prefix.
|
||||
Was: the roomserver/ twins were added earlier, but three defects remained.
|
||||
(1) The creator was listed as Role 30 (CoOwner). The client takes the local
|
||||
role from the Roles list (`FGCPNAACHIK.ONECLLALJEO`, first AccountId match)
|
||||
and the permission editor only allows roles strictly below your own, with
|
||||
Creator (255) the one unconditional pass (`OMJKHJLFOCO.DIIMLMKEJLC`,
|
||||
RecRoom.RoomPermissions.Runtime/OMJKHJLFOCO.txt:2367), so the creator could
|
||||
not edit Co-Owner or Creator permissions. (2) Only the creator or a co-owner
|
||||
could call either route, so an invitee could never accept (own id, role 30)
|
||||
or reject (`/invite` on own id, role 0), and re-inviting an accepted co-owner
|
||||
demoted them to pending. (3) Nobody was told: the invitee's prompt is a
|
||||
message, Type 62 RoomCoOwnerInvited with RoomId (KPFKPIKDBFJ, factory
|
||||
OCGKOJMFLAI.ALEHDHAEFOK), and none was sent.
|
||||
|
||||
Handler: `DorkNet.Server/Controllers/API/Rooms/V2/RoomsController.cs:3695`
|
||||
**Fixed.** Creator is 255 and listed once (`RoomRoleRules.BuildWires`); one
|
||||
row per account with accepted Role and pending InvitedRole side by side.
|
||||
`RoomRoleRules.CheckChange` enforces the ladder: the creator may set any role
|
||||
on anyone but themselves; otherwise the actor must be an accepted co-owner and
|
||||
both the target's current or invited role and the requested role must be
|
||||
strictly below theirs; anyone may drop their own role or decline their invite;
|
||||
an invitee may accept exactly the invited role. Messages 62 (invite), 60
|
||||
(added) and 61 (removed) go to the target, answered invites are marked read,
|
||||
and the full room goes out as `RoomUpdate` to everyone in the room plus the
|
||||
changed player.
|
||||
|
||||
**Fix.** Add [HttpGet("roomserver/rooms/{roomId:long}/roles")] (and twins for the two routes below).
|
||||
|
||||
##### `GET + PUT rooms/{0}/roles/{1}` — MISSING (degraded)
|
||||
|
||||
Handlers are contract-correct: GET returns a single role wire object; PUT reads form 'role' (ReadClientRoomRoleAsync :3944-3973) and returns full BuildRoomServerDetailsWithRolesAsync. But neither has a roomserver twin → 404 under the 2023 prefix.
|
||||
|
||||
Handler: `DorkNet.Server/Controllers/API/Rooms/V2/RoomsController.cs:3818 (GET), 3835 (PUT)`
|
||||
|
||||
**Fix.** Add roomserver twins for GET and PUT/POST.
|
||||
|
||||
##### `PUT rooms/{0}/roles/{1}/invite` — MISSING (degraded)
|
||||
|
||||
Handler correct (form role, Accepted=false row, full-details response); no roomserver twin → 404 under prefix.
|
||||
|
||||
Handler: `DorkNet.Server/Controllers/API/Rooms/V2/RoomsController.cs:3879`
|
||||
|
||||
**Fix.** Add roomserver twin.
|
||||
Handler: `DorkNet.Server/Controllers/API/Rooms/V2/RoomsController.cs` (`GrantRole`, `InviteRole`, `ChangeRoomAccountRoleAsync`), rules in `DorkNet.Server/Services/RoomRoleRules.cs`
|
||||
|
||||
##### `POST rooms/{0}/subrooms/{1}/clone` — SHAPE_MISMATCH (degraded)
|
||||
|
||||
@@ -1084,13 +1167,29 @@ Handler: `DorkNet.Server/Controllers/API/Rooms/V2/RoomsController.cs:2656`
|
||||
|
||||
**Fix.** Drop the [FromBody] parameter; return BuildRoomServerDetailsWithRolesAsync.
|
||||
|
||||
##### `PUT rooms/{0}/subrooms/{1}/permissions` — STUB (degraded)
|
||||
##### `PUT rooms/{0}/subrooms/{1}/permissions` + `GET photon_access_token` — FIXED
|
||||
|
||||
Twins exist but the handler accepts SubRoomModifyRequest (Name/MaxPlayers/IsSandbox/CanMatchmakeInto) — the client actually sends a serialized LIST of BNLPBMJJOMM permission entries {Role,Type,Permission,Override,Value}. No permission matrix is ever stored (there is no storage for it), and the client body would 415/no-op. Response SceneWire. The role-permission matrix silently never saves.
|
||||
Was: the PUT accepted the matrix and dropped it (no storage), and
|
||||
`photon_access_token` always answered a hardcoded seed, so a permission
|
||||
change never survived a reload and nobody else in the room saw it.
|
||||
|
||||
Handler: `DorkNet.Server/Controllers/API/Rooms/V2/RoomsController.cs:3391`
|
||||
**Fixed.** Cells persist in `RoomRolePermissions` (one row per room, sub-room
|
||||
OrderIndex, role, permission; upsert, cells not sent are untouched).
|
||||
`photon_access_token` (`ALIIIAGAPND {RoomInstanceId, PhotonAccessToken,
|
||||
Permissions}`, now also under `roomserver/`) serves the defaults with the
|
||||
caller's current sub-room cells applied on top, and every save pushes the
|
||||
same body as the `PhotonAccessToken` hub message (registered as
|
||||
`Action<ALIIIAGAPND>`, IBEOONPEELF.txt:519) to everyone in that sub-room. Writes
|
||||
follow the client's own rule: the creator may edit every role row, a co-owner
|
||||
only rows below 30, nobody else may write. Role and Type are accepted as
|
||||
numbers, numeric strings or enum names.
|
||||
|
||||
**Fix.** Parse the request as a list of {Role,Type,Permission,Override,Value}; persist per-subroom permission rows (new table); echo full room details. Scope explicitly if deferred — currently it is a silent stub.
|
||||
Unproven: the client's permission-id spelling. The defaults keep the numeric
|
||||
ids the server has always sent ("13" maker pen, "20"–"23"); RecFlare sends
|
||||
names such as `CAN_USE_MAKER_PEN`. Stored cells are echoed exactly as the
|
||||
client wrote them, so the client's own edits round-trip either way.
|
||||
|
||||
Handler: `RoomsController.SubRoomPermissions`, `MatchController.PhotonAccessToken`, `DorkNet.Server/Services/RoomPermissionMatrix.cs`
|
||||
|
||||
##### `POST rooms/{0}/subrooms/{1}/publish_save` — SHAPE_MISMATCH (degraded)
|
||||
|
||||
|
||||
@@ -69,7 +69,7 @@ today.
|
||||
| Key | Msg | Sent by |
|
||||
|---|---|---|
|
||||
| `1` RelationshipChanged | `{PlayerID, RelationshipType, Muted, Ignored, Favorited}`: the recipient's own view, the same row `api/relationships/v2/get` lists | friend request / accept / remove / add / add-with-code (both players); favorite / mute / ignore and their reverses (caller only) |
|
||||
| `2` MessageReceived | `{Id, FromPlayerId (Int32), SentTime, Type, Data (string), RoomId, PlayerEventId}`, the same row `api/messages/v2/get` lists | DMs and game invites; `SystemNotificationService` (cheers, friend request Type 4, accepted Type 40, "wants to join" Type 10, coach message Type 100, event invite text) |
|
||||
| `2` MessageReceived | `{Id, FromPlayerId (Int32), SentTime, Type, Data (string), RoomId, PlayerEventId}`, the same row `api/messages/v2/get` lists | DMs and game invites; `SystemNotificationService` (cheers, friend request Type 4, accepted Type 40, "wants to join" Type 10, coach message Type 100, event invite text, room co-owner Type 62 invited / 60 added / 61 removed with `RoomId` set — the client's co-owner slots read only RoomId) |
|
||||
| `3` MessageDeleted | `{Id}` | not sent |
|
||||
| `5` RefreshLogin | none | not sent |
|
||||
| `6` Logout | `{Reason}`: a **number**, enum Unknown 0, UserInitiated 1, SessionTakeover 2, ForciblyLoggedOut 3, Banned 4 | `NotificationService.LogoutAsync`: second-session takeover (2), account deleted or password reset (3), bans (4) |
|
||||
@@ -89,12 +89,13 @@ today.
|
||||
| `AccountUpdate` / `SelfAccountUpdate` | the public / self account DTOs the account REST routes return | every profile edit, including profile image (`NotificationService.AccountChanged`) |
|
||||
| `PresenceUpdate` | `{playerId, statusVisibility, deviceClass, vrMovementMode, roomInstance, isOnline, appVersion (string)}` | `PresenceBroadcastService`, to friends and subscribers, **never to the subject** (a frame about the local player moves them) |
|
||||
| `RoomInstanceUpdate` | the room instance (`RoomInstanceDto`) | admin force-join / instance pull. It only refreshes the cached instance; the move itself comes from the next heartbeat. |
|
||||
| `RoomUpdate` | the **full room** (`FGCPNAACHIK`, `RoomsController.BuildRoomServerDetails`), not the 2020 `{Room, Scenes}` wrapper | admin role / owner changes (everyone in the room plus the changed player), admin force-join |
|
||||
| `RoomUpdate` | the **full room** (`FGCPNAACHIK`, `RoomsController.BuildRoomServerDetails`), not the 2020 `{Room, Scenes}` wrapper | admin role / owner changes and every in-game role change, invite, accept, reject or removal (everyone in the room plus the changed player and the actor), admin force-join |
|
||||
| `PhotonAccessToken` | `ALIIIAGAPND {RoomInstanceId, PhotonAccessToken (""), Permissions: [{Permission, Role, Override, Type, Value}]}`, the same body `GET photon_access_token` returns (handler IBEOONPEELF.txt:519, `Action<ALIIIAGAPND>`) | every `PUT rooms/{id}/subrooms/{sub}/permissions`, to each player in that sub-room with their own RoomInstanceId |
|
||||
| `ChatMessageReceived` | `{chatMessageId, chatThreadId, senderPlayerId, timeSent, contents}` | chat sends, to every member including the sender |
|
||||
| `PlayerLeftChat` | the same chat-message shape; only `chatThreadId` and `senderPlayerId` (the leaver) are read | leaving a group chat |
|
||||
| `PlayerProgressionLevelUpdate` | `{PlayerId, Level, XP}`, as `api/players/v2/progression` returns it | every XP award (`LevelService.AwardXpAsync`) |
|
||||
| `CommunityBoardUpdate` | community board | community board edits |
|
||||
| `ReputationUpdate`, `RoomCurrency*`, `KeepsakeInstance*`, `PhotonAccessToken`, `GoToFailure`, `AnnouncementUpdate/Delete`, `ClubMembershipUpdate`, `GameConfig.Refresh`, `PlayerSettings.Refresh`, … | see the contract notes in `recroom-2023-client-api-complete.md` | not sent |
|
||||
| `ReputationUpdate`, `RoomCurrency*`, `KeepsakeInstance*`, `GoToFailure`, `AnnouncementUpdate/Delete`, `ClubMembershipUpdate`, `GameConfig.Refresh`, `PlayerSettings.Refresh`, … | see the contract notes in `recroom-2023-client-api-complete.md` | not sent |
|
||||
|
||||
**Not registered at all** (a push with these keys is dropped): numeric 4, 11,
|
||||
12, 13, 15, 21, 24 (ModerationRoomBan), 40, 85, 90, 95.
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
<PropertyGroup>
|
||||
<OutputType>Exe</OutputType>
|
||||
<TargetFramework>net9.0</TargetFramework>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
</PropertyGroup>
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
<PropertyGroup>
|
||||
<OutputType>Exe</OutputType>
|
||||
<TargetFramework>net9.0</TargetFramework>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
</PropertyGroup>
|
||||
|
||||
Reference in New Issue
Block a user