mirror of
https://github.com/DorkSquadRR/DorkNet.git
synced 2026-10-25 23:18:44 -07:00
Compare commits
15 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 96628baebe | |||
| df4842ba55 | |||
| 8cb0db87f7 | |||
| 8bb73dcdb1 | |||
| 995c3a90dd | |||
| 37be87ed85 | |||
| e9d13069e2 | |||
| 78669ee11b | |||
| c0e7254aa1 | |||
| baa17c0fd6 | |||
| 307ce93646 | |||
| ecaf17b764 | |||
| 4985bb09d4 | |||
| a5b00980a5 | |||
| 32cd60792f |
@@ -11,6 +11,9 @@ on:
|
||||
tags:
|
||||
- 'v1-march-*'
|
||||
|
||||
permissions:
|
||||
contents: write # create the GitHub release + upload assets
|
||||
|
||||
jobs:
|
||||
release:
|
||||
runs-on: windows-latest
|
||||
|
||||
+1
-1
@@ -22,7 +22,7 @@ tmp/
|
||||
## OS
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
.backups
|
||||
## Reverse-engineered artifacts (Il2CppDumper / Cpp2IL output, not for commit)
|
||||
dump.cs
|
||||
il2cpp.h
|
||||
|
||||
+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=20
|
||||
|
||||
# ── 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,12 +1,13 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net6.0</TargetFramework>
|
||||
<TargetFramework Condition="'$(TargetFramework)' == '' and '$(TargetFrameworks)' == ''">net6.0</TargetFramework>
|
||||
<LangVersion>latest</LangVersion>
|
||||
<Nullable>enable</Nullable>
|
||||
<RootNamespace>DorkNet.ClientMod</RootNamespace>
|
||||
<AssemblyName>DorkNet.ClientMod</AssemblyName>
|
||||
<AppendTargetFrameworkToOutputPath>false</AppendTargetFrameworkToOutputPath>
|
||||
<AppendTargetFrameworkToOutputPath Condition="'$(TargetFrameworks)' == ''">false</AppendTargetFrameworkToOutputPath>
|
||||
<AppendTargetFrameworkToOutputPath Condition="'$(TargetFrameworks)' != ''">true</AppendTargetFrameworkToOutputPath>
|
||||
<CopyLocalLockFileAssemblies>false</CopyLocalLockFileAssemblies>
|
||||
<NoWarn>$(NoWarn);CS8602;CS8604;CS0436</NoWarn>
|
||||
</PropertyGroup>
|
||||
@@ -19,21 +20,28 @@
|
||||
they would be derivative works of the game anyway, so we never
|
||||
reference them. Only MelonLoader's own DLLs are needed.
|
||||
|
||||
By default the project downloads the pinned MelonLoader release
|
||||
into DorkNet.ClientMod/build/ and references those DLLs. Pass
|
||||
-p:MelonLoaderDir=<path-to-extracted-MelonLoader> only when you
|
||||
deliberately want to use a local override.
|
||||
By default the project downloads the pinned desktop MelonLoader release
|
||||
into DorkNet.ClientMod/build/ and references its net6 DLLs. For Quest /
|
||||
LemonLoader builds, pass:
|
||||
|
||||
-p:TargetFrameworks=net8.0
|
||||
-p:MelonLoaderDir=<path-to-LemonLoader-melon_data/MelonLoader>
|
||||
|
||||
The reference folder follows the target framework unless explicitly
|
||||
overridden with -p:MelonLoaderReferenceTfm=...
|
||||
-->
|
||||
<PropertyGroup>
|
||||
<MelonLoaderVersion Condition="'$(MelonLoaderVersion)' == ''">v0.6.6</MelonLoaderVersion>
|
||||
<MelonLoaderCacheRoot>$(MSBuildThisFileDirectory)build\melonloader</MelonLoaderCacheRoot>
|
||||
<MelonLoaderZip>$(MelonLoaderCacheRoot)\MelonLoader.x64.$(MelonLoaderVersion).zip</MelonLoaderZip>
|
||||
<MelonLoaderDir Condition="'$(MelonLoaderDir)' == ''">$(MelonLoaderCacheRoot)\MelonLoader</MelonLoaderDir>
|
||||
<MelonLoaderReferenceTfm Condition="'$(MelonLoaderReferenceTfm)' == '' and '$(TargetFramework)' == 'net8.0'">net8</MelonLoaderReferenceTfm>
|
||||
<MelonLoaderReferenceTfm Condition="'$(MelonLoaderReferenceTfm)' == ''">net6</MelonLoaderReferenceTfm>
|
||||
</PropertyGroup>
|
||||
|
||||
<Target Name="EnsureMelonLoaderReferences"
|
||||
BeforeTargets="ResolveAssemblyReferences"
|
||||
Condition="!Exists('$(MelonLoaderDir)\net6\MelonLoader.dll')">
|
||||
Condition="'$(MelonLoaderReferenceTfm)' == 'net6' and !Exists('$(MelonLoaderDir)\$(MelonLoaderReferenceTfm)\MelonLoader.dll')">
|
||||
<MakeDir Directories="$(MelonLoaderCacheRoot)" />
|
||||
<DownloadFile
|
||||
SourceUrl="https://github.com/LavaGang/MelonLoader/releases/download/$(MelonLoaderVersion)/MelonLoader.x64.zip"
|
||||
@@ -47,19 +55,19 @@
|
||||
|
||||
<ItemGroup>
|
||||
<Reference Include="MelonLoader">
|
||||
<HintPath>$(MelonLoaderDir)\net6\MelonLoader.dll</HintPath>
|
||||
<HintPath>$(MelonLoaderDir)\$(MelonLoaderReferenceTfm)\MelonLoader.dll</HintPath>
|
||||
<Private>false</Private>
|
||||
</Reference>
|
||||
<Reference Include="0Harmony">
|
||||
<HintPath>$(MelonLoaderDir)\net6\0Harmony.dll</HintPath>
|
||||
<HintPath>$(MelonLoaderDir)\$(MelonLoaderReferenceTfm)\0Harmony.dll</HintPath>
|
||||
<Private>false</Private>
|
||||
</Reference>
|
||||
<Reference Include="Il2CppInterop.Runtime">
|
||||
<HintPath>$(MelonLoaderDir)\net6\Il2CppInterop.Runtime.dll</HintPath>
|
||||
<HintPath>$(MelonLoaderDir)\$(MelonLoaderReferenceTfm)\Il2CppInterop.Runtime.dll</HintPath>
|
||||
<Private>false</Private>
|
||||
</Reference>
|
||||
<Reference Include="Il2CppInterop.Common">
|
||||
<HintPath>$(MelonLoaderDir)\net6\Il2CppInterop.Common.dll</HintPath>
|
||||
<HintPath>$(MelonLoaderDir)\$(MelonLoaderReferenceTfm)\Il2CppInterop.Common.dll</HintPath>
|
||||
<Private>false</Private>
|
||||
</Reference>
|
||||
</ItemGroup>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net9.0</TargetFramework>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
</PropertyGroup>
|
||||
|
||||
@@ -0,0 +1,590 @@
|
||||
using System.IdentityModel.Tokens.Jwt;
|
||||
using System.Security.Claims;
|
||||
using System.Security.Cryptography;
|
||||
using System.Security.Cryptography.X509Certificates;
|
||||
using System.Text;
|
||||
using Open.IdentityServer;
|
||||
using Open.IdentityServer.Models;
|
||||
using Open.IdentityServer.Services;
|
||||
using Open.IdentityServer.Validation;
|
||||
using Microsoft.AspNetCore.Http.Features;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Primitives;
|
||||
using Microsoft.IdentityModel.Tokens;
|
||||
using DorkNet.Server.Data;
|
||||
using DorkNet.Server.Data.Entities;
|
||||
using DorkNet.Server.Services;
|
||||
|
||||
namespace DorkNet.Server.Auth;
|
||||
|
||||
public sealed class IdentityServerSigningKeyProvider
|
||||
{
|
||||
private readonly string _legacySecret;
|
||||
|
||||
public IdentityServerSigningKeyProvider(IConfiguration config)
|
||||
{
|
||||
_legacySecret =
|
||||
Environment.GetEnvironmentVariable("DORKNET_JWT_SECRET")
|
||||
?? Environment.GetEnvironmentVariable("RECNET_JWT_SECRET")
|
||||
?? config["Jwt:Secret"]
|
||||
?? throw new InvalidOperationException(
|
||||
"JWT secret not configured. Set DORKNET_JWT_SECRET env var or Jwt:Secret in appsettings.Local.json.");
|
||||
|
||||
CertificatePath = ResolveCertificatePath(config);
|
||||
var password = config["IdentityServer:SigningCertificatePassword"] ?? _legacySecret;
|
||||
var certificateBase64 = config["IdentityServer:SigningCertificateBase64"];
|
||||
if (!string.IsNullOrWhiteSpace(certificateBase64))
|
||||
{
|
||||
LoadedFromBase64 = true;
|
||||
Certificate = LoadCertificateFromBase64(certificateBase64, password);
|
||||
}
|
||||
else
|
||||
{
|
||||
Certificate = LoadOrCreateCertificate(CertificatePath, password);
|
||||
}
|
||||
SigningKey = new X509SecurityKey(Certificate);
|
||||
LegacyJwtKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(_legacySecret));
|
||||
}
|
||||
|
||||
public string CertificatePath { get; }
|
||||
public bool LoadedFromBase64 { get; }
|
||||
public string CertificateSource => LoadedFromBase64
|
||||
? "IdentityServer:SigningCertificateBase64"
|
||||
: CertificatePath;
|
||||
public X509Certificate2 Certificate { get; }
|
||||
public SecurityKey SigningKey { get; }
|
||||
public SecurityKey LegacyJwtKey { get; }
|
||||
public SecurityKey[] ValidationKeys => new[] { SigningKey, LegacyJwtKey };
|
||||
|
||||
private static string ResolveCertificatePath(IConfiguration config)
|
||||
{
|
||||
var path = config["IdentityServer:SigningCertificatePath"];
|
||||
if (string.IsNullOrWhiteSpace(path))
|
||||
path = Path.Combine(AppContext.BaseDirectory, "data", "identityserver-signing.pfx");
|
||||
return path;
|
||||
}
|
||||
|
||||
private static X509Certificate2 LoadCertificateFromBase64(string certificateBase64, string password)
|
||||
{
|
||||
var flags = X509KeyStorageFlags.UserKeySet | X509KeyStorageFlags.Exportable;
|
||||
var compact = NormalizeCertificateBase64(certificateBase64);
|
||||
byte[] bytes;
|
||||
try
|
||||
{
|
||||
bytes = Convert.FromBase64String(compact);
|
||||
}
|
||||
catch (FormatException ex)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
"IdentityServer signing certificate base64 is invalid. " +
|
||||
"Set IdentityServer__SigningCertificateBase64 to the PFX bytes encoded as base64, " +
|
||||
"without a file path or shell assignment text.",
|
||||
ex);
|
||||
}
|
||||
try
|
||||
{
|
||||
return new X509Certificate2(bytes, password, flags);
|
||||
}
|
||||
catch (CryptographicException ex)
|
||||
{
|
||||
if (TryLoadPemCertificate(bytes, password, flags, out var certificate))
|
||||
return certificate;
|
||||
|
||||
throw new InvalidOperationException(
|
||||
"IdentityServer signing certificate base64 decoded successfully, but it is not a valid PFX/PKCS#12 certificate. " +
|
||||
"Set IdentityServer__SigningCertificateBase64 to the PFX bytes encoded as base64 and make sure " +
|
||||
"IdentityServer__SigningCertificatePassword matches the PFX password.",
|
||||
ex);
|
||||
}
|
||||
}
|
||||
|
||||
private static bool TryLoadPemCertificate(
|
||||
byte[] bytes,
|
||||
string password,
|
||||
X509KeyStorageFlags flags,
|
||||
out X509Certificate2 certificate)
|
||||
{
|
||||
certificate = null!;
|
||||
|
||||
string pem;
|
||||
try
|
||||
{
|
||||
pem = Encoding.UTF8.GetString(bytes);
|
||||
}
|
||||
catch (DecoderFallbackException)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!pem.Contains("-----BEGIN", StringComparison.Ordinal))
|
||||
return false;
|
||||
|
||||
try
|
||||
{
|
||||
using var pemCertificate = X509Certificate2.CreateFromPem(pem, pem);
|
||||
if (!pemCertificate.HasPrivateKey)
|
||||
return false;
|
||||
|
||||
var pfxBytes = pemCertificate.Export(X509ContentType.Pfx, password);
|
||||
certificate = new X509Certificate2(pfxBytes, password, flags);
|
||||
return true;
|
||||
}
|
||||
catch (CryptographicException)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private static string NormalizeCertificateBase64(string value)
|
||||
{
|
||||
var text = value.Trim().Trim('"', '\'');
|
||||
var equals = text.IndexOf('=');
|
||||
if (equals > 0)
|
||||
{
|
||||
var key = text[..equals].Trim();
|
||||
if (key.Equals("IdentityServer__SigningCertificateBase64", StringComparison.OrdinalIgnoreCase) ||
|
||||
key.Equals("IdentityServer:SigningCertificateBase64", StringComparison.OrdinalIgnoreCase))
|
||||
text = text[(equals + 1)..].Trim().Trim('"', '\'');
|
||||
}
|
||||
|
||||
if (text.StartsWith("data:", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
var comma = text.IndexOf(',');
|
||||
if (comma >= 0)
|
||||
text = text[(comma + 1)..];
|
||||
}
|
||||
|
||||
if (text.StartsWith("base64:", StringComparison.OrdinalIgnoreCase))
|
||||
text = text["base64:".Length..];
|
||||
|
||||
var compact = new string(text.Where(c => !char.IsWhiteSpace(c)).ToArray())
|
||||
.Replace('-', '+')
|
||||
.Replace('_', '/');
|
||||
return (compact.Length % 4) switch
|
||||
{
|
||||
2 => compact + "==",
|
||||
3 => compact + "=",
|
||||
_ => compact,
|
||||
};
|
||||
}
|
||||
|
||||
private static X509Certificate2 LoadOrCreateCertificate(string path, string password)
|
||||
{
|
||||
var flags = X509KeyStorageFlags.UserKeySet | X509KeyStorageFlags.Exportable;
|
||||
Directory.CreateDirectory(Path.GetDirectoryName(path)!);
|
||||
|
||||
if (File.Exists(path))
|
||||
return new X509Certificate2(path, password, flags);
|
||||
|
||||
using var rsa = RSA.Create(2048);
|
||||
var request = new CertificateRequest(
|
||||
"CN=DorkNet IdentityServer Signing",
|
||||
rsa,
|
||||
HashAlgorithmName.SHA256,
|
||||
RSASignaturePadding.Pkcs1);
|
||||
request.CertificateExtensions.Add(
|
||||
new X509KeyUsageExtension(X509KeyUsageFlags.DigitalSignature, critical: false));
|
||||
|
||||
using var created = request.CreateSelfSigned(
|
||||
DateTimeOffset.UtcNow.AddDays(-1),
|
||||
DateTimeOffset.UtcNow.AddYears(10));
|
||||
var bytes = created.Export(X509ContentType.Pfx, password);
|
||||
File.WriteAllBytes(path, bytes);
|
||||
return new X509Certificate2(bytes, password, flags);
|
||||
}
|
||||
}
|
||||
|
||||
public static class RecRoomIdentityServerRegistration
|
||||
{
|
||||
public static void AddRecRoomIdentityServer(
|
||||
this IServiceCollection services,
|
||||
IConfiguration config,
|
||||
DomainConfig domain,
|
||||
IdentityServerSigningKeyProvider signingKeyProvider)
|
||||
{
|
||||
services
|
||||
.AddIdentityServer(options =>
|
||||
{
|
||||
options.IssuerUri = domain.AuthIssuer.TrimEnd('/');
|
||||
})
|
||||
.AddInMemoryIdentityResources(RecRoomIdentityServerConfig.IdentityResources)
|
||||
.AddInMemoryApiScopes(RecRoomIdentityServerConfig.ApiScopes)
|
||||
.AddInMemoryClients(RecRoomIdentityServerConfig.Clients)
|
||||
.AddSigningCredential(signingKeyProvider.SigningKey, SecurityAlgorithms.RsaSha256)
|
||||
.AddResourceOwnerValidator<RecRoomPasswordValidator>()
|
||||
.AddExtensionGrantValidator<CachedLoginGrantValidator>()
|
||||
.AddProfileService<RecRoomProfileService>();
|
||||
}
|
||||
}
|
||||
|
||||
internal static class RecRoomIdentityServerConfig
|
||||
{
|
||||
public const string ClientId = "recroom";
|
||||
public const string ClientSecret = "VxZ53kgbbEaRoZAeMe00MagtgD12GLL2";
|
||||
public const string GameClientScope = "gameClient";
|
||||
public const string CachedLoginGrantType = "cached_login";
|
||||
|
||||
public static readonly IdentityResource[] IdentityResources =
|
||||
{
|
||||
new IdentityResources.OpenId(),
|
||||
new IdentityResources.Profile(),
|
||||
};
|
||||
|
||||
public static readonly ApiScope[] ApiScopes =
|
||||
{
|
||||
new(GameClientScope, "Game client")
|
||||
{
|
||||
UserClaims = RecRoomIdentityClaims.UserClaimTypes,
|
||||
},
|
||||
new("api", "DorkNet API")
|
||||
{
|
||||
UserClaims = RecRoomIdentityClaims.UserClaimTypes,
|
||||
},
|
||||
};
|
||||
|
||||
public static readonly Client[] Clients =
|
||||
{
|
||||
new()
|
||||
{
|
||||
ClientId = ClientId,
|
||||
ClientName = "Rec Room game client",
|
||||
RequireClientSecret = true,
|
||||
ClientSecrets = { new Secret(ClientSecret.Sha256()) },
|
||||
AllowedGrantTypes = { GrantType.ResourceOwnerPassword, "refresh_token", CachedLoginGrantType },
|
||||
AllowedScopes = RecRoomScopes(),
|
||||
AllowOfflineAccess = true,
|
||||
AccessTokenLifetime = 60 * 60 * 12,
|
||||
AbsoluteRefreshTokenLifetime = 60 * 60 * 24 * 30,
|
||||
SlidingRefreshTokenLifetime = 60 * 60 * 24 * 30,
|
||||
RefreshTokenUsage = TokenUsage.ReUse,
|
||||
RefreshTokenExpiration = TokenExpiration.Absolute,
|
||||
UpdateAccessTokenClaimsOnRefresh = true,
|
||||
AlwaysIncludeUserClaimsInIdToken = true,
|
||||
},
|
||||
};
|
||||
|
||||
private static ICollection<string> RecRoomScopes() =>
|
||||
[
|
||||
IdentityServerConstants.StandardScopes.OpenId,
|
||||
IdentityServerConstants.StandardScopes.Profile,
|
||||
IdentityServerConstants.StandardScopes.OfflineAccess,
|
||||
GameClientScope,
|
||||
"api",
|
||||
];
|
||||
}
|
||||
|
||||
public sealed class IdentityServerGameTokenRequestMiddleware(RequestDelegate next)
|
||||
{
|
||||
public async Task InvokeAsync(HttpContext context)
|
||||
{
|
||||
if (!IsTokenRequest(context))
|
||||
{
|
||||
await next(context);
|
||||
return;
|
||||
}
|
||||
|
||||
var form = await context.Request.ReadFormAsync();
|
||||
var values = form.ToDictionary(
|
||||
kvp => kvp.Key,
|
||||
kvp => kvp.Value,
|
||||
StringComparer.OrdinalIgnoreCase);
|
||||
var changed = false;
|
||||
|
||||
if (IsDevicePasswordGrant(values))
|
||||
{
|
||||
if (!values.ContainsKey("username") || string.IsNullOrWhiteSpace(values["username"].ToString()))
|
||||
{
|
||||
values["username"] = First(
|
||||
values.TryGetValue("deviceId", out var deviceId) ? deviceId.ToString() : null,
|
||||
values.TryGetValue("device_id", out var deviceIdSnake) ? deviceIdSnake.ToString() : null,
|
||||
values.TryGetValue("platformId", out var platformId) ? platformId.ToString() : null,
|
||||
values.TryGetValue("platform_id", out var platformIdSnake) ? platformIdSnake.ToString() : null,
|
||||
"anonymous");
|
||||
changed = true;
|
||||
}
|
||||
|
||||
if (!values.ContainsKey("password") || string.IsNullOrWhiteSpace(values["password"].ToString()))
|
||||
{
|
||||
values["password"] = "__dorknet_device_login__";
|
||||
values["dorknet_device_login"] = "true";
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
|
||||
var scopes = values.TryGetValue("scope", out var rawScope)
|
||||
? rawScope.ToString()
|
||||
: string.Empty;
|
||||
var mergedScopes = MergeScopes(scopes);
|
||||
if (!string.Equals(scopes, mergedScopes, StringComparison.Ordinal))
|
||||
{
|
||||
values["scope"] = mergedScopes;
|
||||
changed = true;
|
||||
}
|
||||
|
||||
if (changed)
|
||||
context.Features.Set<IFormFeature>(new FormFeature(new FormCollection(values)));
|
||||
|
||||
await next(context);
|
||||
}
|
||||
|
||||
private static bool IsTokenRequest(HttpContext context) =>
|
||||
HttpMethods.IsPost(context.Request.Method)
|
||||
&& context.Request.Path.Equals("/connect/token", StringComparison.OrdinalIgnoreCase)
|
||||
&& context.Request.HasFormContentType;
|
||||
|
||||
private static bool IsDevicePasswordGrant(Dictionary<string, StringValues> values)
|
||||
{
|
||||
if (!values.TryGetValue("grant_type", out var grantType) ||
|
||||
!string.Equals(grantType.ToString(), GrantType.ResourceOwnerPassword, StringComparison.OrdinalIgnoreCase))
|
||||
return false;
|
||||
return !values.TryGetValue("password", out var password) ||
|
||||
string.IsNullOrWhiteSpace(password.ToString());
|
||||
}
|
||||
|
||||
private static string First(params string?[] values) =>
|
||||
values.FirstOrDefault(v => !string.IsNullOrWhiteSpace(v)) ?? "anonymous";
|
||||
|
||||
private static string MergeScopes(string existing)
|
||||
{
|
||||
var scopes = existing
|
||||
.Split(' ', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
|
||||
.ToHashSet(StringComparer.OrdinalIgnoreCase);
|
||||
scopes.Add(IdentityServerConstants.StandardScopes.OpenId);
|
||||
scopes.Add(IdentityServerConstants.StandardScopes.Profile);
|
||||
scopes.Add(IdentityServerConstants.StandardScopes.OfflineAccess);
|
||||
scopes.Add(RecRoomIdentityServerConfig.GameClientScope);
|
||||
return string.Join(' ', scopes);
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class RecRoomPasswordValidator(
|
||||
PlayerService playerService,
|
||||
LevelService level,
|
||||
ServerSettingsService settings,
|
||||
DorkNetDbContext db,
|
||||
ILogger<RecRoomPasswordValidator> logger) : IResourceOwnerPasswordValidator
|
||||
{
|
||||
public async Task ValidateAsync(ResourceOwnerPasswordValidationContext context)
|
||||
{
|
||||
var raw = context.Request.Raw;
|
||||
var deviceId = First(raw.Get("deviceId"), raw.Get("device_id"));
|
||||
var platformId = First(raw.Get("platformId"), raw.Get("platform_id"));
|
||||
var platform = int.TryParse(raw.Get("platform"), out var p) ? p : 0;
|
||||
var deviceLogin = string.Equals(raw.Get("dorknet_device_login"), "true", StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
if (!deviceLogin && !string.IsNullOrEmpty(context.Password) && !string.IsNullOrEmpty(context.UserName))
|
||||
{
|
||||
var byName = await playerService.GetByUsernameAsync(context.UserName);
|
||||
if (byName is null || byName.PasswordHash is null)
|
||||
{
|
||||
context.Result = new GrantValidationResult(
|
||||
TokenRequestErrors.InvalidGrant,
|
||||
"unknown_user_or_no_password");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!BCrypt.Net.BCrypt.Verify(context.Password, byName.PasswordHash))
|
||||
{
|
||||
context.Result = new GrantValidationResult(
|
||||
TokenRequestErrors.InvalidGrant,
|
||||
"password_mismatch");
|
||||
return;
|
||||
}
|
||||
|
||||
await playerService.TagPlatformAsync(byName.Id, platform, platformId);
|
||||
await TryAwardLoginXpAsync(byName.Id);
|
||||
context.Result = await CreateSuccessAsync(byName, "password");
|
||||
return;
|
||||
}
|
||||
|
||||
var effectiveDeviceId = !string.IsNullOrWhiteSpace(deviceId)
|
||||
? deviceId
|
||||
: $"legacy-{(context.UserName ?? "anonymous").ToLowerInvariant()}";
|
||||
|
||||
if (await settings.AreSignupsDisabledAsync())
|
||||
{
|
||||
var existing = await playerService.GetByDeviceAsync(effectiveDeviceId);
|
||||
if (existing is null)
|
||||
{
|
||||
logger.LogInformation(
|
||||
"[auth] IdentityServer password grant device fallback refused - signups disabled (device={Device})",
|
||||
effectiveDeviceId);
|
||||
context.Result = new GrantValidationResult(
|
||||
TokenRequestErrors.InvalidGrant,
|
||||
"signups_disabled");
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
var player = await playerService.GetOrCreateByDeviceAsync(
|
||||
deviceId: effectiveDeviceId,
|
||||
platform: platform,
|
||||
platformId: platformId,
|
||||
displayName: context.UserName);
|
||||
await TryAwardLoginXpAsync(player.Id);
|
||||
context.Result = await CreateSuccessAsync(player, "device_password");
|
||||
}
|
||||
|
||||
private async Task<GrantValidationResult> CreateSuccessAsync(PlayerEntity player, string authMethod) =>
|
||||
new(
|
||||
subject: player.Id.ToString(),
|
||||
authenticationMethod: authMethod,
|
||||
claims: await RecRoomIdentityClaims.CreateAsync(db, player.Id, player.Username));
|
||||
|
||||
private async Task TryAwardLoginXpAsync(long playerId)
|
||||
{
|
||||
var lastSeen = await db.Players
|
||||
.Where(p => p.Id == playerId)
|
||||
.Select(p => p.LastSeenAt)
|
||||
.FirstOrDefaultAsync();
|
||||
if (lastSeen.Date < DateTime.UtcNow.Date)
|
||||
await level.AwardXpAsync(playerId, LevelService.FirstLoginXp, "first_login_today");
|
||||
}
|
||||
|
||||
private static string? First(params string?[] values) =>
|
||||
values.FirstOrDefault(v => !string.IsNullOrWhiteSpace(v));
|
||||
}
|
||||
|
||||
public sealed class CachedLoginGrantValidator(
|
||||
PlayerService playerService,
|
||||
LevelService level,
|
||||
OrphanAccountTracker orphans,
|
||||
DorkNetDbContext db,
|
||||
ILogger<CachedLoginGrantValidator> logger) : IExtensionGrantValidator
|
||||
{
|
||||
public string GrantType => RecRoomIdentityServerConfig.CachedLoginGrantType;
|
||||
|
||||
public async Task ValidateAsync(ExtensionGrantValidationContext context)
|
||||
{
|
||||
var raw = context.Request.Raw;
|
||||
if (!long.TryParse(raw.Get("account_id"), out var pickedId))
|
||||
{
|
||||
context.Result = new GrantValidationResult(
|
||||
TokenRequestErrors.InvalidGrant,
|
||||
"missing account_id");
|
||||
return;
|
||||
}
|
||||
|
||||
var picked = await playerService.GetByIdAsync(pickedId);
|
||||
if (picked is null)
|
||||
{
|
||||
context.Result = new GrantValidationResult(
|
||||
TokenRequestErrors.InvalidGrant,
|
||||
"unknown account_id");
|
||||
return;
|
||||
}
|
||||
|
||||
var deviceId = First(raw.Get("deviceId"), raw.Get("device_id"));
|
||||
var platformId = First(raw.Get("platformId"), raw.Get("platform_id"));
|
||||
var platform = int.TryParse(raw.Get("platform"), out var p) ? p : 0;
|
||||
|
||||
var pendingId = orphans.PeekPending(deviceId, platformId);
|
||||
if (pendingId is long orphanId && orphanId != pickedId)
|
||||
{
|
||||
await playerService.DeleteOrphanAsync(orphanId);
|
||||
logger.LogInformation(
|
||||
"[orphan-cleanup] cached_login picked {PickedId}; deleted just-created {OrphanId}",
|
||||
pickedId,
|
||||
orphanId);
|
||||
}
|
||||
orphans.Clear(deviceId, platformId);
|
||||
|
||||
await TryAwardLoginXpAsync(picked.Id);
|
||||
await playerService.TagPlatformAsync(picked.Id, platform, platformId);
|
||||
context.Result = new GrantValidationResult(
|
||||
subject: picked.Id.ToString(),
|
||||
authenticationMethod: GrantType,
|
||||
claims: await RecRoomIdentityClaims.CreateAsync(db, picked.Id, picked.Username));
|
||||
}
|
||||
|
||||
private async Task TryAwardLoginXpAsync(long playerId)
|
||||
{
|
||||
var lastSeen = await db.Players
|
||||
.Where(p => p.Id == playerId)
|
||||
.Select(p => p.LastSeenAt)
|
||||
.FirstOrDefaultAsync();
|
||||
if (lastSeen.Date < DateTime.UtcNow.Date)
|
||||
await level.AwardXpAsync(playerId, LevelService.FirstLoginXp, "first_login_today");
|
||||
}
|
||||
|
||||
private static string? First(params string?[] values) =>
|
||||
values.FirstOrDefault(v => !string.IsNullOrWhiteSpace(v));
|
||||
}
|
||||
|
||||
public sealed class RecRoomProfileService(PlayerService playerService, DorkNetDbContext db) : IProfileService
|
||||
{
|
||||
public async Task GetProfileDataAsync(ProfileDataRequestContext context)
|
||||
{
|
||||
if (!TryGetSubjectId(context.Subject, out var playerId))
|
||||
return;
|
||||
|
||||
var player = await playerService.GetByIdAsync(playerId);
|
||||
if (player is null)
|
||||
return;
|
||||
|
||||
context.IssuedClaims.AddRange(
|
||||
await RecRoomIdentityClaims.CreateAsync(db, player.Id, player.Username));
|
||||
}
|
||||
|
||||
public async Task IsActiveAsync(IsActiveContext context)
|
||||
{
|
||||
context.IsActive =
|
||||
TryGetSubjectId(context.Subject, out var playerId)
|
||||
&& await playerService.GetByIdAsync(playerId) is not null;
|
||||
}
|
||||
|
||||
private static bool TryGetSubjectId(ClaimsPrincipal subject, out long playerId)
|
||||
{
|
||||
var value = subject.FindFirstValue(JwtRegisteredClaimNames.Sub)
|
||||
?? subject.FindFirstValue(ClaimTypes.NameIdentifier);
|
||||
return long.TryParse(value, out playerId);
|
||||
}
|
||||
}
|
||||
|
||||
public static class RecRoomIdentityClaims
|
||||
{
|
||||
public static readonly List<string> UserClaimTypes =
|
||||
[
|
||||
JwtRegisteredClaimNames.Sub,
|
||||
ClaimTypes.NameIdentifier,
|
||||
"name",
|
||||
"role",
|
||||
ClaimTypes.Role,
|
||||
"roles",
|
||||
"accountId",
|
||||
"account_id",
|
||||
"accountid",
|
||||
];
|
||||
|
||||
public static async Task<List<Claim>> CreateAsync(DorkNetDbContext db, long playerId, string? username = null)
|
||||
{
|
||||
var playerInfo = await db.Players
|
||||
.Where(p => p.Id == playerId)
|
||||
.Select(p => new { p.Username, IsDev = p.IsDeveloper || p.IsAdmin })
|
||||
.FirstOrDefaultAsync();
|
||||
var name = username ?? playerInfo?.Username ?? playerId.ToString();
|
||||
var isDev = playerInfo?.IsDev ?? false;
|
||||
|
||||
var claims = new List<Claim>
|
||||
{
|
||||
new(ClaimTypes.NameIdentifier, playerId.ToString()),
|
||||
new(JwtRegisteredClaimNames.Sub, playerId.ToString()),
|
||||
new("name", name),
|
||||
new("accountId", playerId.ToString()),
|
||||
new("account_id", playerId.ToString()),
|
||||
new("accountid", playerId.ToString()),
|
||||
new(ClaimTypes.Role, "gameClient"),
|
||||
new("role", "gameClient"),
|
||||
new("roles", "gameClient"),
|
||||
};
|
||||
|
||||
if (isDev)
|
||||
{
|
||||
claims.Add(new Claim(ClaimTypes.Role, "developer"));
|
||||
claims.Add(new Claim("role", "developer"));
|
||||
claims.Add(new Claim("roles", "developer"));
|
||||
}
|
||||
|
||||
return claims;
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.IdentityModel.Tokens;
|
||||
using DorkNet.Models.Notification;
|
||||
using DorkNet.Server.Auth;
|
||||
using DorkNet.Server.Data;
|
||||
@@ -39,6 +40,7 @@ public class AdminController(
|
||||
SignupCodeService signupCodes,
|
||||
IObjectStorage storage,
|
||||
DomainConfig domain,
|
||||
IdentityServerSigningKeyProvider signingKeys,
|
||||
ILogger<AdminController> adminLogger) : ControllerBase
|
||||
{
|
||||
private long CurrentAdminId => this.RequireCurrentPlayerId();
|
||||
@@ -1159,6 +1161,52 @@ public class AdminController(
|
||||
|
||||
// ── Server settings (runtime toggles) ────────────────────────────────
|
||||
|
||||
[HttpGet("identityserver")]
|
||||
public ActionResult GetIdentityServerSettings()
|
||||
{
|
||||
var issuer = domain.AuthIssuer.TrimEnd('/');
|
||||
var client = RecRoomIdentityServerConfig.Clients[0];
|
||||
var certPath = signingKeys.CertificatePath;
|
||||
var fullCertPath = Path.GetFullPath(certPath);
|
||||
var certificateExists = signingKeys.LoadedFromBase64 || System.IO.File.Exists(fullCertPath);
|
||||
|
||||
return Ok(new
|
||||
{
|
||||
Issuer = issuer,
|
||||
DiscoveryUrl = $"{issuer}/.well-known/openid-configuration",
|
||||
TokenEndpoint = $"{issuer}/connect/token",
|
||||
UserInfoEndpoint = $"{issuer}/connect/userinfo",
|
||||
Client = new
|
||||
{
|
||||
client.ClientId,
|
||||
client.ClientName,
|
||||
AllowedGrantTypes = client.AllowedGrantTypes.ToArray(),
|
||||
AllowedScopes = client.AllowedScopes.ToArray(),
|
||||
client.AllowOfflineAccess,
|
||||
client.AccessTokenLifetime,
|
||||
client.AbsoluteRefreshTokenLifetime,
|
||||
client.SlidingRefreshTokenLifetime,
|
||||
RefreshTokenUsage = client.RefreshTokenUsage.ToString(),
|
||||
RefreshTokenExpiration = client.RefreshTokenExpiration.ToString(),
|
||||
},
|
||||
Signing = new
|
||||
{
|
||||
signingKeys.CertificateSource,
|
||||
signingKeys.LoadedFromBase64,
|
||||
CertificatePath = fullCertPath,
|
||||
CertificateExists = certificateExists,
|
||||
signingKeys.Certificate.Thumbprint,
|
||||
NotBeforeUtc = signingKeys.Certificate.NotBefore.ToUniversalTime(),
|
||||
NotAfterUtc = signingKeys.Certificate.NotAfter.ToUniversalTime(),
|
||||
Algorithm = SecurityAlgorithms.RsaSha256,
|
||||
},
|
||||
PersistedGrants = new
|
||||
{
|
||||
Store = "in-memory",
|
||||
Warning = "Refresh tokens are process-local until an operational store is configured.",
|
||||
},
|
||||
});
|
||||
}
|
||||
/// <summary>GET <c>api/admin/v1/settings</c> — current server-wide
|
||||
/// toggle state. Single row, single round trip.</summary>
|
||||
[HttpGet("settings")]
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using DorkNet.Models.Auth;
|
||||
using DorkNet.Server.Data.Entities;
|
||||
using DorkNet.Server.Services;
|
||||
@@ -7,242 +6,26 @@ using DorkNet.Server.Services;
|
||||
namespace DorkNet.Server.Controllers.Auth;
|
||||
|
||||
/// <summary>
|
||||
/// auth.rec.net — OAuth-style token endpoint used by 2019+ builds alongside PlatformLogin.
|
||||
/// auth.rec.net game-specific auth helpers. OAuth/OIDC endpoints are owned by IdentityServer.
|
||||
/// </summary>
|
||||
[ApiController]
|
||||
public class AuthController(
|
||||
PlayerService playerService,
|
||||
AuthService authService,
|
||||
LevelService level,
|
||||
OrphanAccountTracker orphans,
|
||||
ServerSettingsService settings,
|
||||
DorkNet.Server.Data.DorkNetDbContext db,
|
||||
DomainConfig domain,
|
||||
ILogger<AuthController> logger) : ControllerBase
|
||||
AuthService authService) : ControllerBase
|
||||
{
|
||||
/// <summary>Award the per-day first-login XP if the calling
|
||||
/// player hasn't been seen yet today (UTC).</summary>
|
||||
private async Task TryAwardLoginXpAsync(long playerId)
|
||||
{
|
||||
var lastSeen = await db.Players
|
||||
.Where(p => p.Id == playerId)
|
||||
.Select(p => p.LastSeenAt)
|
||||
.FirstOrDefaultAsync();
|
||||
if (lastSeen.Date < DateTime.UtcNow.Date)
|
||||
await level.AwardXpAsync(playerId, LevelService.FirstLoginXp, "first_login_today");
|
||||
}
|
||||
// OAuth token endpoint. The 2020 client uses three grant types here:
|
||||
// - grant_type=cached_login → user clicked a remembered account on the
|
||||
// account-selection screen. Body includes `account_id` (and platform
|
||||
// creds). MUST issue a token for THAT specific account; otherwise the
|
||||
// client receives a JWT for a different account and prompts the user
|
||||
// to create a new one (because the JWT identity doesn't match what
|
||||
// they picked).
|
||||
// - grant_type=refresh_token → re-issue access token from the existing
|
||||
// refresh JWT.
|
||||
// - grant_type=password → first-time sign-in, resolve via deviceId
|
||||
// (preferred) or fall back to a stable synthetic id derived from
|
||||
// username so legacy callers don't share accounts.
|
||||
[HttpPost("/connect/token")]
|
||||
[Consumes("application/x-www-form-urlencoded")]
|
||||
public async Task<IActionResult> Token(
|
||||
[FromForm] string? grant_type,
|
||||
[FromForm] string? username,
|
||||
[FromForm] string? password,
|
||||
[FromForm] string? refresh_token,
|
||||
[FromForm] string? deviceId,
|
||||
[FromForm(Name = "device_id")] string? device_id_snake,
|
||||
[FromForm] int platform = 0,
|
||||
[FromForm] string? platformId = null,
|
||||
[FromForm(Name = "platform_id")] string? platform_id_snake = null,
|
||||
[FromForm] long? account_id = null)
|
||||
{
|
||||
// The client mixes camelCase and snake_case parameter naming
|
||||
// depending on grant type — accept both and merge.
|
||||
var effDeviceId = !string.IsNullOrWhiteSpace(deviceId) ? deviceId : device_id_snake;
|
||||
var effPlatformId = !string.IsNullOrWhiteSpace(platformId) ? platformId : platform_id_snake;
|
||||
|
||||
if (string.Equals(grant_type, "cached_login", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
if (account_id is not long pickedId)
|
||||
return BadRequest(new { error = "invalid_request", error_description = "missing account_id" });
|
||||
var picked = await playerService.GetByIdAsync(pickedId);
|
||||
if (picked is null)
|
||||
return Unauthorized(new { error = "invalid_grant", error_description = "unknown account_id" });
|
||||
|
||||
// Orphan cleanup: if the watch just created a brand-new
|
||||
// account on account/create as part of its boot probe, but
|
||||
// the user actually picked a different cached account here,
|
||||
// the just-created row is unused. Delete it so the DB
|
||||
// doesn't accumulate one orphan per game launch. When the
|
||||
// user is genuinely creating a new account, the picked id
|
||||
// matches the just-created id and we leave it.
|
||||
var pendingId = orphans.PeekPending(effDeviceId, effPlatformId);
|
||||
if (pendingId is long orphanId && orphanId != pickedId)
|
||||
{
|
||||
await playerService.DeleteOrphanAsync(orphanId);
|
||||
logger.LogInformation(
|
||||
"[orphan-cleanup] cached_login picked {PickedId}; deleted just-created {OrphanId}",
|
||||
pickedId, orphanId);
|
||||
}
|
||||
orphans.Clear(effDeviceId, effPlatformId);
|
||||
|
||||
await TryAwardLoginXpAsync(picked.Id);
|
||||
await playerService.TagPlatformAsync(picked.Id, platform, effPlatformId);
|
||||
var (acc1, refr1) = authService.GenerateTokenPair(picked.Id);
|
||||
return Ok(new
|
||||
{
|
||||
access_token = acc1,
|
||||
token_type = "Bearer",
|
||||
expires_in = 43200,
|
||||
refresh_token = refr1,
|
||||
scope = "openid profile",
|
||||
});
|
||||
}
|
||||
|
||||
if (string.Equals(grant_type, "refresh_token", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(refresh_token))
|
||||
return BadRequest(new { error = "invalid_request" });
|
||||
var resolved = authService.ValidateToken(refresh_token);
|
||||
if (resolved is not long refreshedId)
|
||||
return Unauthorized(new { error = "invalid_grant" });
|
||||
var existing = await playerService.GetByIdAsync(refreshedId);
|
||||
if (existing is null)
|
||||
return Unauthorized(new { error = "invalid_grant" });
|
||||
var (acc, refr) = authService.GenerateTokenPair(existing.Id);
|
||||
return Ok(new
|
||||
{
|
||||
access_token = acc,
|
||||
token_type = "Bearer",
|
||||
expires_in = 43200,
|
||||
refresh_token = refr,
|
||||
scope = "openid profile",
|
||||
});
|
||||
}
|
||||
|
||||
// password grant: two paths.
|
||||
// • Username + password supplied: real password verify against
|
||||
// PlayerEntity.PasswordHash. Wrong password → 401.
|
||||
// • No password: legacy device-id synth path for the 2020
|
||||
// client's first-launch flow where the player hasn't set a
|
||||
// password yet (HasPassword returns false in the watch UI
|
||||
// and prompts them to create one). Resolves the existing
|
||||
// account (or creates a fresh one) by deviceId.
|
||||
if (!string.IsNullOrEmpty(password) && !string.IsNullOrEmpty(username))
|
||||
{
|
||||
var byName = await playerService.GetByUsernameAsync(username);
|
||||
if (byName is null || byName.PasswordHash is null)
|
||||
return Unauthorized(new
|
||||
{
|
||||
error = "invalid_grant",
|
||||
error_description = "unknown_user_or_no_password",
|
||||
});
|
||||
if (!BCrypt.Net.BCrypt.Verify(password, byName.PasswordHash))
|
||||
return Unauthorized(new
|
||||
{
|
||||
error = "invalid_grant",
|
||||
error_description = "password_mismatch",
|
||||
});
|
||||
// Tag the platform so the next boot's cached-login lookup
|
||||
// finds this account — without this the watch's "remembered
|
||||
// accounts" prompt would stay empty after a manual sign-in.
|
||||
await playerService.TagPlatformAsync(byName.Id, platform, effPlatformId);
|
||||
await TryAwardLoginXpAsync(byName.Id);
|
||||
var pair = authService.GenerateTokenPair(byName.Id);
|
||||
return Ok(new
|
||||
{
|
||||
access_token = pair.AccessToken,
|
||||
token_type = "Bearer",
|
||||
expires_in = 43200,
|
||||
refresh_token = pair.RefreshToken,
|
||||
scope = "openid profile",
|
||||
});
|
||||
}
|
||||
|
||||
// Device-id fallback path. Prefer the real deviceId; fall back
|
||||
// to a stable legacy synthesis from username so older callers
|
||||
// don't accidentally share an account.
|
||||
var effectiveDeviceId = !string.IsNullOrWhiteSpace(effDeviceId)
|
||||
? effDeviceId
|
||||
: $"legacy-{(username ?? "anonymous").ToLowerInvariant()}";
|
||||
|
||||
// Honour the admin "signups disabled" toggle. If the device
|
||||
// hasn't logged in before, GetOrCreateByDeviceAsync would mint
|
||||
// a fresh account here — same outcome as hitting
|
||||
// /account/create directly, which the toggle blocks. Refuse
|
||||
// BEFORE creation so the flag isn't bypassable.
|
||||
if (await settings.AreSignupsDisabledAsync())
|
||||
{
|
||||
var existing = await playerService.GetByDeviceAsync(effectiveDeviceId);
|
||||
if (existing is null)
|
||||
{
|
||||
logger.LogInformation(
|
||||
"[auth] /connect/token device fallback refused — signups disabled (device={Device})",
|
||||
effectiveDeviceId);
|
||||
return Unauthorized(new
|
||||
{
|
||||
error = "signups_disabled",
|
||||
error_description = "Account creation is currently disabled by the server admin.",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
var player = await playerService.GetOrCreateByDeviceAsync(
|
||||
deviceId: effectiveDeviceId,
|
||||
platform: platform,
|
||||
platformId: effPlatformId,
|
||||
displayName: username);
|
||||
await TryAwardLoginXpAsync(player.Id);
|
||||
var (access, refresh) = authService.GenerateTokenPair(player.Id);
|
||||
|
||||
return Ok(new
|
||||
{
|
||||
access_token = access,
|
||||
token_type = "Bearer",
|
||||
expires_in = 43200,
|
||||
refresh_token = refresh,
|
||||
scope = "openid profile",
|
||||
});
|
||||
}
|
||||
|
||||
[HttpGet("/connect/userinfo")]
|
||||
public async Task<IActionResult> UserInfo()
|
||||
{
|
||||
var auth = Request.Headers.Authorization.ToString();
|
||||
if (!auth.StartsWith("Bearer ", StringComparison.OrdinalIgnoreCase))
|
||||
return Unauthorized();
|
||||
var token = auth["Bearer ".Length..].Trim();
|
||||
var id = authService.ValidateToken(token);
|
||||
if (id is not long playerId) return Unauthorized();
|
||||
var player = await playerService.GetByIdAsync(playerId);
|
||||
if (player is null) return Unauthorized();
|
||||
return Ok(new { sub = player.Id.ToString(), name = player.Username });
|
||||
}
|
||||
|
||||
// Discovery document — issuer + endpoints derived from
|
||||
// DomainConfig so a single DORKNET_DOMAIN env var keeps the
|
||||
// OpenID document pointed at the live deployment.
|
||||
[HttpGet("/.well-known/openid-configuration")]
|
||||
public IActionResult OpenIdConfig()
|
||||
{
|
||||
var base_ = $"https://{domain.Sub("auth")}";
|
||||
return Ok(new
|
||||
{
|
||||
issuer = base_,
|
||||
token_endpoint = $"{base_}/connect/token",
|
||||
userinfo_endpoint = $"{base_}/connect/userinfo",
|
||||
});
|
||||
}
|
||||
// 2020.12 added an EAC challenge step in the login flow. The client GETs
|
||||
// a challenge string and passes it to EACManager.GenerateChallengeResponse,
|
||||
// which calls System.Convert.FromBase64CharPtr on the string. So the
|
||||
// returned challenge MUST be valid base64 — sending arbitrary text throws
|
||||
// FormatException client-side and kicks the login.
|
||||
[HttpGet("/eac/challenge")]
|
||||
[HttpPost("/eac/challenge")]
|
||||
public IActionResult EacChallenge()
|
||||
=> Content("\"AAAAAAAAAAAAAAAAAAAAAAAA\"", "application/json");
|
||||
|
||||
// Cached-logins lookup — boot-sequence call from ScreenAccountSelectionManager.
|
||||
// URL: GET auth.rec.net/cachedlogin/forplatformid/{platform}/{platformId}
|
||||
// Returns List<Login.CachedLogin>. Empty array = no remembered accounts.
|
||||
//
|
||||
// Real lookup: any account whose last platformlogin matched
|
||||
// (platform, platformId) shows up here, sorted most-recent-first. On a
|
||||
// single-Steam-emu setup that's one row; on a multi-account dev box
|
||||
// it's the full list the user has cycled through.
|
||||
[HttpGet("/cachedlogin/forplatformid/{platform:int}/{platformId}")]
|
||||
public async Task<IActionResult> CachedLoginsForPlatform(int platform, string platformId)
|
||||
{
|
||||
@@ -252,10 +35,8 @@ public class AuthController(
|
||||
|
||||
/// <summary>POST <c>cachedlogin/forplatformids</c> — bulk variant
|
||||
/// the watch uses when hydrating multiple platform-id mappings
|
||||
/// at once (e.g. populating a friend list from Steam ids). Body
|
||||
/// is form-urlencoded <c>platform=&platformIds=a,b,c</c> or
|
||||
/// JSON with the same shape. Returns the flattened
|
||||
/// <c>List<CachedLogin></c> across every matched id.</summary>
|
||||
/// at once. Body is form-urlencoded <c>platform=&platformIds=a,b,c</c>
|
||||
/// or JSON with the same shape.</summary>
|
||||
[HttpPost("/cachedlogin/forplatformids")]
|
||||
[HttpGet("/cachedlogin/forplatformids")]
|
||||
public async Task<IActionResult> CachedLoginsForPlatformIds()
|
||||
@@ -273,6 +54,7 @@ public class AuthController(
|
||||
}
|
||||
}
|
||||
catch { /* not form */ }
|
||||
|
||||
if (string.IsNullOrWhiteSpace(rawIds))
|
||||
{
|
||||
int.TryParse(Request.Query["platform"], out platform);
|
||||
@@ -295,13 +77,50 @@ public class AuthController(
|
||||
return Ok(results);
|
||||
}
|
||||
|
||||
private static CachedLogin ToCachedLogin(PlayerEntity p, int platform, string platformId) => new()
|
||||
[HttpGet("/cachedlogin/current")]
|
||||
public async Task<IActionResult> CurrentCachedLogin()
|
||||
{
|
||||
var token = GetBearerToken();
|
||||
if (token is null) return Unauthorized();
|
||||
var id = authService.ValidateToken(token);
|
||||
if (id is not long playerId) return Unauthorized();
|
||||
var player = await playerService.GetByIdAsync(playerId);
|
||||
if (player is null) return Unauthorized();
|
||||
return Ok(ToCachedLogin(player, player.LastPlatform, player.LastPlatformId));
|
||||
}
|
||||
|
||||
[HttpPost("/cachedlogin/migrate")]
|
||||
public async Task<IActionResult> MigrateCachedLogin(
|
||||
[FromForm] int platform = 0,
|
||||
[FromForm] string? platformId = null)
|
||||
{
|
||||
var token = GetBearerToken();
|
||||
if (token is null) return Unauthorized();
|
||||
var id = authService.ValidateToken(token);
|
||||
if (id is not long playerId) return Unauthorized();
|
||||
await playerService.TagPlatformAsync(playerId, platform, platformId);
|
||||
var player = await playerService.GetByIdAsync(playerId);
|
||||
return player is null
|
||||
? Unauthorized()
|
||||
: Ok(ToCachedLogin(player, platform, platformId ?? string.Empty));
|
||||
}
|
||||
|
||||
private string? GetBearerToken()
|
||||
{
|
||||
var auth = Request.Headers.Authorization.ToString();
|
||||
if (auth.StartsWith("Bearer ", StringComparison.OrdinalIgnoreCase))
|
||||
return auth["Bearer ".Length..].Trim();
|
||||
return Request.Cookies.TryGetValue(AuthService.AccessCookieName, out var cookieToken)
|
||||
? cookieToken
|
||||
: null;
|
||||
}
|
||||
|
||||
private static CachedLogin ToCachedLogin(PlayerEntity p, int platform, string? platformId) => new()
|
||||
{
|
||||
Platform = platform,
|
||||
PlatformId = platformId,
|
||||
PlatformId = platformId ?? string.Empty,
|
||||
AccountId = (int)p.Id,
|
||||
LastLoginTime = p.LastSeenAt,
|
||||
RequirePassword = false,
|
||||
};
|
||||
|
||||
}
|
||||
|
||||
@@ -59,6 +59,18 @@ public class PublicSiteController(
|
||||
|
||||
var photoCount = await db.Photos
|
||||
.CountAsync(ph => ph.UploaderPlayerId == id && ph.IsPublic && ph.DeletedAt == null);
|
||||
var taggedNeedle = $",{id},";
|
||||
var photosOfPlayerCount = await db.Photos
|
||||
.CountAsync(ph => ph.IsPublic && ph.DeletedAt == null &&
|
||||
("," + ph.TaggedPlayerIdsCsv + ",").Contains(taggedNeedle));
|
||||
var friendCount = await db.Relationships
|
||||
.Where(r => r.Status == RelationshipStatus.Friend &&
|
||||
(r.RequesterId == id || r.TargetId == id))
|
||||
.Select(r => r.RequesterId == id ? r.TargetId : r.RequesterId)
|
||||
.Distinct()
|
||||
.CountAsync();
|
||||
var publicRoomCount = await PublicPublishedRooms()
|
||||
.CountAsync(r => r.CreatorPlayerId == id);
|
||||
|
||||
return Ok(new
|
||||
{
|
||||
@@ -75,6 +87,10 @@ public class PublicSiteController(
|
||||
createdAt = p.CreatedAt,
|
||||
profileImageName = p.ProfileImageName,
|
||||
photoCount,
|
||||
photosTakenCount = photoCount,
|
||||
photosOfPlayerCount,
|
||||
friendCount,
|
||||
publicRoomCount,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -94,6 +110,54 @@ public class PublicSiteController(
|
||||
return Ok(await EnrichPhotosAsync(rows));
|
||||
}
|
||||
|
||||
/// <summary>GET /api/site/v1/players/{id}/photos/of — public photos
|
||||
/// where this player was tagged by the camera.</summary>
|
||||
[HttpGet("players/{id:long}/photos/of")]
|
||||
public async Task<IActionResult> GetPhotosOfPlayer(long id,
|
||||
[FromQuery] int take = 24, [FromQuery] int skip = 0)
|
||||
{
|
||||
take = Math.Clamp(take, 1, 100);
|
||||
skip = Math.Max(0, skip);
|
||||
var needle = $",{id},";
|
||||
var rows = await db.Photos
|
||||
.Where(p => p.IsPublic && p.DeletedAt == null &&
|
||||
("," + p.TaggedPlayerIdsCsv + ",").Contains(needle))
|
||||
.OrderByDescending(p => p.CreatedAt)
|
||||
.Skip(skip).Take(take)
|
||||
.ToListAsync();
|
||||
return Ok(await EnrichPhotosAsync(rows));
|
||||
}
|
||||
|
||||
/// <summary>GET /api/site/v1/players/{id}/rooms — public rooms
|
||||
/// published by this player.</summary>
|
||||
[HttpGet("players/{id:long}/rooms")]
|
||||
public async Task<IActionResult> GetPlayerRooms(long id,
|
||||
[FromQuery] int take = 24, [FromQuery] int skip = 0)
|
||||
{
|
||||
take = Math.Clamp(take, 1, 100);
|
||||
skip = Math.Max(0, skip);
|
||||
var rows = await PublicPublishedRooms()
|
||||
.Where(r => r.CreatorPlayerId == id)
|
||||
.OrderByDescending(r => r.HotScore)
|
||||
.ThenByDescending(r => r.UpdatedAt)
|
||||
.Skip(skip).Take(take)
|
||||
.Select(r => new
|
||||
{
|
||||
id = r.Id,
|
||||
name = r.Name,
|
||||
description = r.Description,
|
||||
creatorPlayerId = r.CreatorPlayerId,
|
||||
isDormRoom = r.IsDormRoom,
|
||||
isAGRoom = r.IsAGRoom,
|
||||
visitCount = r.VisitCount,
|
||||
visitorCount = r.VisitorCount,
|
||||
cheerCount = r.CheerCount,
|
||||
imageName = r.ImageName,
|
||||
})
|
||||
.ToListAsync();
|
||||
return Ok(rows);
|
||||
}
|
||||
|
||||
// ── Photos ───────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>GET /api/site/v1/feed — global public feed, newest first.</summary>
|
||||
@@ -195,6 +259,12 @@ public class PublicSiteController(
|
||||
profileImageName = p.ProfileImageName,
|
||||
};
|
||||
|
||||
private IQueryable<RoomEntity> PublicPublishedRooms() =>
|
||||
db.Rooms.Where(r => r.State == 0 &&
|
||||
r.Accessibility == 1 &&
|
||||
!r.IsDormRoom &&
|
||||
!r.HiddenFromBrowse);
|
||||
|
||||
/// <summary>Materialise photo rows into wire DTOs with display names
|
||||
/// + room names + an absolute image URL. The URL apex switches
|
||||
/// based on the request host so a localhost visitor gets localhost
|
||||
@@ -205,10 +275,15 @@ public class PublicSiteController(
|
||||
if (list.Count == 0) return new();
|
||||
|
||||
var uploaderIds = list.Select(p => p.UploaderPlayerId).Distinct().ToList();
|
||||
var taggedIds = list
|
||||
.SelectMany(p => ParseTaggedPlayerIds(p.TaggedPlayerIdsCsv))
|
||||
.Distinct()
|
||||
.ToList();
|
||||
var playerIds = uploaderIds.Concat(taggedIds).Distinct().ToList();
|
||||
var roomIds = list.Select(p => p.RoomId).Where(id => id > 0).Distinct().ToList();
|
||||
|
||||
var uploaders = await db.Players
|
||||
.Where(p => uploaderIds.Contains(p.Id))
|
||||
var playersById = await db.Players
|
||||
.Where(p => playerIds.Contains(p.Id))
|
||||
.Select(p => new { p.Id, p.Username, p.DisplayName, p.ProfileImageName })
|
||||
.ToListAsync();
|
||||
var rooms = await db.Rooms
|
||||
@@ -216,14 +291,14 @@ public class PublicSiteController(
|
||||
.Select(r => new { r.Id, r.Name })
|
||||
.ToListAsync();
|
||||
|
||||
var uMap = uploaders.ToDictionary(u => u.Id);
|
||||
var pMap = playersById.ToDictionary(u => u.Id);
|
||||
var rMap = rooms.ToDictionary(r => r.Id);
|
||||
|
||||
var cdnHost = domain.Sub("cdn");
|
||||
|
||||
return list.Select(p =>
|
||||
{
|
||||
uMap.TryGetValue(p.UploaderPlayerId, out var u);
|
||||
pMap.TryGetValue(p.UploaderPlayerId, out var u);
|
||||
rMap.TryGetValue(p.RoomId, out var r);
|
||||
return (object)new
|
||||
{
|
||||
@@ -237,6 +312,17 @@ public class PublicSiteController(
|
||||
caption = p.Caption,
|
||||
roomId = p.RoomId,
|
||||
roomName = r?.Name ?? string.Empty,
|
||||
taggedPlayers = ParseTaggedPlayerIds(p.TaggedPlayerIdsCsv)
|
||||
.Select(id => pMap.TryGetValue(id, out var tagged) ? tagged : null)
|
||||
.Where(tagged => tagged is not null)
|
||||
.Select(tagged => new
|
||||
{
|
||||
id = tagged!.Id,
|
||||
username = tagged.Username,
|
||||
displayName = tagged.DisplayName,
|
||||
profileImageName = tagged.ProfileImageName,
|
||||
})
|
||||
.ToArray(),
|
||||
isPublic = p.IsPublic,
|
||||
cheerCount = p.CheerCount,
|
||||
viewCount = p.ViewCount,
|
||||
@@ -245,6 +331,16 @@ public class PublicSiteController(
|
||||
}).ToList();
|
||||
}
|
||||
|
||||
private static List<long> ParseTaggedPlayerIds(string? csv)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(csv)) return [];
|
||||
return csv.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
|
||||
.Select(s => long.TryParse(s, out var id) ? id : 0)
|
||||
.Where(id => id > 0)
|
||||
.Distinct()
|
||||
.ToList();
|
||||
}
|
||||
|
||||
// ── Signup (code redemption) ─────────────────────────────────────
|
||||
|
||||
/// <summary>GET /api/site/v1/join/pending-devices — device ids that
|
||||
|
||||
@@ -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="Open.IdentityServer" Version="1.0.0" />
|
||||
<PackageReference Include="MessagePack" Version="3.1.7" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="10.0.9" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="10.0.9" />
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="10.0.9">
|
||||
<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.9" />
|
||||
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="10.0.2" />
|
||||
<!-- 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
|
||||
@@ -25,8 +27,8 @@
|
||||
scale: SignalR group fanout, OrphanAccountTracker (sticky-session
|
||||
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="StackExchange.Redis" Version="2.13.17" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.SignalR.StackExchangeRedis" Version="10.0.9" />
|
||||
<!-- 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
|
||||
@@ -38,7 +40,7 @@
|
||||
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="Swashbuckle.AspNetCore" Version="10.2.3" />
|
||||
<!-- 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
|
||||
|
||||
+6
-1356
@@ -1,1367 +1,17 @@
|
||||
using Microsoft.AspNetCore.Authentication.JwtBearer;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.IdentityModel.Tokens;
|
||||
using DorkNet.Server.Data;
|
||||
using DorkNet.Server.Services;
|
||||
using Serilog;
|
||||
using Serilog.Formatting.Compact;
|
||||
using StackExchange.Redis;
|
||||
using System.Text;
|
||||
using DorkNet.Server.Startup;
|
||||
|
||||
var builder = WebApplication.CreateBuilder(args);
|
||||
|
||||
// Personal/local-machine override layer. Loaded last so it wins over both
|
||||
// appsettings.json and appsettings.{Environment}.json. Gitignored — use
|
||||
// this for Photon AppId, JWT secret, and anything you don't want committed.
|
||||
// appsettings.json and appsettings.{Environment}.json. Gitignored; use this
|
||||
// for Photon AppId, JWT secret, and anything you don't want committed.
|
||||
builder.Configuration.AddJsonFile("appsettings.Local.json",
|
||||
optional: true, reloadOnChange: true);
|
||||
|
||||
// ── Logging ──────────────────────────────────────────────────────────────────
|
||||
// Production: structured JSON (one JSON object per line) so Coolify's log
|
||||
// pipeline / future Loki ingest can parse fields without regex.
|
||||
// Development: human-readable text so the local console stays scannable.
|
||||
// The JSON output is the same shape no matter the environment, just
|
||||
// rendered differently — log keys like {Tag}, {Status}, {Method} stay
|
||||
// queryable when we ship to a structured log store later.
|
||||
builder.Host.UseSerilog((ctx, services, cfg) =>
|
||||
{
|
||||
cfg.ReadFrom.Configuration(ctx.Configuration)
|
||||
.ReadFrom.Services(services)
|
||||
.Enrich.FromLogContext();
|
||||
|
||||
if (ctx.HostingEnvironment.IsDevelopment())
|
||||
cfg.WriteTo.Console();
|
||||
else
|
||||
cfg.WriteTo.Console(new RenderedCompactJsonFormatter());
|
||||
});
|
||||
|
||||
// ── Database ──────────────────────────────────────────────────────────────────
|
||||
// Provider switch — `Database:Provider` config key (or DATABASE__PROVIDER env var)
|
||||
// chooses sqlite (default for local dev) or postgres (production). Each provider
|
||||
// gets its own migrations assembly path so we can ship both schemas without
|
||||
// EF Core complaining about provider-specific column types in the snapshot.
|
||||
var dbProvider = (builder.Configuration["Database:Provider"] ?? "sqlite").ToLowerInvariant();
|
||||
|
||||
builder.Services.AddDbContext<DorkNetDbContext>(opt =>
|
||||
{
|
||||
// EF Core 9 throws on every connection if the model drifts from
|
||||
// the migrations snapshot. We track migrations + the snapshot
|
||||
// by hand for data-only fixes, so this warning is noise — Coolify
|
||||
// and local-dev databases still apply real migrations from disk.
|
||||
opt.ConfigureWarnings(w => w.Ignore(
|
||||
Microsoft.EntityFrameworkCore.Diagnostics.RelationalEventId.PendingModelChangesWarning));
|
||||
|
||||
if (dbProvider == "postgres")
|
||||
{
|
||||
// ConnectionStrings:Default is the standard ASP.NET Core key; env var
|
||||
// form is ConnectionStrings__Default. Coolify sets this when you link
|
||||
// the managed Postgres service.
|
||||
var conn = builder.Configuration.GetConnectionString("Default")
|
||||
?? throw new InvalidOperationException(
|
||||
"Database:Provider=postgres but ConnectionStrings:Default is not set. " +
|
||||
"Set the ConnectionStrings__Default env var or `ConnectionStrings:Default` " +
|
||||
"in appsettings.Local.json.");
|
||||
opt.UseNpgsql(conn, npg => npg.MigrationsAssembly("DorkNet.Server"));
|
||||
}
|
||||
else
|
||||
{
|
||||
// Honor an explicit path the host can set via env var
|
||||
// (Database__SqlitePath) or appsettings (Database:SqlitePath).
|
||||
// The Windows launcher sets this to %APPDATA%\DorkNet\dorknet.db
|
||||
// so server installs don't accumulate state under <bin>\data\.
|
||||
// Existing Docker / standalone deploys that mounted <bin>\data\
|
||||
// keep working because that's still the fallback.
|
||||
var dbPath = builder.Configuration["Database:SqlitePath"];
|
||||
if (string.IsNullOrWhiteSpace(dbPath))
|
||||
dbPath = Path.Combine(AppContext.BaseDirectory, "data", "dorknet.db");
|
||||
Directory.CreateDirectory(Path.GetDirectoryName(dbPath)!);
|
||||
opt.UseSqlite($"Data Source={dbPath}", lite => lite.MigrationsAssembly("DorkNet.Server"));
|
||||
}
|
||||
});
|
||||
|
||||
// ── Domain ────────────────────────────────────────────────────────────────────
|
||||
// Single source of truth for the deployment apex. Read once at startup
|
||||
// from DORKNET_DOMAIN (env-var) or Domain:Apex (config), defaulting to
|
||||
// localhost so existing deploys keep working without setting the env var.
|
||||
// Replaces the per-controller [Host(...)] filters that hard-coded
|
||||
// rec.net/localhost pairs — the singleton DomainConfig is injected
|
||||
// anywhere code needs to build outbound URLs (img.{apex}/...,
|
||||
// cdn.{apex}/..., notify.{apex}/...). The HostFilteringMiddleware
|
||||
// allowed-hosts list is derived from {apex} + *.{apex} so every
|
||||
// subdomain under the configured apex reaches the controllers; the
|
||||
// [Host] attributes previously acted as both routing keys AND host
|
||||
// filters, but now they're gone and HostFiltering does the latter alone.
|
||||
var apex =
|
||||
builder.Configuration["Domain:Apex"]
|
||||
?? Environment.GetEnvironmentVariable("DORKNET_DOMAIN")
|
||||
?? "localhost";
|
||||
var domainScheme =
|
||||
builder.Configuration["Domain:Scheme"]
|
||||
?? Environment.GetEnvironmentVariable("DORKNET_DOMAIN_SCHEME")
|
||||
?? "https";
|
||||
var domainPort =
|
||||
builder.Configuration["Domain:Port"]
|
||||
?? Environment.GetEnvironmentVariable("DORKNET_DOMAIN_PORT");
|
||||
var singleOriginBaseUrl =
|
||||
builder.Configuration["Domain:SingleOriginBaseUrl"]
|
||||
?? Environment.GetEnvironmentVariable("DORKNET_SINGLE_ORIGIN_BASE_URL");
|
||||
builder.Services.AddSingleton(new DomainConfig(apex, domainScheme, domainPort, singleOriginBaseUrl));
|
||||
builder.Services.Configure<Microsoft.AspNetCore.HostFiltering.HostFilteringOptions>(opt =>
|
||||
{
|
||||
opt.AllowedHosts = new[] { apex, $"*.{apex}", "localhost", "127.0.0.1" };
|
||||
opt.AllowEmptyHosts = true;
|
||||
opt.IncludeFailureMessage = true;
|
||||
});
|
||||
Console.WriteLine($"[domain] apex={apex}, scheme={domainScheme}, port={domainPort}, singleOrigin={singleOriginBaseUrl}, allowedHosts=[{apex}, *.{apex}, localhost]");
|
||||
|
||||
// ── Services ──────────────────────────────────────────────────────────────────
|
||||
builder.Services.AddScoped<PlayerService>();
|
||||
builder.Services.AddScoped<ConfigService>();
|
||||
builder.Services.AddScoped<AuthService>();
|
||||
builder.Services.AddScoped<RoomService>();
|
||||
builder.Services.AddScoped<LevelService>();
|
||||
builder.Services.AddScoped<StoreService>();
|
||||
// Singleton — the protobuf blob is identical for every room and built
|
||||
// once at startup; no need to rebuild per request.
|
||||
builder.Services.AddSingleton<RoomDataBlobService>();
|
||||
builder.Services.AddSingleton<RoomBlobNormalizerService>();
|
||||
// IHttpClientFactory + the .htr asset mirror that the admin Import
|
||||
// Room endpoint kicks off as fire-and-forget after a successful upload.
|
||||
builder.Services.AddHttpClient();
|
||||
builder.Services.AddSingleton<HtrAssetMirrorService>();
|
||||
// Scoped (per-request) — these services hold DbContext references and
|
||||
// must share the request's scope to participate in the request's
|
||||
// transaction lifecycle. Pre-PR-3 they were Singleton with in-process
|
||||
// ConcurrentDictionary state; PR 3 moved their state to Postgres and
|
||||
// they now resolve from DI per-request like any other DbContext consumer.
|
||||
builder.Services.AddScoped<GameSessionService>();
|
||||
builder.Services.AddScoped<PrivateInstanceService>();
|
||||
builder.Services.AddScoped<CommunityBoardService>();
|
||||
builder.Services.AddScoped<ServerSettingsService>();
|
||||
builder.Services.AddScoped<SignupCodeService>();
|
||||
// Singletons — these own connectionless state (Redis-backed or
|
||||
// process-local) and don't need a per-request scope.
|
||||
builder.Services.AddSingleton<NotificationService>();
|
||||
builder.Services.AddSingleton<OrphanAccountTracker>();
|
||||
builder.Services.AddSingleton<PlayerPresenceService>();
|
||||
builder.Services.AddSingleton<OnlinePresenceService>();
|
||||
// Per-player rolling request log used by the admin UI's "Player logs"
|
||||
// tab. Singleton because it's stateless beyond Redis + an in-process
|
||||
// fallback queue; no DbContext involved.
|
||||
builder.Services.AddSingleton<PlayerLogService>();
|
||||
// S3-compatible object storage (Garage in production, MinIO/disk in dev)
|
||||
// — holds profile images + room-blob bytes. Stateless wrapper around
|
||||
// the AWS SDK; safe as a singleton.
|
||||
builder.Services.AddSingleton<IObjectStorage, ObjectStorageService>();
|
||||
builder.Services.AddSingleton<ImageSignatureService>();
|
||||
|
||||
// ── Redis ─────────────────────────────────────────────────────────────────────
|
||||
// IConnectionMultiplexer is registered ONLY when a connection string is
|
||||
// present. The Redis-backed services check for it via DI and fall back to
|
||||
// process-local state when null. Local dev can run without Redis; Coolify
|
||||
// production sets ConnectionStrings__Redis on the linked service.
|
||||
//
|
||||
// Coolify hands you a URI like
|
||||
// redis://default:PASSWORD@host:6379/0
|
||||
// but StackExchange.Redis.Connect(string) doesn't reliably handle the
|
||||
// user-info segment of a URI (the "default:PASSWORD@" part) — passing
|
||||
// the URI verbatim ends up with no password applied to the
|
||||
// ConfigurationOptions and Connect throws a misleading
|
||||
// "Error connecting right now". So we detect a URI prefix and translate
|
||||
// to SE.Redis's native config string ("host:port,password=...,user=...,
|
||||
// abortConnect=false") before handing off.
|
||||
var redisConn = builder.Configuration.GetConnectionString("Redis");
|
||||
if (!string.IsNullOrWhiteSpace(redisConn))
|
||||
{
|
||||
var seRedisConfig = NormalizeRedisConn(redisConn);
|
||||
var mux = ConnectionMultiplexer.Connect(seRedisConfig);
|
||||
builder.Services.AddSingleton<IConnectionMultiplexer>(mux);
|
||||
}
|
||||
|
||||
static string NormalizeRedisConn(string raw)
|
||||
{
|
||||
if (!raw.StartsWith("redis://", StringComparison.OrdinalIgnoreCase) &&
|
||||
!raw.StartsWith("rediss://", StringComparison.OrdinalIgnoreCase))
|
||||
return raw; // already SE.Redis native config string
|
||||
var uri = new Uri(raw);
|
||||
var host = uri.Host;
|
||||
var port = uri.Port > 0 ? uri.Port : 6379;
|
||||
string? user = null, pass = null;
|
||||
if (!string.IsNullOrEmpty(uri.UserInfo))
|
||||
{
|
||||
var parts = uri.UserInfo.Split(':', 2);
|
||||
user = Uri.UnescapeDataString(parts[0]);
|
||||
if (parts.Length == 2) pass = Uri.UnescapeDataString(parts[1]);
|
||||
}
|
||||
var opts = new StackExchange.Redis.ConfigurationOptions
|
||||
{
|
||||
EndPoints = { { host, port } },
|
||||
AbortOnConnectFail = false,
|
||||
Ssl = raw.StartsWith("rediss://", StringComparison.OrdinalIgnoreCase),
|
||||
};
|
||||
if (!string.IsNullOrEmpty(user)) opts.User = user;
|
||||
if (!string.IsNullOrEmpty(pass)) opts.Password = pass;
|
||||
return opts.ToString();
|
||||
}
|
||||
|
||||
// ── JWT Auth ──────────────────────────────────────────────────────────────────
|
||||
// Resolution order: env-var first (for production / containerised deploys),
|
||||
// then `Jwt:Secret` from any configuration source (appsettings.Local.json
|
||||
// is the same-machine convenience path the patcher script writes to).
|
||||
// Never commit a real secret to appsettings.json — the gitignored
|
||||
// appsettings.Local.json or the env-var are the only legitimate sources.
|
||||
// DORKNET_JWT_SECRET is the canonical env var name; RECNET_JWT_SECRET
|
||||
// kept for backward compat with older Coolify configs (will warn at
|
||||
// startup, can be removed once everyone has migrated). AuthService
|
||||
// has the matching fallback so signing + validation use the same key.
|
||||
var jwtSecret = Environment.GetEnvironmentVariable("DORKNET_JWT_SECRET")
|
||||
?? Environment.GetEnvironmentVariable("RECNET_JWT_SECRET")
|
||||
?? builder.Configuration["Jwt:Secret"]
|
||||
?? throw new InvalidOperationException(
|
||||
"JWT secret not configured. Set the DORKNET_JWT_SECRET env var or " +
|
||||
"add `Jwt:Secret` to appsettings.Local.json.");
|
||||
|
||||
builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
|
||||
.AddJwtBearer(opt =>
|
||||
{
|
||||
opt.TokenValidationParameters = new TokenValidationParameters
|
||||
{
|
||||
ValidateIssuerSigningKey = true,
|
||||
IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(jwtSecret)),
|
||||
ValidateIssuer = true,
|
||||
ValidIssuer = "https://api.rec.net/",
|
||||
ValidateAudience = false,
|
||||
ClockSkew = TimeSpan.Zero,
|
||||
};
|
||||
// SignalR over WebSocket: BestHTTP.SignalRCore (the 2020 watch's
|
||||
// hub client) negotiates with Authorization: Bearer, but the
|
||||
// WebSocket upgrade leg isn't guaranteed to keep the header
|
||||
// depending on the proxy in front of us — Cloudflare Tunnel
|
||||
// passes through, but Coolify's Caddy / browsers don't. The
|
||||
// hub client falls back to ?access_token=<jwt> on the WS URL,
|
||||
// which we extract here for any /hub/* path so the connection
|
||||
// authenticates the same way negotiate did. Without this, the
|
||||
// watch's RecNet.Notifications connect throws "Failed to
|
||||
// connect to RecNet Notifications" mid-login.
|
||||
opt.Events = new Microsoft.AspNetCore.Authentication.JwtBearer.JwtBearerEvents
|
||||
{
|
||||
OnMessageReceived = ctx =>
|
||||
{
|
||||
var accessToken = ctx.Request.Query["access_token"];
|
||||
var path = ctx.HttpContext.Request.Path;
|
||||
if (!string.IsNullOrEmpty(accessToken) && path.StartsWithSegments("/hub"))
|
||||
ctx.Token = accessToken;
|
||||
return Task.CompletedTask;
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
builder.Services.AddAuthorization();
|
||||
// Use C# property names verbatim — [JsonPropertyName] attributes on each
|
||||
// model give the per-key casing the client's LitJson Util.GetKey calls
|
||||
// expect (a mix of lowercase, camelCase and snake_case; verified by
|
||||
// disassembling each Deserialize body). Don't apply a global naming policy
|
||||
// or it will override the explicit per-key attributes.
|
||||
builder.Services.AddControllers().AddJsonOptions(opt =>
|
||||
opt.JsonSerializerOptions.PropertyNamingPolicy = null);
|
||||
|
||||
// Kestrel-level limits — the defaults trim long-lived sockets earlier
|
||||
// than our SignalR ping interval allows. Specifically:
|
||||
// * KeepAliveTimeout (HTTP keep-alive between requests, default 130s)
|
||||
// applies to upgraded WebSocket connections in some hosting paths;
|
||||
// bump to 10m so it never beats SignalR's own ping cadence.
|
||||
// * RequestHeadersTimeout (default 30s) is for the initial upgrade
|
||||
// handshake only — leave it.
|
||||
// * MinRequestBodyDataRate / MinResponseDataRate enforce a minimum
|
||||
// bytes/sec on streaming bodies; for chunked-upload room imports
|
||||
// that's been the cause of mysterious abort-mid-write errors on
|
||||
// slow links. Disabling them is safer than risking false-positive
|
||||
// "client too slow" closures.
|
||||
builder.WebHost.ConfigureKestrel(kopts =>
|
||||
{
|
||||
kopts.Limits.KeepAliveTimeout = TimeSpan.FromMinutes(10);
|
||||
kopts.Limits.MinRequestBodyDataRate = null;
|
||||
kopts.Limits.MinResponseDataRate = null;
|
||||
});
|
||||
|
||||
// SignalR — drives notify.rec.net/hub/v1, consumed client-side by
|
||||
// BestHTTP.SignalRCore.HubConnection. The framework handles the
|
||||
// /negotiate POST automatically. When Redis is configured, attach
|
||||
// the StackExchange.Redis backplane so groups (player:N) fan out
|
||||
// across replicas — without it, player A connected to replica 1
|
||||
// would never receive a notification published from replica 2.
|
||||
//
|
||||
// Keepalive cadence: server pings every 10s, gives the client 60s
|
||||
// to ack before declaring the connection dead. The 10s ping forces
|
||||
// frame traffic well below every proxy timeout we've seen in the
|
||||
// path (Cloudflare edge ~100s, cloudflared 600s, Coolify/Traefik
|
||||
// 180s, Kestrel 130s). 60s client timeout is double the ping
|
||||
// interval so a single dropped ping doesn't kill the connection.
|
||||
var signalR = builder.Services.AddSignalR(opts =>
|
||||
{
|
||||
opts.KeepAliveInterval = TimeSpan.FromSeconds(10);
|
||||
opts.ClientTimeoutInterval = TimeSpan.FromSeconds(60);
|
||||
opts.HandshakeTimeout = TimeSpan.FromSeconds(15);
|
||||
// Allow large gift / community-board payloads (default 32 KB
|
||||
// truncates anything bigger and disconnects the client).
|
||||
opts.MaximumReceiveMessageSize = 1024 * 1024;
|
||||
// Surface transport errors so we can tell whether a disconnect
|
||||
// was clean (Traefik idleTimeout) vs framing-level (cloudflared
|
||||
// chunked-encoding mismatch).
|
||||
opts.EnableDetailedErrors = true;
|
||||
});
|
||||
if (!string.IsNullOrWhiteSpace(redisConn))
|
||||
signalR.AddStackExchangeRedis(NormalizeRedisConn(redisConn), opts =>
|
||||
{
|
||||
// Channel prefix keeps the SignalR backplane's pub/sub channels
|
||||
// namespaced from any other Redis tenants in the same instance.
|
||||
opts.Configuration.ChannelPrefix = RedisChannel.Literal("dorknet-signalr");
|
||||
});
|
||||
builder.Services.AddEndpointsApiExplorer();
|
||||
builder.Services.AddSwaggerGen();
|
||||
builder.AddDorkNetServices();
|
||||
|
||||
var app = builder.Build();
|
||||
|
||||
// DomainConfig is the single source of truth for the deployment apex
|
||||
// (DORKNET_DOMAIN). Resolve it once at startup; every downstream
|
||||
// host-mount / redirect / hub binding below uses this instead of
|
||||
// hardcoded rec.net/localhost literals.
|
||||
var domainCfg = app.Services.GetRequiredService<DomainConfig>();
|
||||
|
||||
// Easy Launcher single-origin mode. localtunnel gives us one public
|
||||
// hostname, so the launcher name-server emits URLs like
|
||||
// https://abc.loca.lt/__dn/api/...; this middleware maps those path
|
||||
// prefixes back into the same internal host layout used by advanced
|
||||
// wildcard deployments. If Domain:SingleOriginBaseUrl is unset this is
|
||||
// inert, preserving the normal subdomain behavior.
|
||||
app.Use(async (ctx, next) =>
|
||||
{
|
||||
if (domainCfg.SingleOriginEnabled &&
|
||||
TryMapSingleOriginPath(ctx.Request.Path, out var service, out var rest))
|
||||
{
|
||||
ctx.Request.Host = new HostString(service == "www" ? domainCfg.Apex : domainCfg.Sub(service));
|
||||
ctx.Request.Path = string.IsNullOrEmpty(rest) ? "/" : rest;
|
||||
}
|
||||
|
||||
await next();
|
||||
});
|
||||
|
||||
// Keep endpoint selection after the single-origin path rewrite. Without an
|
||||
// explicit routing point here, WebApplication can auto-insert routing before
|
||||
// the rewrite middleware, causing /__dn/api/... requests to stay route misses
|
||||
// even though the trace logs show the rewritten /api/... path.
|
||||
app.UseRouting();
|
||||
|
||||
// Request/response tracing — emits one structured log per non-health request,
|
||||
// with the response body included for any 4xx/5xx so we can see exactly what
|
||||
// the game is asking for. This runs early enough to catch route misses,
|
||||
// auth failures, storage/CDN host traffic, and controller responses.
|
||||
var reqLogger = app.Services.GetRequiredService<ILoggerFactory>()
|
||||
.CreateLogger("DorkNet.Server.HttpTrace");
|
||||
var playerLog = app.Services.GetRequiredService<DorkNet.Server.Services.PlayerLogService>();
|
||||
app.Use(async (ctx, next) =>
|
||||
{
|
||||
if (IsHealthProbe(ctx.Request))
|
||||
{
|
||||
await next();
|
||||
return;
|
||||
}
|
||||
|
||||
// WebSocket upgrades take over the underlying connection — wrapping the
|
||||
// response body in a MemoryStream confuses the upgrade handshake and
|
||||
// prevents SignalR's WebSocket transport from working. Skip tracing for
|
||||
// upgrade requests and let them flow through unmodified.
|
||||
if (ctx.WebSockets.IsWebSocketRequest)
|
||||
{
|
||||
reqLogger.LogInformation(
|
||||
"[ws] {Method} {Host}{Path}{Query}",
|
||||
ctx.Request.Method, ctx.Request.Host, ctx.Request.Path,
|
||||
ctx.Request.QueryString.Value ?? "");
|
||||
await next();
|
||||
return;
|
||||
}
|
||||
|
||||
var host = ctx.Request.Host.Host;
|
||||
var path = ctx.Request.Path.Value ?? "";
|
||||
var isStorageLike = host.StartsWith("storage.", StringComparison.OrdinalIgnoreCase)
|
||||
|| host.StartsWith("cdn.", StringComparison.OrdinalIgnoreCase)
|
||||
|| path.Contains("upload", StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
if (isStorageLike)
|
||||
{
|
||||
reqLogger.LogInformation(
|
||||
"[req:start] {Method} {Host}{Path}{Query} contentType={ContentType} contentLength={ContentLength}",
|
||||
ctx.Request.Method, ctx.Request.Host, ctx.Request.Path,
|
||||
ctx.Request.QueryString.Value ?? "", ctx.Request.ContentType ?? "",
|
||||
ctx.Request.ContentLength);
|
||||
}
|
||||
|
||||
var sw = System.Diagnostics.Stopwatch.StartNew();
|
||||
var originalBody = ctx.Response.Body;
|
||||
var captureResponse = ShouldCaptureResponse(ctx.Request);
|
||||
MemoryStream? capture = null;
|
||||
if (captureResponse)
|
||||
{
|
||||
capture = new MemoryStream();
|
||||
ctx.Response.Body = capture;
|
||||
}
|
||||
|
||||
string? requestBody = null;
|
||||
if (ctx.Request.ContentLength is > 0 and < 4096 && IsTextLikeRequest(ctx.Request.ContentType))
|
||||
{
|
||||
ctx.Request.EnableBuffering();
|
||||
using var reader = new StreamReader(ctx.Request.Body, leaveOpen: true);
|
||||
requestBody = await reader.ReadToEndAsync();
|
||||
ctx.Request.Body.Position = 0;
|
||||
}
|
||||
|
||||
Exception? thrown = null;
|
||||
try
|
||||
{
|
||||
await next();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
thrown = ex;
|
||||
throw;
|
||||
}
|
||||
finally
|
||||
{
|
||||
sw.Stop();
|
||||
var status = thrown is null ? ctx.Response.StatusCode : 500;
|
||||
var responseBody = "";
|
||||
|
||||
if (capture is not null)
|
||||
{
|
||||
try
|
||||
{
|
||||
capture.Seek(0, SeekOrigin.Begin);
|
||||
if (ShouldReadResponseBody(ctx.Response.ContentType, status))
|
||||
responseBody = await new StreamReader(capture, leaveOpen: true).ReadToEndAsync();
|
||||
capture.Seek(0, SeekOrigin.Begin);
|
||||
await capture.CopyToAsync(originalBody);
|
||||
}
|
||||
finally
|
||||
{
|
||||
ctx.Response.Body = originalBody;
|
||||
await capture.DisposeAsync();
|
||||
}
|
||||
}
|
||||
|
||||
var trimmedRequest = requestBody is null ? null
|
||||
: (requestBody.Length > 200 ? requestBody[..200] + "..." : requestBody);
|
||||
var trimmedResponse = responseBody.Length > 300 ? responseBody[..300] + "..." : responseBody;
|
||||
var level = thrown is not null || status >= 500 ? LogLevel.Error
|
||||
: status >= 400 ? LogLevel.Warning
|
||||
: LogLevel.Information;
|
||||
|
||||
reqLogger.Log(level, thrown,
|
||||
"[req] {Status} {Method} {Host}{Path}{Query} {ElapsedMs}ms contentType={ContentType} contentLength={ContentLength} req={ReqBody} resp={RespBody}",
|
||||
status, ctx.Request.Method, ctx.Request.Host, ctx.Request.Path,
|
||||
ctx.Request.QueryString.Value ?? "", sw.ElapsedMilliseconds,
|
||||
ctx.Request.ContentType ?? "", ctx.Request.ContentLength,
|
||||
trimmedRequest ?? "", trimmedResponse);
|
||||
|
||||
// Per-player log — only record when we know who made the call.
|
||||
var pid = DorkNet.Server.Auth.ControllerBaseExtensions.CurrentPlayerId(ctx.User);
|
||||
if (pid is long playerId)
|
||||
{
|
||||
playerLog.Record(new DorkNet.Server.Services.PlayerLogEntry
|
||||
{
|
||||
Timestamp = DateTime.UtcNow,
|
||||
PlayerId = playerId,
|
||||
Method = ctx.Request.Method,
|
||||
Host = ctx.Request.Host.Host,
|
||||
Path = ctx.Request.Path.Value ?? string.Empty,
|
||||
Query = ctx.Request.QueryString.Value ?? string.Empty,
|
||||
Status = status,
|
||||
ElapsedMs = sw.ElapsedMilliseconds,
|
||||
ReqBody = trimmedRequest,
|
||||
RespBody = trimmedResponse,
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// ── Create/repair DB ──────────────────────────────────────────────────────────
|
||||
// Local launcher installs use SQLite as an appliance database. For that path,
|
||||
// create the current EF model directly instead of replaying the migration chain;
|
||||
// then run small idempotent repair patches for DBs made by older launcher builds.
|
||||
// Hosted Postgres keeps its existing EnsureCreated + explicit patch path below.
|
||||
using (var scope = app.Services.CreateScope())
|
||||
{
|
||||
var db = scope.ServiceProvider.GetRequiredService<DorkNetDbContext>();
|
||||
if (db.Database.IsSqlite())
|
||||
{
|
||||
db.Database.EnsureCreated();
|
||||
ApplySqliteCompatibilityPatches(db);
|
||||
// New tables that post-date this DB's EnsureCreated snapshot —
|
||||
// EnsureCreated never revisits an existing file, so create them
|
||||
// idempotently (matches the Postgres patch block below).
|
||||
await db.Database.ExecuteSqlRawAsync(
|
||||
@"CREATE TABLE IF NOT EXISTS ""SignupCodes"" (
|
||||
""Id"" INTEGER NOT NULL CONSTRAINT ""PK_SignupCodes"" PRIMARY KEY AUTOINCREMENT,
|
||||
""Code"" TEXT NOT NULL,
|
||||
""Descriptor"" TEXT NOT NULL DEFAULT '',
|
||||
""CreatedByPlayerId"" INTEGER NOT NULL DEFAULT 0,
|
||||
""CreatedAt"" TEXT NOT NULL,
|
||||
""ExpiresAt"" TEXT NULL,
|
||||
""RedeemedByPlayerId"" INTEGER NULL,
|
||||
""RedeemedAt"" TEXT NULL,
|
||||
""Revoked"" INTEGER NOT NULL DEFAULT 0
|
||||
);");
|
||||
await db.Database.ExecuteSqlRawAsync(
|
||||
@"CREATE UNIQUE INDEX IF NOT EXISTS ""IX_SignupCodes_Code"" ON ""SignupCodes"" (""Code"");");
|
||||
await db.Database.ExecuteSqlRawAsync(
|
||||
@"CREATE TABLE IF NOT EXISTS ""PendingDevices"" (
|
||||
""Id"" INTEGER NOT NULL CONSTRAINT ""PK_PendingDevices"" PRIMARY KEY AUTOINCREMENT,
|
||||
""DeviceId"" TEXT NOT NULL,
|
||||
""Platform"" INTEGER NOT NULL DEFAULT 0,
|
||||
""PlatformId"" TEXT NOT NULL DEFAULT '',
|
||||
""LastIp"" TEXT NULL,
|
||||
""FirstSeenAt"" TEXT NOT NULL,
|
||||
""LastSeenAt"" TEXT NOT NULL
|
||||
);");
|
||||
await db.Database.ExecuteSqlRawAsync(
|
||||
@"CREATE UNIQUE INDEX IF NOT EXISTS ""IX_PendingDevices_DeviceId"" ON ""PendingDevices"" (""DeviceId"");");
|
||||
}
|
||||
else
|
||||
{
|
||||
// Postgres path: schema generated from the EF Core model via
|
||||
// EnsureCreated. Wrapped in a transaction-scoped advisory lock
|
||||
// so concurrent replicas booting against the same database
|
||||
// don't race on CREATE TABLE — only the first replica to take
|
||||
// the lock runs EnsureCreated, the rest see "schema already
|
||||
// exists" and proceed.
|
||||
//
|
||||
// Lock key 0x444F524B (ascii "DORK") is hard-coded; it just
|
||||
// needs to be the same int64 across replicas and not collide
|
||||
// with anything else in the database. pg_advisory_xact_lock
|
||||
// releases automatically on COMMIT, so a crashed replica can't
|
||||
// wedge the next boot.
|
||||
//
|
||||
// Future schema changes will swap EnsureCreated for
|
||||
// db.Database.Migrate() once we cut a proper Postgres
|
||||
// migrations set; the advisory-lock wrapping stays the same.
|
||||
var conn = db.Database.GetDbConnection();
|
||||
|
||||
// Retry initial Postgres connect — on container-orchestrated boots
|
||||
// (Coolify, docker-compose) the Postgres service's DNS often isn't
|
||||
// resolvable yet when this app starts, surfacing as SocketException
|
||||
// errno=11 EAGAIN from Npgsql's Dns.GetHostEntryOrAddressesCore.
|
||||
// Postgres itself can also be up-but-not-accepting-connections for
|
||||
// a few seconds while it replays WAL. 15 attempts × ~2s backoff
|
||||
// covers the typical 5-30s warmup window without looping forever
|
||||
// on a permanently broken config.
|
||||
var connectLogger = app.Services.GetRequiredService<ILoggerFactory>()
|
||||
.CreateLogger("DorkNet.Server.Bootstrap");
|
||||
for (var attempt = 1; ; attempt++)
|
||||
{
|
||||
try { await conn.OpenAsync(); break; }
|
||||
catch (Exception ex) when (attempt < 15 &&
|
||||
(ex is System.Net.Sockets.SocketException
|
||||
|| ex is Npgsql.NpgsqlException
|
||||
|| ex is System.Data.Common.DbException))
|
||||
{
|
||||
var delay = TimeSpan.FromSeconds(Math.Min(attempt, 5));
|
||||
connectLogger.LogWarning(
|
||||
"[bootstrap] Postgres connect attempt {Attempt}/15 failed ({Type}: {Message}); retrying in {Delay}s",
|
||||
attempt, ex.GetType().Name, ex.Message, delay.TotalSeconds);
|
||||
await Task.Delay(delay);
|
||||
}
|
||||
}
|
||||
await using var tx = await conn.BeginTransactionAsync();
|
||||
await using (var lockCmd = conn.CreateCommand())
|
||||
{
|
||||
lockCmd.Transaction = tx;
|
||||
lockCmd.CommandText = "SELECT pg_advisory_xact_lock(1146246987);";
|
||||
await lockCmd.ExecuteNonQueryAsync();
|
||||
}
|
||||
db.Database.EnsureCreated();
|
||||
await tx.CommitAsync();
|
||||
|
||||
// ── Post-EnsureCreated schema patches (Postgres only) ─────────
|
||||
// EnsureCreated only fires once per database — it lays down the
|
||||
// initial schema and never revisits it on later deploys. Any
|
||||
// model change after the first boot (column added, NOT NULL
|
||||
// dropped, etc.) leaves prod stuck on the bootstrap schema.
|
||||
// EF migrations in this codebase are SQLite-only; Postgres
|
||||
// schema drift has to be patched explicitly here.
|
||||
//
|
||||
// Each patch is idempotent — Postgres treats redundant
|
||||
// ALTER COLUMN statements as no-ops, so safe on every boot.
|
||||
// Add new patches at the bottom of this block; never remove
|
||||
// older ones (older databases still need them when they catch
|
||||
// up).
|
||||
var bootstrapLogger = app.Services.GetRequiredService<ILoggerFactory>()
|
||||
.CreateLogger("DorkNet.Server.Bootstrap");
|
||||
|
||||
async Task RunPatchAsync(string label, string sql)
|
||||
{
|
||||
await using var cmd = conn.CreateCommand();
|
||||
cmd.CommandText = sql;
|
||||
try { await cmd.ExecuteNonQueryAsync(); }
|
||||
catch (Exception ex)
|
||||
{
|
||||
bootstrapLogger.LogWarning(ex, "[schema-patch] {Label} failed", label);
|
||||
}
|
||||
}
|
||||
|
||||
// 2026-05-16 — RoomDataBlobs.Bytes nullable. The storage
|
||||
// backfill (admin → Storage panel) needs to UPDATE this
|
||||
// column to NULL after streaming the bytes to S3.
|
||||
await RunPatchAsync("RoomDataBlobs.Bytes nullable",
|
||||
@"ALTER TABLE ""RoomDataBlobs"" ALTER COLUMN ""Bytes"" DROP NOT NULL;");
|
||||
|
||||
// 2026-05-17 — ChatThreads / ChatThreadMembers added for
|
||||
// group chats + per-thread snooze/last-read pointers. Both
|
||||
// tables are EnsureCreated-skipped on existing DBs; recreate
|
||||
// them idempotently here. Column order + types must match
|
||||
// the entity classes (see Data/Entities/ChatThread*.cs).
|
||||
await RunPatchAsync("ChatThreads table",
|
||||
@"CREATE TABLE IF NOT EXISTS ""ChatThreads"" (
|
||||
""Id"" bigserial PRIMARY KEY,
|
||||
""ThreadKey"" varchar(96) NOT NULL,
|
||||
""Name"" varchar(128) NOT NULL DEFAULT '',
|
||||
""CreatorPlayerId"" bigint NOT NULL,
|
||||
""CreatedAt"" timestamp with time zone NOT NULL DEFAULT now(),
|
||||
""UpdatedAt"" timestamp with time zone NOT NULL DEFAULT now()
|
||||
);");
|
||||
await RunPatchAsync("ChatThreads ThreadKey unique index",
|
||||
@"CREATE UNIQUE INDEX IF NOT EXISTS ""IX_ChatThreads_ThreadKey""
|
||||
ON ""ChatThreads"" (""ThreadKey"");");
|
||||
await RunPatchAsync("ChatThreadMembers table",
|
||||
@"CREATE TABLE IF NOT EXISTS ""ChatThreadMembers"" (
|
||||
""Id"" bigserial PRIMARY KEY,
|
||||
""ThreadKey"" text NOT NULL,
|
||||
""PlayerId"" bigint NOT NULL,
|
||||
""JoinedAt"" timestamp with time zone NOT NULL DEFAULT now(),
|
||||
""SnoozeUntil"" timestamp with time zone NULL,
|
||||
""LastReadMessageId"" bigint NULL
|
||||
);");
|
||||
await RunPatchAsync("ChatThreadMembers (ThreadKey, PlayerId) unique index",
|
||||
@"CREATE UNIQUE INDEX IF NOT EXISTS ""IX_ChatThreadMembers_ThreadKey_PlayerId""
|
||||
ON ""ChatThreadMembers"" (""ThreadKey"", ""PlayerId"");");
|
||||
await RunPatchAsync("ChatThreadMembers PlayerId index",
|
||||
@"CREATE INDEX IF NOT EXISTS ""IX_ChatThreadMembers_PlayerId""
|
||||
ON ""ChatThreadMembers"" (""PlayerId"");");
|
||||
|
||||
// 2026-05-17 — Relationships favorite/mute/ignore columns
|
||||
// for v1/favorite, v1/mute, v1/ignore persistence. Default
|
||||
// false on every row; existing relationships keep no
|
||||
// preference set.
|
||||
await RunPatchAsync("Relationships.Favorited column",
|
||||
@"ALTER TABLE ""Relationships""
|
||||
ADD COLUMN IF NOT EXISTS ""Favorited"" boolean NOT NULL DEFAULT false;");
|
||||
await RunPatchAsync("Relationships.Muted column",
|
||||
@"ALTER TABLE ""Relationships""
|
||||
ADD COLUMN IF NOT EXISTS ""Muted"" boolean NOT NULL DEFAULT false;");
|
||||
await RunPatchAsync("Relationships.Ignored column",
|
||||
@"ALTER TABLE ""Relationships""
|
||||
ADD COLUMN IF NOT EXISTS ""Ignored"" boolean NOT NULL DEFAULT false;");
|
||||
|
||||
// 2026-05-17 — Widen StoreItems.Slug to 128 chars to fit
|
||||
// wardrobe-colored-{guid}-{color} SKUs. Postgres ALTER COLUMN
|
||||
// is fast (metadata-only); idempotent if already 128+.
|
||||
await RunPatchAsync("StoreItems.Slug widen to 128",
|
||||
@"ALTER TABLE ""StoreItems"" ALTER COLUMN ""Slug"" TYPE varchar(128);");
|
||||
|
||||
// 2026-05-17 — Messages.Type column for RecNet.MessageType
|
||||
// round-tripping (RequestGameInvite = 10, GameInvite = 0,
|
||||
// TextMessage = 30, etc.). Before this column existed every
|
||||
// message came back to the watch as Type=30, breaking the
|
||||
// join-request flow.
|
||||
await RunPatchAsync("Messages.Type column",
|
||||
@"ALTER TABLE ""Messages""
|
||||
ADD COLUMN IF NOT EXISTS ""Type"" integer NOT NULL DEFAULT 30;");
|
||||
await RunPatchAsync("Messages.RoomId column",
|
||||
@"ALTER TABLE ""Messages""
|
||||
ADD COLUMN IF NOT EXISTS ""RoomId"" bigint NULL;");
|
||||
|
||||
// 2026-05-20 — PrivateInstanceInvitees.LatestInviteMessageId.
|
||||
// Lets /goto/invite/{messageId} resolve the roomInstanceId via
|
||||
// the invitee row when the watch's accept flow has already
|
||||
// raced through DELETE /api/messages/v3/delete and the Message
|
||||
// row is gone. Pre-patch the server returned ErrorCode 40
|
||||
// ("invite expired") for accepts where DELETE landed first.
|
||||
await RunPatchAsync("PrivateInstanceInvitees.LatestInviteMessageId column",
|
||||
@"ALTER TABLE ""PrivateInstanceInvitees""
|
||||
ADD COLUMN IF NOT EXISTS ""LatestInviteMessageId"" bigint NULL;");
|
||||
|
||||
// 2026-05-20 — coerce every existing PrivateInstance row to
|
||||
// match the current Photon:CloudRegion. Older rows still carry
|
||||
// whatever region was in config when their dorm was first
|
||||
// registered; without this rewrite, an invitee whose
|
||||
// /goto/invite resolves to one of those rows would be sent to
|
||||
// the stale region while the inviter's most recent
|
||||
// /goto/room/DormRoom puts them on the current region — two
|
||||
// parallel Photon rooms, players can't see each other. The
|
||||
// EnsureForDormAsync update path now refreshes PhotonRegion on
|
||||
// each owner /goto, so going forward this drift is impossible;
|
||||
// this one-shot UPDATE just cleans up the pre-fix backlog.
|
||||
// Idempotent — the WHERE skips rows already on the right
|
||||
// region.
|
||||
var currentRegion = (app.Configuration["Photon:CloudRegion"] ?? "us").ToLowerInvariant();
|
||||
await RunPatchAsync($"PrivateInstances.PhotonRegion → {currentRegion}",
|
||||
$@"UPDATE ""PrivateInstances""
|
||||
SET ""PhotonRegion"" = '{currentRegion}'
|
||||
WHERE ""PhotonRegion"" IS DISTINCT FROM '{currentRegion}';");
|
||||
|
||||
// 2026-05-18 — CommunityBoardRows table for the dorm community
|
||||
// board (FeaturedPlayer/Announcement/InstagramImages/Videos).
|
||||
// EnsureCreated skips this on databases created before the
|
||||
// entity was added; without this patch the
|
||||
// /api/communityboard/v1/current endpoint silently returns
|
||||
// DefaultState because the row table doesn't exist (or the
|
||||
// row was never persisted) and admins see an empty board.
|
||||
await RunPatchAsync("CommunityBoardRows table",
|
||||
@"CREATE TABLE IF NOT EXISTS ""CommunityBoardRows"" (
|
||||
""Id"" integer PRIMARY KEY,
|
||||
""Json"" text NOT NULL DEFAULT '{}',
|
||||
""UpdatedAt"" timestamp with time zone NOT NULL DEFAULT now()
|
||||
);");
|
||||
|
||||
// 2026-05-19 — ServerSettings single-row table backing the
|
||||
// admin SPA's runtime toggles (currently just SignupsDisabled).
|
||||
// Same EnsureCreated-skip story as CommunityBoardRows above:
|
||||
// existing prod DBs won't get the table without this idempotent
|
||||
// patch.
|
||||
await RunPatchAsync("ServerSettings table",
|
||||
@"CREATE TABLE IF NOT EXISTS ""ServerSettings"" (
|
||||
""Id"" integer PRIMARY KEY,
|
||||
""SignupsDisabled"" boolean NOT NULL DEFAULT false,
|
||||
""UpdatedAt"" timestamp with time zone NOT NULL DEFAULT now()
|
||||
);");
|
||||
|
||||
// 2026-05-31 — Weekly-challenge config columns on ServerSettings.
|
||||
// Back the admin SPA's editable weekly slate + gift (XP/tokens +
|
||||
// an optional store skin granted on completion). EnsureCreated-
|
||||
// skipped on existing DBs, so add idempotently.
|
||||
await RunPatchAsync("ServerSettings.WeeklyChallengesCompletedRequired column",
|
||||
@"ALTER TABLE ""ServerSettings""
|
||||
ADD COLUMN IF NOT EXISTS ""WeeklyChallengesCompletedRequired"" boolean NOT NULL DEFAULT true;");
|
||||
await RunPatchAsync("ServerSettings.WeeklyChallengesJson column",
|
||||
@"ALTER TABLE ""ServerSettings""
|
||||
ADD COLUMN IF NOT EXISTS ""WeeklyChallengesJson"" text NOT NULL DEFAULT '';");
|
||||
await RunPatchAsync("ServerSettings.WeeklyChallengeRewardJson column",
|
||||
@"ALTER TABLE ""ServerSettings""
|
||||
ADD COLUMN IF NOT EXISTS ""WeeklyChallengeRewardJson"" text NOT NULL DEFAULT '';");
|
||||
// 2026-06-04 — "everyone is friends" admin toggle (synthesized at
|
||||
// read time; see ServerSettingsEntity.GlobalFriendsEnabled).
|
||||
await RunPatchAsync("ServerSettings.GlobalFriendsEnabled column",
|
||||
@"ALTER TABLE ""ServerSettings""
|
||||
ADD COLUMN IF NOT EXISTS ""GlobalFriendsEnabled"" boolean NOT NULL DEFAULT false;");
|
||||
|
||||
// 2026-05-19 — Players.IsCommunityTeam column. Backs the
|
||||
// overhead-badge admin toggle alongside IsDeveloper; the
|
||||
// RoleController ORs the two so either one unlocks the watch's
|
||||
// in-settings developer-display slider (which renders
|
||||
// "Community Team" / "Developer" above the player's head per
|
||||
// Cpp2IL_ISIL/.../PlayerUI.txt:9085-9099).
|
||||
await RunPatchAsync("Players.IsCommunityTeam column",
|
||||
@"ALTER TABLE ""Players""
|
||||
ADD COLUMN IF NOT EXISTS ""IsCommunityTeam"" boolean NOT NULL DEFAULT false;");
|
||||
|
||||
// 2026-05-20 — HiddenFromBrowse keeps admin-utility rooms
|
||||
// (MakerRoom, EventRoom) and rooms-folded-into-others
|
||||
// (paintball sub-maps, LaserTag Hangar) out of room browse,
|
||||
// search, and the originals feed while leaving /goto + clone
|
||||
// + admin access intact.
|
||||
await RunPatchAsync("Rooms.HiddenFromBrowse column",
|
||||
@"ALTER TABLE ""Rooms""
|
||||
ADD COLUMN IF NOT EXISTS ""HiddenFromBrowse"" boolean NOT NULL DEFAULT false;");
|
||||
|
||||
// 2026-05-20 — RoomRoles table for per-room co-owner / moderator
|
||||
// / host grants. Drives RoomDetails.CoOwners / Moderators / Hosts
|
||||
// arrays (and InvitedCoOwners / InvitedModerators / InvitedHosts
|
||||
// when Accepted=false). Unique on (RoomId, PlayerId, Role) so
|
||||
// granting the same role twice is a no-op.
|
||||
await RunPatchAsync("RoomRoles table",
|
||||
@"CREATE TABLE IF NOT EXISTS ""RoomRoles"" (
|
||||
""Id"" bigserial PRIMARY KEY,
|
||||
""RoomId"" bigint NOT NULL,
|
||||
""PlayerId"" bigint NOT NULL,
|
||||
""Role"" integer NOT NULL,
|
||||
""Accepted"" boolean NOT NULL DEFAULT true,
|
||||
""GrantedByPlayerId"" bigint NULL,
|
||||
""GrantedAt"" timestamp with time zone NOT NULL DEFAULT now()
|
||||
);");
|
||||
await RunPatchAsync("RoomRoles (RoomId, PlayerId, Role) unique index",
|
||||
@"CREATE UNIQUE INDEX IF NOT EXISTS ""IX_RoomRoles_RoomId_PlayerId_Role""
|
||||
ON ""RoomRoles"" (""RoomId"", ""PlayerId"", ""Role"");");
|
||||
await RunPatchAsync("RoomRoles RoomId index",
|
||||
@"CREATE INDEX IF NOT EXISTS ""IX_RoomRoles_RoomId"" ON ""RoomRoles"" (""RoomId"");");
|
||||
|
||||
// 2026-05-20 — LeaderboardChannelMeta lets admins map any
|
||||
// StatChannel int the watch reports to (room, name, sort
|
||||
// direction). Drives the per-room Leaderboards tab in the
|
||||
// admin SPA. KnownStatChannels in AdminController stays as
|
||||
// hardcoded fallback for channels with no row here.
|
||||
await RunPatchAsync("LeaderboardChannelMeta table",
|
||||
@"CREATE TABLE IF NOT EXISTS ""LeaderboardChannelMeta"" (
|
||||
""Channel"" integer PRIMARY KEY,
|
||||
""RoomId"" bigint NOT NULL DEFAULT 0,
|
||||
""Name"" varchar(128) NOT NULL DEFAULT '',
|
||||
""LowerIsBetter"" boolean NOT NULL DEFAULT false,
|
||||
""ValueFormat"" varchar(32) NOT NULL DEFAULT 'count',
|
||||
""CreatedAt"" timestamp with time zone NOT NULL DEFAULT now(),
|
||||
""UpdatedAt"" timestamp with time zone NOT NULL DEFAULT now()
|
||||
);");
|
||||
await RunPatchAsync("LeaderboardChannelMeta RoomId index",
|
||||
@"CREATE INDEX IF NOT EXISTS ""IX_LeaderboardChannelMeta_RoomId""
|
||||
ON ""LeaderboardChannelMeta"" (""RoomId"");");
|
||||
|
||||
// 2026-05-17 — LoadingScreenTips table for admin-editable
|
||||
// dorm-load splash tips with optional uploaded images.
|
||||
await RunPatchAsync("LoadingScreenTips table",
|
||||
@"CREATE TABLE IF NOT EXISTS ""LoadingScreenTips"" (
|
||||
""Id"" bigserial PRIMARY KEY,
|
||||
""Title"" varchar(128) NOT NULL DEFAULT '',
|
||||
""Message"" varchar(512) NOT NULL DEFAULT '',
|
||||
""ImageName"" varchar(128) NOT NULL DEFAULT '',
|
||||
""Context"" int NOT NULL DEFAULT 0,
|
||||
""PlatformMask"" int NOT NULL DEFAULT -1,
|
||||
""RoomNamesCsv"" varchar(512) NOT NULL DEFAULT '',
|
||||
""SortOrder"" int NOT NULL DEFAULT 0,
|
||||
""IsActive"" boolean NOT NULL DEFAULT true,
|
||||
""CreatedAt"" timestamp with time zone NOT NULL DEFAULT now(),
|
||||
""UpdatedAt"" timestamp with time zone NOT NULL DEFAULT now()
|
||||
);");
|
||||
|
||||
// 2026-05-31 — Signup codes + pending-device capture for the
|
||||
// admin-issued invite flow (site /join). New tables, so
|
||||
// EnsureCreated-skipped on existing DBs.
|
||||
await RunPatchAsync("SignupCodes table",
|
||||
@"CREATE TABLE IF NOT EXISTS ""SignupCodes"" (
|
||||
""Id"" bigserial PRIMARY KEY,
|
||||
""Code"" text NOT NULL,
|
||||
""Descriptor"" text NOT NULL DEFAULT '',
|
||||
""CreatedByPlayerId"" bigint NOT NULL DEFAULT 0,
|
||||
""CreatedAt"" timestamp with time zone NOT NULL DEFAULT now(),
|
||||
""ExpiresAt"" timestamp with time zone NULL,
|
||||
""RedeemedByPlayerId"" bigint NULL,
|
||||
""RedeemedAt"" timestamp with time zone NULL,
|
||||
""Revoked"" boolean NOT NULL DEFAULT false
|
||||
);");
|
||||
await RunPatchAsync("SignupCodes Code unique index",
|
||||
@"CREATE UNIQUE INDEX IF NOT EXISTS ""IX_SignupCodes_Code"" ON ""SignupCodes"" (""Code"");");
|
||||
await RunPatchAsync("PendingDevices table",
|
||||
@"CREATE TABLE IF NOT EXISTS ""PendingDevices"" (
|
||||
""Id"" bigserial PRIMARY KEY,
|
||||
""DeviceId"" text NOT NULL,
|
||||
""Platform"" integer NOT NULL DEFAULT 0,
|
||||
""PlatformId"" text NOT NULL DEFAULT '',
|
||||
""LastIp"" text NULL,
|
||||
""FirstSeenAt"" timestamp with time zone NOT NULL DEFAULT now(),
|
||||
""LastSeenAt"" timestamp with time zone NOT NULL DEFAULT now()
|
||||
);");
|
||||
await RunPatchAsync("PendingDevices DeviceId unique index",
|
||||
@"CREATE UNIQUE INDEX IF NOT EXISTS ""IX_PendingDevices_DeviceId"" ON ""PendingDevices"" (""DeviceId"");");
|
||||
|
||||
await conn.CloseAsync();
|
||||
}
|
||||
|
||||
// Coach system account at Player.Id=1. The RR-Original room seeder
|
||||
// below assigns CreatorPlayerId=1 to every canonical room
|
||||
// (RecCenter, Paintball, Dodgeball, etc.), so the FK target needs
|
||||
// to exist before we insert rooms — otherwise admin tools' "rooms
|
||||
// by player N" queries return rows whose CreatorPlayerId points to
|
||||
// a non-existent Player. Idempotent: the call no-ops once the row
|
||||
// is there. Must run BEFORE roomService.SeedAsync().
|
||||
var playerService = scope.ServiceProvider.GetRequiredService<PlayerService>();
|
||||
await playerService.EnsureSystemAccountAsync();
|
||||
// Self-heal the AvatarEntity seed on every startup so accounts
|
||||
// created with old/broken starter values get the current safe
|
||||
// GUIDs without needing a one-shot EF migration. Idempotent —
|
||||
// only updates rows that need it (new accounts, accounts with
|
||||
// the old mask/swatch starter, and any orphan empty rows).
|
||||
await playerService.EnsureAvatarSeedAsync();
|
||||
|
||||
// Grant the starter RecCenterTokens balance to any account that
|
||||
// doesn't yet have a CurrencyType=2 row. Pre-existing accounts
|
||||
// (created before this seed shipped) booted into a 0-token wallet
|
||||
// and saw "Not enough tokens" on every store tile — this brings
|
||||
// them up to the same starting state new accounts get. Idempotent:
|
||||
// only inserts where the row is absent, never overwrites a row
|
||||
// that's already there (so spent-down accounts aren't refilled).
|
||||
await playerService.BackfillStarterWalletsAsync();
|
||||
|
||||
// Backfill the per-player dorm room + DormStateEntity for any
|
||||
// legacy account that signed in via a path that didn't run
|
||||
// EnsurePersonalDormAsync (early-bird signups, manual SQL
|
||||
// imports). Without these rows the watch's
|
||||
// /api/rooms/v4/details/{dormId} → cdn.localhost/room/{blobName}
|
||||
// chain hits an empty CurrentDataBlobName which the CDN serves
|
||||
// as the default-dorm bytes — but only if the dorm room itself
|
||||
// exists. This call guarantees both rows for every existing
|
||||
// account before traffic starts. Runs after EnsureSystemAccount
|
||||
// so Coach is excluded.
|
||||
var roomService = scope.ServiceProvider.GetRequiredService<RoomService>();
|
||||
await roomService.EnsureDormsForAllPlayersAsync();
|
||||
|
||||
// Seed canonical Rec Room Original rooms so the watch's "Trending" tab
|
||||
// has content on first launch. Idempotent — RoomService.SeedAsync
|
||||
// bails if any rooms already exist.
|
||||
await roomService.SeedAsync();
|
||||
|
||||
// Canonical room overrides: rename BloodMoon → Crescendo, fold the
|
||||
// paintball-map standalone rooms (River/Homestead/Quarry/Clearcut/
|
||||
// Spillway/Drive-In) into Paintball as sub-rooms, fold LaserTagHangar
|
||||
// into LaserTag, hide MakerRoom + EventRoom from browse, and pull
|
||||
// down the user-supplied thumbnails for Crescendo + Paintball.
|
||||
// Idempotent — re-running on an already-overridden DB no-ops.
|
||||
using (var http = new HttpClient { Timeout = TimeSpan.FromSeconds(10) })
|
||||
{
|
||||
var imagesDir = Path.Combine(AppContext.BaseDirectory, "data", "images");
|
||||
await roomService.ApplyCanonicalOverridesAsync(http, imagesDir);
|
||||
}
|
||||
|
||||
// Seed the default store catalog so the Shop tab is populated on
|
||||
// first boot. Idempotent — only inserts items whose slug isn't
|
||||
// already present.
|
||||
var storeService = scope.ServiceProvider.GetRequiredService<StoreService>();
|
||||
await storeService.SeedAsync();
|
||||
|
||||
// Tell the /healthz probe migrations are done. Coolify's rolling
|
||||
// deploy will hold traffic at the LB until this flips and the
|
||||
// healthz response goes 503 → 200.
|
||||
DorkNet.Server.Controllers.Health.HealthController.MigrationsComplete = true;
|
||||
}
|
||||
|
||||
// ── Middleware ────────────────────────────────────────────────────────────────
|
||||
if (app.Environment.IsDevelopment())
|
||||
{
|
||||
app.UseSwagger();
|
||||
app.UseSwaggerUI();
|
||||
}
|
||||
|
||||
// HTTP → HTTPS redirect — only kicks in when an HTTPS endpoint is configured
|
||||
// in appsettings (Kestrel-direct mode). When fronted by nginx, both endpoints
|
||||
// are effectively HTTP from Kestrel's perspective and this is a no-op.
|
||||
app.UseHttpsRedirection();
|
||||
|
||||
// 2-minute WebSocket pings are too lazy for cloudflared — the tunnel
|
||||
// kills the underlying TCP stream after ~90-100s of silence even
|
||||
// though SignalR's app-layer pings would arrive on schedule. Send a
|
||||
// ping frame every 30s so the tunnel always sees fresh traffic; the
|
||||
// ping payload is tiny, and SignalR's own KeepAliveInterval (15s)
|
||||
// stays under this floor.
|
||||
app.UseWebSockets(new WebSocketOptions { KeepAliveInterval = TimeSpan.FromSeconds(30) });
|
||||
|
||||
// Static admin UI hosted on admin.rec.net (its own subdomain). The
|
||||
// PhysicalFileProvider is rooted at wwwroot/admin so requests like
|
||||
// admin.rec.net/style.css map to wwwroot/admin/style.css — the URL
|
||||
// has no /admin prefix.
|
||||
//
|
||||
// Mounted BEFORE the tracing wrapper because StaticFileMiddleware uses
|
||||
// IHttpResponseBodyFeature.SendFileAsync, which writes directly to the
|
||||
// underlying connection and ignores any Response.Body replacement done
|
||||
// downstream — putting it after tracing would result in the actual
|
||||
// file being sent to the socket, then the tracing middleware copying
|
||||
// its (empty) MemoryStream back over the same body and clobbering it.
|
||||
//
|
||||
// UseWhen (NOT MapWhen) so that requests for paths the admin UI
|
||||
// doesn't serve (e.g. admin.rec.net/api/admin/v1/players) fall through
|
||||
// to the controller pipeline. MapWhen would fork into a parallel
|
||||
// pipeline that terminates without invoking matched endpoints.
|
||||
// Helper: bind a per-host static-files branch. Each subdomain gets its
|
||||
// own PhysicalFileProvider rooted at wwwroot/<subdir>, so requests like
|
||||
// admin.rec.net/style.css map to wwwroot/admin/style.css with no path
|
||||
// prefix munging required.
|
||||
static void MountStaticHost(WebApplication app, string host, string subdir, bool spaFallback = false)
|
||||
{
|
||||
var staticHostLogger = app.Services.GetRequiredService<ILoggerFactory>()
|
||||
.CreateLogger("DorkNet.Server.StaticHost");
|
||||
var root = Path.Combine(app.Environment.ContentRootPath, "wwwroot", subdir);
|
||||
staticHostLogger.LogInformation(
|
||||
"[static-host] {Host} → {Root} (exists: {Exists}, spaFallback: {Spa})",
|
||||
host, root, Directory.Exists(root), spaFallback);
|
||||
if (!Directory.Exists(root)) return;
|
||||
var files = new Microsoft.Extensions.FileProviders.PhysicalFileProvider(root);
|
||||
|
||||
// Use UseDefaultFiles + UseStaticFiles directly rather than
|
||||
// UseFileServer because the latter's nested options initialization
|
||||
// doesn't always propagate the FileProvider correctly when wrapped
|
||||
// inside UseWhen — leading to the static files middleware looking
|
||||
// at the default WebRoot (wwwroot/) instead of our scoped folder
|
||||
// (wwwroot/admin/), which makes /index.html requests fall through
|
||||
// to the API catch-all.
|
||||
var defaultFilesOpts = new DefaultFilesOptions { FileProvider = files };
|
||||
var staticFilesOpts = new StaticFileOptions
|
||||
{
|
||||
FileProvider = files,
|
||||
// Don't let the browser cache admin assets — we iterate on the
|
||||
// HTML/JS/CSS often and a stale cached page silently swallows
|
||||
// every change. Cache-Control: no-cache forces a conditional
|
||||
// GET (If-Modified-Since) on every load; the file is small and
|
||||
// the round-trip is fine for an admin UI used by 1-2 people.
|
||||
OnPrepareResponse = ctx =>
|
||||
{
|
||||
var headers = ctx.Context.Response.Headers;
|
||||
headers.CacheControl = "no-cache, no-store, must-revalidate";
|
||||
headers.Pragma = "no-cache";
|
||||
headers.Expires = "0";
|
||||
},
|
||||
};
|
||||
|
||||
app.UseWhen(ctx => ctx.Request.Host.Host.Equals(host, StringComparison.OrdinalIgnoreCase),
|
||||
branch =>
|
||||
{
|
||||
// Diagnostic: confirm the branch is being entered AND the
|
||||
// FileProvider can resolve the requested file. At Information
|
||||
// level temporarily to debug the admin asset 404s.
|
||||
//
|
||||
// Endpoint-clearing rule: the auto-added UseRouting (which
|
||||
// runs before our middleware in minimal hosting) may have
|
||||
// matched a non-admin endpoint for paths the static-host
|
||||
// should answer — typically NsController.Root for "/" on
|
||||
// admin.localhost and the apex, which would serve the
|
||||
// service-URL-map JSON instead of the SPA's index.html.
|
||||
// StaticFileMiddleware's ValidateNoEndpoint guard skips
|
||||
// serving whenever an endpoint is already matched, so we
|
||||
// clear it for non-API paths to let UseStaticFiles run.
|
||||
//
|
||||
// BUT: /api/* paths MUST keep their matched endpoint —
|
||||
// those are admin/site/feed API calls that the matching
|
||||
// controller (AdminController, SiteController, etc.)
|
||||
// needs to handle. Clearing the endpoint for /api/*
|
||||
// would 404 every admin API call.
|
||||
branch.Use(async (ctx, next) =>
|
||||
{
|
||||
var p = ctx.Request.Path.Value ?? "/";
|
||||
var probe = files.GetFileInfo(p == "/" ? "/index.html" : p);
|
||||
staticHostLogger.LogInformation(
|
||||
"[static-host:{Host}] path={Path} probe={Probe} exists={Exists}",
|
||||
host, p, probe.PhysicalPath ?? "<null>", probe.Exists);
|
||||
if (!p.StartsWith("/api/", StringComparison.OrdinalIgnoreCase))
|
||||
ctx.SetEndpoint(null);
|
||||
await next();
|
||||
});
|
||||
branch.UseDefaultFiles(defaultFilesOpts);
|
||||
branch.UseStaticFiles(staticFilesOpts);
|
||||
|
||||
// Terminal handler — runs AFTER UseStaticFiles. By this point
|
||||
// the request either:
|
||||
// (a) was a static file UseStaticFiles served — Response
|
||||
// already started, we just return.
|
||||
// (b) is an API call we want to fall through to controllers
|
||||
// (e.g. admin.localhost/api/admin/v1/players,
|
||||
// localhost/api/site/v1/feed) — call next().
|
||||
// (c) is a SPA deep link (no extension) we want to answer
|
||||
// with index.html so React Router can re-resolve.
|
||||
// (d) is a missing static asset (has extension, file
|
||||
// gone — e.g. /assets/index-<stalehash>.js after a
|
||||
// redeploy). Return 404 with no body — DO NOT fall
|
||||
// through to controllers. The catch-all used to
|
||||
// answer these with JSON {} which a browser <script>
|
||||
// loader rejects with "disallowed MIME type" and the
|
||||
// page stays broken until the user hard-refreshes.
|
||||
// 404 makes the browser drop its stale index.html
|
||||
// cache and refetch on the next load.
|
||||
branch.Use(async (ctx, next) =>
|
||||
{
|
||||
if (ctx.Response.HasStarted) return;
|
||||
|
||||
var path = ctx.Request.Path.Value ?? "/";
|
||||
|
||||
// /api/* paths are reserved for controllers (admin API,
|
||||
// site API, photos feed API, etc.). Always pass through.
|
||||
if (path.StartsWith("/api/", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
await next();
|
||||
return;
|
||||
}
|
||||
|
||||
// Non-GET methods aren't ours either — let controllers
|
||||
// handle POST/PUT/DELETE on this host.
|
||||
if (ctx.Request.Method != "GET" && ctx.Request.Method != "HEAD")
|
||||
{
|
||||
await next();
|
||||
return;
|
||||
}
|
||||
|
||||
// GET with file extension — UseStaticFiles already tried
|
||||
// and didn't serve, so the file is missing. 404 directly;
|
||||
// don't fall through to controllers (a stale
|
||||
// /assets/<hash>.js can't possibly resolve to anything
|
||||
// useful, and serving HTML for a .js request breaks the
|
||||
// browser's module loader).
|
||||
if (Path.HasExtension(path))
|
||||
{
|
||||
ctx.Response.StatusCode = StatusCodes.Status404NotFound;
|
||||
return;
|
||||
}
|
||||
|
||||
// GET, no extension. SPA fallback if enabled (admin SPA,
|
||||
// public site). Without it (feed.* legacy), 404 so
|
||||
// missing deep links don't silently land on the homepage.
|
||||
if (!spaFallback)
|
||||
{
|
||||
ctx.Response.StatusCode = StatusCodes.Status404NotFound;
|
||||
return;
|
||||
}
|
||||
|
||||
var index = files.GetFileInfo("/index.html");
|
||||
if (!index.Exists)
|
||||
{
|
||||
ctx.Response.StatusCode = StatusCodes.Status404NotFound;
|
||||
return;
|
||||
}
|
||||
|
||||
ctx.Response.ContentType = "text/html; charset=utf-8";
|
||||
var hdrs = ctx.Response.Headers;
|
||||
hdrs.CacheControl = "no-cache, no-store, must-revalidate";
|
||||
hdrs.Pragma = "no-cache";
|
||||
hdrs.Expires = "0";
|
||||
await using var stream = index.CreateReadStream();
|
||||
await stream.CopyToAsync(ctx.Response.Body);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// Per-host static branches — admin SPA, public site, legacy feed.* —
|
||||
// all keyed off the configured apex so a single DORKNET_DOMAIN env
|
||||
// var moves every static host together (admin.{apex}, {apex},
|
||||
// www.{apex}, feed.{apex}).
|
||||
MountStaticHost(app, domainCfg.Sub("admin"), "admin", spaFallback: true);
|
||||
// Public-facing site at the apex (e.g. localhost + www variant).
|
||||
// React-router routes are client-side so spaFallback must be true —
|
||||
// without it /players?q=foo, /photo/123, etc. would 404 on first
|
||||
// load. PublicSiteController owns /api/site/v1/*; everything else
|
||||
// extensionless falls back to index.html.
|
||||
MountStaticHost(app, domainCfg.Apex, "site", spaFallback: true);
|
||||
MountStaticHost(app, $"www.{domainCfg.Apex}", "site", spaFallback: true);
|
||||
// Old feed.* subdomain kept as legacy; new visitors land on the apex.
|
||||
MountStaticHost(app, domainCfg.Sub("feed"), "feed");
|
||||
|
||||
// Friendly redirect from the old api.{apex}/admin path → admin.{apex}.
|
||||
app.Use(async (ctx, next) =>
|
||||
{
|
||||
if (ctx.Request.Host.Host.Equals(domainCfg.Sub("api"), StringComparison.OrdinalIgnoreCase) &&
|
||||
(ctx.Request.Path == "/admin" || ctx.Request.Path.StartsWithSegments("/admin")))
|
||||
{
|
||||
ctx.Response.Redirect($"https://{domainCfg.Sub("admin")}/", permanent: false);
|
||||
return;
|
||||
}
|
||||
await next();
|
||||
});
|
||||
|
||||
// IP-level bans run BEFORE authentication so banned-IP traffic
|
||||
// gets cut off at the earliest point possible — no JWT round-trip,
|
||||
// no DB hit beyond the (small) IpBans lookup.
|
||||
app.UseMiddleware<DorkNet.Server.Auth.IpBanCheckMiddleware>();
|
||||
|
||||
app.UseAuthentication();
|
||||
app.UseAuthorization();
|
||||
// Sits between auth and the controllers — by this point ctx.User has the
|
||||
// validated principal (or is empty for anonymous calls). Bans are enforced
|
||||
// uniformly across every authenticated endpoint without each controller
|
||||
// having to remember to check.
|
||||
app.UseMiddleware<DorkNet.Server.Auth.BanCheckMiddleware>();
|
||||
app.MapControllers();
|
||||
|
||||
// SignalR hub on the notify.* subdomain — RequireHost prevents the
|
||||
// same path from being matched on api.* or other subdomains. The host
|
||||
// is derived from DomainConfig so a single DORKNET_DOMAIN env var
|
||||
// drives where the hub answers; setting DORKNET_DOMAIN=localhost keeps
|
||||
// notify.localhost, rec.net keeps notify.rec.net, etc.
|
||||
// Pin transport to WebSockets-only. Letting SignalR fall back to
|
||||
// ServerSentEvents or LongPolling makes "is the proxy idle?" a
|
||||
// per-poll question instead of a stream-level one — Coolify's
|
||||
// Traefik or Cloudflare's edge can close a poll mid-flight and the
|
||||
// watch's BestHTTP framing layer surfaces it as "TCP Stream closed
|
||||
// unexpectedly" → "check your internet". WebSockets keep one socket
|
||||
// hot with our 10s pings; the proxies all see continuous frame
|
||||
// traffic and never mark it idle.
|
||||
app.MapHub<DorkNet.Server.Hubs.NotifyHub>("/hub/v1", opts =>
|
||||
{
|
||||
opts.Transports = Microsoft.AspNetCore.Http.Connections.HttpTransportType.WebSockets;
|
||||
// SignalR's per-connection idle disconnect — must be > the longest
|
||||
// keepalive interval in the stack (we ping every 10s; 90s lets two
|
||||
// pings be missed before the server gives up).
|
||||
opts.TransportSendTimeout = TimeSpan.FromSeconds(30);
|
||||
}).RequireHost(domainCfg.Sub("notify"));
|
||||
|
||||
await app.RunDatabaseBootstrapAsync();
|
||||
app.UseDorkNetPipeline();
|
||||
app.Run();
|
||||
|
||||
static bool TryMapSingleOriginPath(PathString path, out string service, out PathString rest)
|
||||
{
|
||||
service = "";
|
||||
rest = PathString.Empty;
|
||||
if (!path.StartsWithSegments("/__dn", out var tail)) return false;
|
||||
|
||||
var value = tail.Value ?? "";
|
||||
if (value.Length <= 1) return false;
|
||||
var slash = value.IndexOf('/', 1);
|
||||
service = slash < 0 ? value[1..] : value[1..slash];
|
||||
if (string.IsNullOrWhiteSpace(service)) return false;
|
||||
|
||||
rest = slash < 0 ? "/" : value[slash..];
|
||||
return true;
|
||||
}
|
||||
|
||||
static bool IsHealthProbe(HttpRequest request)
|
||||
{
|
||||
return request.Path.Equals("/healthz", StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
static bool IsTextLikeRequest(string? contentType)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(contentType)) return false;
|
||||
return contentType.StartsWith("application/json", StringComparison.OrdinalIgnoreCase)
|
||||
|| contentType.StartsWith("application/x-www-form-urlencoded", StringComparison.OrdinalIgnoreCase)
|
||||
|| contentType.StartsWith("text/", StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
static bool ShouldCaptureResponse(HttpRequest request)
|
||||
{
|
||||
var host = request.Host.Host;
|
||||
if (host.StartsWith("cdn.", StringComparison.OrdinalIgnoreCase))
|
||||
return false;
|
||||
|
||||
var path = request.Path.Value ?? "";
|
||||
var ext = Path.GetExtension(path);
|
||||
if (string.IsNullOrEmpty(ext))
|
||||
return true;
|
||||
|
||||
return ext.Equals(".json", StringComparison.OrdinalIgnoreCase)
|
||||
|| ext.Equals(".txt", StringComparison.OrdinalIgnoreCase)
|
||||
|| ext.Equals(".log", StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
static bool ShouldReadResponseBody(string? contentType, int status)
|
||||
{
|
||||
if (status < 400) return false;
|
||||
if (string.IsNullOrWhiteSpace(contentType)) return true;
|
||||
return contentType.StartsWith("application/json", StringComparison.OrdinalIgnoreCase)
|
||||
|| contentType.StartsWith("text/", StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
// One-shot upgrade helper: when the SQLite db already contains tables but
|
||||
// has no __EFMigrationsHistory entries (i.e. it was created by the legacy
|
||||
// `EnsureCreated()` path before we shipped migrations), insert a history
|
||||
// row for the Initial migration so Migrate() treats Initial as
|
||||
// already-applied and runs only the subsequent column-add migrations.
|
||||
//
|
||||
// Detection: `Players` table exists AND __EFMigrationsHistory has zero
|
||||
// rows. After this runs once, the inserted row keeps it from firing again.
|
||||
//
|
||||
// Safe to leave in indefinitely — on a fresh DB the `Players` table won't
|
||||
// exist yet, so the helper short-circuits and Migrate() does the full
|
||||
// CreateTable run as normal.
|
||||
static void BaselineExistingSchemaIfNeeded(DorkNetDbContext db)
|
||||
{
|
||||
using var conn = db.Database.GetDbConnection();
|
||||
conn.Open();
|
||||
|
||||
using (var probe = conn.CreateCommand())
|
||||
{
|
||||
probe.CommandText =
|
||||
"SELECT name FROM sqlite_master WHERE type='table' AND name='Players';";
|
||||
var hasPlayers = probe.ExecuteScalar() is not null;
|
||||
if (!hasPlayers) return; // Fresh DB — let Migrate() do its full thing.
|
||||
}
|
||||
|
||||
using (var historyProbe = conn.CreateCommand())
|
||||
{
|
||||
historyProbe.CommandText =
|
||||
"SELECT name FROM sqlite_master WHERE type='table' AND name='__EFMigrationsHistory';";
|
||||
var historyExists = historyProbe.ExecuteScalar() is not null;
|
||||
if (historyExists)
|
||||
{
|
||||
using var countCmd = conn.CreateCommand();
|
||||
countCmd.CommandText = "SELECT COUNT(*) FROM __EFMigrationsHistory;";
|
||||
var existingRows = Convert.ToInt64(countCmd.ExecuteScalar());
|
||||
if (existingRows > 0) return; // Already on migrations — nothing to do.
|
||||
}
|
||||
else
|
||||
{
|
||||
using var createHistory = conn.CreateCommand();
|
||||
createHistory.CommandText = """
|
||||
CREATE TABLE "__EFMigrationsHistory" (
|
||||
"MigrationId" TEXT NOT NULL CONSTRAINT "PK___EFMigrationsHistory" PRIMARY KEY,
|
||||
"ProductVersion" TEXT NOT NULL
|
||||
);
|
||||
""";
|
||||
createHistory.ExecuteNonQuery();
|
||||
}
|
||||
}
|
||||
|
||||
using (var insert = conn.CreateCommand())
|
||||
{
|
||||
insert.CommandText = """
|
||||
INSERT OR IGNORE INTO "__EFMigrationsHistory" ("MigrationId", "ProductVersion")
|
||||
VALUES ('20260508092637_Initial', '9.0.4');
|
||||
""";
|
||||
insert.ExecuteNonQuery();
|
||||
}
|
||||
|
||||
// A previous failed migrate may have left a stale row in
|
||||
// __EFMigrationsLock (EF Core 9 acquires this lock around the migrate
|
||||
// run; a crash before release leaves the row, blocking the next
|
||||
// Migrate call indefinitely). Drop the table so Migrate re-creates
|
||||
// it cleanly. Safe — the lock is per-application-instance and we're
|
||||
// single-instance here.
|
||||
using (var dropLock = conn.CreateCommand())
|
||||
{
|
||||
dropLock.CommandText = "DROP TABLE IF EXISTS __EFMigrationsLock;";
|
||||
dropLock.ExecuteNonQuery();
|
||||
}
|
||||
|
||||
Log.Information(
|
||||
"[migrations] Detected legacy EnsureCreated DB; baselined as 20260508092637_Initial. Subsequent migrations will apply normally.");
|
||||
}
|
||||
|
||||
static void ApplySqliteCompatibilityPatches(DorkNetDbContext db)
|
||||
{
|
||||
if (!db.Database.IsSqlite()) return;
|
||||
AddSqliteColumnIfMissing(db, "Rooms", "HiddenFromBrowse",
|
||||
@"""HiddenFromBrowse"" INTEGER NOT NULL DEFAULT 0");
|
||||
// 2026-05-31 — weekly-challenge config columns (see ServerSettingsEntity).
|
||||
AddSqliteColumnIfMissing(db, "ServerSettings", "WeeklyChallengesCompletedRequired",
|
||||
@"""WeeklyChallengesCompletedRequired"" INTEGER NOT NULL DEFAULT 1");
|
||||
AddSqliteColumnIfMissing(db, "ServerSettings", "WeeklyChallengesJson",
|
||||
@"""WeeklyChallengesJson"" TEXT NOT NULL DEFAULT ''");
|
||||
AddSqliteColumnIfMissing(db, "ServerSettings", "WeeklyChallengeRewardJson",
|
||||
@"""WeeklyChallengeRewardJson"" TEXT NOT NULL DEFAULT ''");
|
||||
// 2026-06-04 — "everyone is friends" admin toggle.
|
||||
AddSqliteColumnIfMissing(db, "ServerSettings", "GlobalFriendsEnabled",
|
||||
@"""GlobalFriendsEnabled"" INTEGER NOT NULL DEFAULT 0");
|
||||
}
|
||||
|
||||
static void AddSqliteColumnIfMissing(DorkNetDbContext db, string table, string column, string definition)
|
||||
{
|
||||
var conn = db.Database.GetDbConnection();
|
||||
var shouldClose = conn.State == System.Data.ConnectionState.Closed;
|
||||
if (shouldClose) conn.Open();
|
||||
try
|
||||
{
|
||||
var exists = false;
|
||||
using var check = conn.CreateCommand();
|
||||
check.CommandText = $"PRAGMA table_info(\"{table.Replace("\"", "\"\"")}\");";
|
||||
{
|
||||
using var reader = check.ExecuteReader();
|
||||
while (reader.Read())
|
||||
{
|
||||
if (!string.Equals(reader.GetString(1), column, StringComparison.OrdinalIgnoreCase)) continue;
|
||||
exists = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (exists)
|
||||
{
|
||||
Log.Information("[sqlite-compat] {Table}.{Column} already exists", table, column);
|
||||
return;
|
||||
}
|
||||
|
||||
using var alter = conn.CreateCommand();
|
||||
alter.CommandText = $"ALTER TABLE \"{table.Replace("\"", "\"\"")}\" ADD COLUMN {definition};";
|
||||
alter.ExecuteNonQuery();
|
||||
Log.Information("[sqlite-compat] Added {Table}.{Column}", table, column);
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (shouldClose) conn.Close();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,12 +3,16 @@ using Microsoft.IdentityModel.Tokens;
|
||||
using System.IdentityModel.Tokens.Jwt;
|
||||
using System.Security.Claims;
|
||||
using System.Text;
|
||||
using DorkNet.Server.Auth;
|
||||
using DorkNet.Server.Data;
|
||||
|
||||
namespace DorkNet.Server.Services;
|
||||
|
||||
public class AuthService(IConfiguration config, DorkNetDbContext db)
|
||||
public class AuthService(IConfiguration config, DorkNetDbContext db, DomainConfig domain, IdentityServerSigningKeyProvider signingKeyProvider)
|
||||
{
|
||||
public const string AccessCookieName = "dorknet_access_token";
|
||||
public const string LegacyIssuer = "https://api.rec.net/";
|
||||
|
||||
// Resolution order MUST match Program.cs's JwtBearer validator setup —
|
||||
// env var first, config key second — otherwise tokens are signed with
|
||||
// one secret and validated against another and every authenticated
|
||||
@@ -38,38 +42,17 @@ public class AuthService(IConfiguration config, DorkNetDbContext db)
|
||||
private string CreateJwt(long playerId, TimeSpan lifetime, string tokenType)
|
||||
{
|
||||
var handler = new JwtSecurityTokenHandler();
|
||||
var key = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(SecretKey));
|
||||
|
||||
// Look up dev/admin flags so the JWT carries a "developer" role
|
||||
// claim for accounts that should see the in-game debug button.
|
||||
// The watch's Login.SetAccessToken parses the access_token's
|
||||
// <c>role</c> claim — either a single string or a list — and
|
||||
// calls <c>HasRole("developer", token)</c>; that result drives
|
||||
// <c>Accounts.SetLocalAccountIsDev</c> and ultimately
|
||||
// <c>SessionManager.IsDeveloper</c>, which the watch UI checks
|
||||
// when deciding whether to show the dev console toggle.
|
||||
var isDev = db.Players
|
||||
.Where(p => p.Id == playerId)
|
||||
.Select(p => (bool?)(p.IsDeveloper || p.IsAdmin))
|
||||
.FirstOrDefault() ?? false;
|
||||
|
||||
var claims = new List<Claim>
|
||||
{
|
||||
new(ClaimTypes.NameIdentifier, playerId.ToString()),
|
||||
new(ClaimTypes.Role, "gameClient"),
|
||||
new("token_type", tokenType),
|
||||
};
|
||||
// Multiple Role claims become a JSON array on the wire — the
|
||||
// watch's HasRole helper handles both string and List<string>
|
||||
// forms.
|
||||
if (isDev) claims.Add(new Claim(ClaimTypes.Role, "developer"));
|
||||
var claims = RecRoomIdentityClaims.CreateAsync(db, playerId)
|
||||
.GetAwaiter()
|
||||
.GetResult();
|
||||
claims.Add(new Claim("token_type", tokenType));
|
||||
|
||||
var descriptor = new SecurityTokenDescriptor
|
||||
{
|
||||
Subject = new ClaimsIdentity(claims),
|
||||
Expires = DateTime.UtcNow.Add(lifetime),
|
||||
Issuer = "https://api.rec.net/",
|
||||
SigningCredentials = new SigningCredentials(key, SecurityAlgorithms.HmacSha256),
|
||||
Issuer = domain.AuthIssuer,
|
||||
SigningCredentials = new SigningCredentials(signingKeyProvider.SigningKey, SecurityAlgorithms.RsaSha256),
|
||||
};
|
||||
|
||||
return handler.WriteToken(handler.CreateJwtSecurityToken(descriptor));
|
||||
@@ -80,19 +63,22 @@ public class AuthService(IConfiguration config, DorkNetDbContext db)
|
||||
try
|
||||
{
|
||||
var handler = new JwtSecurityTokenHandler();
|
||||
var key = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(SecretKey));
|
||||
|
||||
var principal = handler.ValidateToken(token, new TokenValidationParameters
|
||||
{
|
||||
ValidateIssuerSigningKey = true,
|
||||
IssuerSigningKey = key,
|
||||
IssuerSigningKeys = signingKeyProvider.ValidationKeys,
|
||||
ValidateIssuer = true,
|
||||
ValidIssuer = "https://api.rec.net/",
|
||||
ValidIssuers = new[] { domain.AuthIssuer, LegacyIssuer },
|
||||
ValidateAudience = false,
|
||||
ClockSkew = TimeSpan.Zero,
|
||||
}, out _);
|
||||
|
||||
var idClaim = principal.FindFirst(ClaimTypes.NameIdentifier)?.Value;
|
||||
var idClaim = principal.FindFirst(ClaimTypes.NameIdentifier)?.Value
|
||||
?? principal.FindFirst(JwtRegisteredClaimNames.Sub)?.Value
|
||||
?? principal.FindFirst("sub")?.Value
|
||||
?? principal.FindFirst("accountId")?.Value
|
||||
?? principal.FindFirst("account_id")?.Value
|
||||
?? principal.FindFirst("accountid")?.Value;
|
||||
return idClaim is not null ? long.Parse(idClaim) : null;
|
||||
}
|
||||
catch
|
||||
|
||||
@@ -27,6 +27,7 @@ public sealed class DomainConfig
|
||||
}
|
||||
|
||||
public string Sub(string prefix) => $"{prefix}.{Apex}";
|
||||
public string AuthIssuer => SubUrl("auth");
|
||||
public string Url(string host) => Port is null ? $"{Scheme}://{host}" : $"{Scheme}://{host}:{Port}";
|
||||
public string SubUrl(string prefix) => Url(Sub(prefix));
|
||||
public static bool MatchesSubdomain(string host, string prefix)
|
||||
|
||||
@@ -681,8 +681,21 @@ public class PlayerService(DorkNetDbContext db, RoomService rooms, ILogger<Playe
|
||||
// request `take=1000000` and exhaust the connection. 50 is enough
|
||||
// for the watch's typeahead UI (it shows ~10 rows at a time).
|
||||
var clamped = Math.Clamp(take, 1, 50);
|
||||
var needle = (query ?? string.Empty).Trim().ToLower();
|
||||
if (needle.Length == 0) return [];
|
||||
var idQuery = long.TryParse(needle, out var parsedId) ? parsedId : -1;
|
||||
|
||||
return await db.Players
|
||||
.Where(p => p.Username.Contains(query) || p.DisplayName.Contains(query))
|
||||
.Where(p => p.Id == idQuery ||
|
||||
p.Username.ToLower().Contains(needle) ||
|
||||
p.DisplayName.ToLower().Contains(needle))
|
||||
.OrderBy(p =>
|
||||
p.Id == idQuery ||
|
||||
p.Username.ToLower() == needle ||
|
||||
p.DisplayName.ToLower() == needle ? 0 :
|
||||
p.Username.ToLower().StartsWith(needle) ||
|
||||
p.DisplayName.ToLower().StartsWith(needle) ? 1 : 2)
|
||||
.ThenBy(p => p.Username)
|
||||
.Take(clamped)
|
||||
.ToListAsync();
|
||||
}
|
||||
|
||||
@@ -0,0 +1,349 @@
|
||||
using System.Data;
|
||||
using System.Data.Common;
|
||||
using DorkNet.Server.Controllers.Health;
|
||||
using DorkNet.Server.Data;
|
||||
using DorkNet.Server.Services;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Serilog;
|
||||
|
||||
namespace DorkNet.Server.Startup;
|
||||
|
||||
/// <summary>Boot-time schema repair, seed data, and health readiness.</summary>
|
||||
public static class DatabaseBootstrap
|
||||
{
|
||||
public static async Task RunDatabaseBootstrapAsync(this WebApplication app)
|
||||
{
|
||||
using var scope = app.Services.CreateScope();
|
||||
var db = scope.ServiceProvider.GetRequiredService<DorkNetDbContext>();
|
||||
|
||||
if (db.Database.IsSqlite())
|
||||
{
|
||||
db.Database.EnsureCreated();
|
||||
ApplySqliteCompatibilityPatches(db);
|
||||
await EnsureSqliteInviteTablesAsync(db);
|
||||
}
|
||||
else
|
||||
{
|
||||
await BootstrapPostgresAsync(app, db);
|
||||
}
|
||||
|
||||
var playerService = scope.ServiceProvider.GetRequiredService<PlayerService>();
|
||||
await playerService.EnsureSystemAccountAsync();
|
||||
await playerService.EnsureAvatarSeedAsync();
|
||||
await playerService.BackfillStarterWalletsAsync();
|
||||
|
||||
var roomService = scope.ServiceProvider.GetRequiredService<RoomService>();
|
||||
await roomService.EnsureDormsForAllPlayersAsync();
|
||||
await roomService.SeedAsync();
|
||||
|
||||
using (var http = new HttpClient { Timeout = TimeSpan.FromSeconds(10) })
|
||||
{
|
||||
var imagesDir = Path.Combine(AppContext.BaseDirectory, "data", "images");
|
||||
await roomService.ApplyCanonicalOverridesAsync(http, imagesDir);
|
||||
}
|
||||
|
||||
var storeService = scope.ServiceProvider.GetRequiredService<StoreService>();
|
||||
await storeService.SeedAsync();
|
||||
|
||||
HealthController.MigrationsComplete = true;
|
||||
}
|
||||
|
||||
private static async Task EnsureSqliteInviteTablesAsync(DorkNetDbContext db)
|
||||
{
|
||||
await db.Database.ExecuteSqlRawAsync(
|
||||
@"CREATE TABLE IF NOT EXISTS ""SignupCodes"" (
|
||||
""Id"" INTEGER NOT NULL CONSTRAINT ""PK_SignupCodes"" PRIMARY KEY AUTOINCREMENT,
|
||||
""Code"" TEXT NOT NULL,
|
||||
""Descriptor"" TEXT NOT NULL DEFAULT '',
|
||||
""CreatedByPlayerId"" INTEGER NOT NULL DEFAULT 0,
|
||||
""CreatedAt"" TEXT NOT NULL,
|
||||
""ExpiresAt"" TEXT NULL,
|
||||
""RedeemedByPlayerId"" INTEGER NULL,
|
||||
""RedeemedAt"" TEXT NULL,
|
||||
""Revoked"" INTEGER NOT NULL DEFAULT 0
|
||||
);");
|
||||
await db.Database.ExecuteSqlRawAsync(
|
||||
@"CREATE UNIQUE INDEX IF NOT EXISTS ""IX_SignupCodes_Code"" ON ""SignupCodes"" (""Code"");");
|
||||
await db.Database.ExecuteSqlRawAsync(
|
||||
@"CREATE TABLE IF NOT EXISTS ""PendingDevices"" (
|
||||
""Id"" INTEGER NOT NULL CONSTRAINT ""PK_PendingDevices"" PRIMARY KEY AUTOINCREMENT,
|
||||
""DeviceId"" TEXT NOT NULL,
|
||||
""Platform"" INTEGER NOT NULL DEFAULT 0,
|
||||
""PlatformId"" TEXT NOT NULL DEFAULT '',
|
||||
""LastIp"" TEXT NULL,
|
||||
""FirstSeenAt"" TEXT NOT NULL,
|
||||
""LastSeenAt"" TEXT NOT NULL
|
||||
);");
|
||||
await db.Database.ExecuteSqlRawAsync(
|
||||
@"CREATE UNIQUE INDEX IF NOT EXISTS ""IX_PendingDevices_DeviceId"" ON ""PendingDevices"" (""DeviceId"");");
|
||||
}
|
||||
|
||||
private static async Task BootstrapPostgresAsync(WebApplication app, DorkNetDbContext db)
|
||||
{
|
||||
var conn = db.Database.GetDbConnection();
|
||||
var connectLogger = app.Services.GetRequiredService<ILoggerFactory>()
|
||||
.CreateLogger("DorkNet.Server.Bootstrap");
|
||||
|
||||
for (var attempt = 1; ; attempt++)
|
||||
{
|
||||
try { await conn.OpenAsync(); break; }
|
||||
catch (Exception ex) when (attempt < 15 &&
|
||||
(ex is System.Net.Sockets.SocketException
|
||||
|| ex is Npgsql.NpgsqlException
|
||||
|| ex is DbException))
|
||||
{
|
||||
var delay = TimeSpan.FromSeconds(Math.Min(attempt, 5));
|
||||
connectLogger.LogWarning(
|
||||
"[bootstrap] Postgres connect attempt {Attempt}/15 failed ({Type}: {Message}); retrying in {Delay}s",
|
||||
attempt, ex.GetType().Name, ex.Message, delay.TotalSeconds);
|
||||
await Task.Delay(delay);
|
||||
}
|
||||
}
|
||||
|
||||
await using var tx = await conn.BeginTransactionAsync();
|
||||
await using (var lockCmd = conn.CreateCommand())
|
||||
{
|
||||
lockCmd.Transaction = tx;
|
||||
lockCmd.CommandText = "SELECT pg_advisory_xact_lock(1146246987);";
|
||||
await lockCmd.ExecuteNonQueryAsync();
|
||||
}
|
||||
db.Database.EnsureCreated();
|
||||
await tx.CommitAsync();
|
||||
|
||||
var bootstrapLogger = app.Services.GetRequiredService<ILoggerFactory>()
|
||||
.CreateLogger("DorkNet.Server.Bootstrap");
|
||||
|
||||
await RunPostgresPatchAsync(conn, bootstrapLogger, "RoomDataBlobs.Bytes nullable",
|
||||
@"ALTER TABLE ""RoomDataBlobs"" ALTER COLUMN ""Bytes"" DROP NOT NULL;");
|
||||
|
||||
await RunPostgresPatchAsync(conn, bootstrapLogger, "ChatThreads table",
|
||||
@"CREATE TABLE IF NOT EXISTS ""ChatThreads"" (
|
||||
""Id"" bigserial PRIMARY KEY,
|
||||
""ThreadKey"" varchar(96) NOT NULL,
|
||||
""Name"" varchar(128) NOT NULL DEFAULT '',
|
||||
""CreatorPlayerId"" bigint NOT NULL,
|
||||
""CreatedAt"" timestamp with time zone NOT NULL DEFAULT now(),
|
||||
""UpdatedAt"" timestamp with time zone NOT NULL DEFAULT now()
|
||||
);");
|
||||
await RunPostgresPatchAsync(conn, bootstrapLogger, "ChatThreads ThreadKey unique index",
|
||||
@"CREATE UNIQUE INDEX IF NOT EXISTS ""IX_ChatThreads_ThreadKey""
|
||||
ON ""ChatThreads"" (""ThreadKey"");");
|
||||
await RunPostgresPatchAsync(conn, bootstrapLogger, "ChatThreadMembers table",
|
||||
@"CREATE TABLE IF NOT EXISTS ""ChatThreadMembers"" (
|
||||
""Id"" bigserial PRIMARY KEY,
|
||||
""ThreadKey"" text NOT NULL,
|
||||
""PlayerId"" bigint NOT NULL,
|
||||
""JoinedAt"" timestamp with time zone NOT NULL DEFAULT now(),
|
||||
""SnoozeUntil"" timestamp with time zone NULL,
|
||||
""LastReadMessageId"" bigint NULL
|
||||
);");
|
||||
await RunPostgresPatchAsync(conn, bootstrapLogger, "ChatThreadMembers (ThreadKey, PlayerId) unique index",
|
||||
@"CREATE UNIQUE INDEX IF NOT EXISTS ""IX_ChatThreadMembers_ThreadKey_PlayerId""
|
||||
ON ""ChatThreadMembers"" (""ThreadKey"", ""PlayerId"");");
|
||||
await RunPostgresPatchAsync(conn, bootstrapLogger, "ChatThreadMembers PlayerId index",
|
||||
@"CREATE INDEX IF NOT EXISTS ""IX_ChatThreadMembers_PlayerId""
|
||||
ON ""ChatThreadMembers"" (""PlayerId"");");
|
||||
|
||||
await RunPostgresPatchAsync(conn, bootstrapLogger, "Relationships.Favorited column",
|
||||
@"ALTER TABLE ""Relationships""
|
||||
ADD COLUMN IF NOT EXISTS ""Favorited"" boolean NOT NULL DEFAULT false;");
|
||||
await RunPostgresPatchAsync(conn, bootstrapLogger, "Relationships.Muted column",
|
||||
@"ALTER TABLE ""Relationships""
|
||||
ADD COLUMN IF NOT EXISTS ""Muted"" boolean NOT NULL DEFAULT false;");
|
||||
await RunPostgresPatchAsync(conn, bootstrapLogger, "Relationships.Ignored column",
|
||||
@"ALTER TABLE ""Relationships""
|
||||
ADD COLUMN IF NOT EXISTS ""Ignored"" boolean NOT NULL DEFAULT false;");
|
||||
|
||||
await RunPostgresPatchAsync(conn, bootstrapLogger, "StoreItems.Slug widen to 128",
|
||||
@"ALTER TABLE ""StoreItems"" ALTER COLUMN ""Slug"" TYPE varchar(128);");
|
||||
|
||||
await RunPostgresPatchAsync(conn, bootstrapLogger, "Messages.Type column",
|
||||
@"ALTER TABLE ""Messages""
|
||||
ADD COLUMN IF NOT EXISTS ""Type"" integer NOT NULL DEFAULT 30;");
|
||||
await RunPostgresPatchAsync(conn, bootstrapLogger, "Messages.RoomId column",
|
||||
@"ALTER TABLE ""Messages""
|
||||
ADD COLUMN IF NOT EXISTS ""RoomId"" bigint NULL;");
|
||||
|
||||
await RunPostgresPatchAsync(conn, bootstrapLogger, "PrivateInstanceInvitees.LatestInviteMessageId column",
|
||||
@"ALTER TABLE ""PrivateInstanceInvitees""
|
||||
ADD COLUMN IF NOT EXISTS ""LatestInviteMessageId"" bigint NULL;");
|
||||
|
||||
var currentRegion = (app.Configuration["Photon:CloudRegion"] ?? "us").ToLowerInvariant().Replace("'", "''");
|
||||
await RunPostgresPatchAsync(conn, bootstrapLogger, $"PrivateInstances.PhotonRegion -> {currentRegion}",
|
||||
$@"UPDATE ""PrivateInstances""
|
||||
SET ""PhotonRegion"" = '{currentRegion}'
|
||||
WHERE ""PhotonRegion"" IS DISTINCT FROM '{currentRegion}';");
|
||||
|
||||
await RunPostgresPatchAsync(conn, bootstrapLogger, "CommunityBoardRows table",
|
||||
@"CREATE TABLE IF NOT EXISTS ""CommunityBoardRows"" (
|
||||
""Id"" integer PRIMARY KEY,
|
||||
""Json"" text NOT NULL DEFAULT '{}',
|
||||
""UpdatedAt"" timestamp with time zone NOT NULL DEFAULT now()
|
||||
);");
|
||||
|
||||
await RunPostgresPatchAsync(conn, bootstrapLogger, "ServerSettings table",
|
||||
@"CREATE TABLE IF NOT EXISTS ""ServerSettings"" (
|
||||
""Id"" integer PRIMARY KEY,
|
||||
""SignupsDisabled"" boolean NOT NULL DEFAULT false,
|
||||
""UpdatedAt"" timestamp with time zone NOT NULL DEFAULT now()
|
||||
);");
|
||||
|
||||
await RunPostgresPatchAsync(conn, bootstrapLogger, "ServerSettings.WeeklyChallengesCompletedRequired column",
|
||||
@"ALTER TABLE ""ServerSettings""
|
||||
ADD COLUMN IF NOT EXISTS ""WeeklyChallengesCompletedRequired"" boolean NOT NULL DEFAULT true;");
|
||||
await RunPostgresPatchAsync(conn, bootstrapLogger, "ServerSettings.WeeklyChallengesJson column",
|
||||
@"ALTER TABLE ""ServerSettings""
|
||||
ADD COLUMN IF NOT EXISTS ""WeeklyChallengesJson"" text NOT NULL DEFAULT '';");
|
||||
await RunPostgresPatchAsync(conn, bootstrapLogger, "ServerSettings.WeeklyChallengeRewardJson column",
|
||||
@"ALTER TABLE ""ServerSettings""
|
||||
ADD COLUMN IF NOT EXISTS ""WeeklyChallengeRewardJson"" text NOT NULL DEFAULT '';");
|
||||
await RunPostgresPatchAsync(conn, bootstrapLogger, "ServerSettings.GlobalFriendsEnabled column",
|
||||
@"ALTER TABLE ""ServerSettings""
|
||||
ADD COLUMN IF NOT EXISTS ""GlobalFriendsEnabled"" boolean NOT NULL DEFAULT false;");
|
||||
|
||||
await RunPostgresPatchAsync(conn, bootstrapLogger, "Players.IsCommunityTeam column",
|
||||
@"ALTER TABLE ""Players""
|
||||
ADD COLUMN IF NOT EXISTS ""IsCommunityTeam"" boolean NOT NULL DEFAULT false;");
|
||||
|
||||
await RunPostgresPatchAsync(conn, bootstrapLogger, "Rooms.HiddenFromBrowse column",
|
||||
@"ALTER TABLE ""Rooms""
|
||||
ADD COLUMN IF NOT EXISTS ""HiddenFromBrowse"" boolean NOT NULL DEFAULT false;");
|
||||
|
||||
await RunPostgresPatchAsync(conn, bootstrapLogger, "RoomRoles table",
|
||||
@"CREATE TABLE IF NOT EXISTS ""RoomRoles"" (
|
||||
""Id"" bigserial PRIMARY KEY,
|
||||
""RoomId"" bigint NOT NULL,
|
||||
""PlayerId"" bigint NOT NULL,
|
||||
""Role"" integer NOT NULL,
|
||||
""Accepted"" boolean NOT NULL DEFAULT true,
|
||||
""GrantedByPlayerId"" bigint NULL,
|
||||
""GrantedAt"" timestamp with time zone NOT NULL DEFAULT now()
|
||||
);");
|
||||
await RunPostgresPatchAsync(conn, bootstrapLogger, "RoomRoles (RoomId, PlayerId, Role) unique index",
|
||||
@"CREATE UNIQUE INDEX IF NOT EXISTS ""IX_RoomRoles_RoomId_PlayerId_Role""
|
||||
ON ""RoomRoles"" (""RoomId"", ""PlayerId"", ""Role"");");
|
||||
await RunPostgresPatchAsync(conn, bootstrapLogger, "RoomRoles RoomId index",
|
||||
@"CREATE INDEX IF NOT EXISTS ""IX_RoomRoles_RoomId"" ON ""RoomRoles"" (""RoomId"");");
|
||||
|
||||
await RunPostgresPatchAsync(conn, bootstrapLogger, "LeaderboardChannelMeta table",
|
||||
@"CREATE TABLE IF NOT EXISTS ""LeaderboardChannelMeta"" (
|
||||
""Channel"" integer PRIMARY KEY,
|
||||
""RoomId"" bigint NOT NULL DEFAULT 0,
|
||||
""Name"" varchar(128) NOT NULL DEFAULT '',
|
||||
""LowerIsBetter"" boolean NOT NULL DEFAULT false,
|
||||
""ValueFormat"" varchar(32) NOT NULL DEFAULT 'count',
|
||||
""CreatedAt"" timestamp with time zone NOT NULL DEFAULT now(),
|
||||
""UpdatedAt"" timestamp with time zone NOT NULL DEFAULT now()
|
||||
);");
|
||||
await RunPostgresPatchAsync(conn, bootstrapLogger, "LeaderboardChannelMeta RoomId index",
|
||||
@"CREATE INDEX IF NOT EXISTS ""IX_LeaderboardChannelMeta_RoomId""
|
||||
ON ""LeaderboardChannelMeta"" (""RoomId"");");
|
||||
|
||||
await RunPostgresPatchAsync(conn, bootstrapLogger, "LoadingScreenTips table",
|
||||
@"CREATE TABLE IF NOT EXISTS ""LoadingScreenTips"" (
|
||||
""Id"" bigserial PRIMARY KEY,
|
||||
""Title"" varchar(128) NOT NULL DEFAULT '',
|
||||
""Message"" varchar(512) NOT NULL DEFAULT '',
|
||||
""ImageName"" varchar(128) NOT NULL DEFAULT '',
|
||||
""Context"" int NOT NULL DEFAULT 0,
|
||||
""PlatformMask"" int NOT NULL DEFAULT -1,
|
||||
""RoomNamesCsv"" varchar(512) NOT NULL DEFAULT '',
|
||||
""SortOrder"" int NOT NULL DEFAULT 0,
|
||||
""IsActive"" boolean NOT NULL DEFAULT true,
|
||||
""CreatedAt"" timestamp with time zone NOT NULL DEFAULT now(),
|
||||
""UpdatedAt"" timestamp with time zone NOT NULL DEFAULT now()
|
||||
);");
|
||||
|
||||
await RunPostgresPatchAsync(conn, bootstrapLogger, "SignupCodes table",
|
||||
@"CREATE TABLE IF NOT EXISTS ""SignupCodes"" (
|
||||
""Id"" bigserial PRIMARY KEY,
|
||||
""Code"" text NOT NULL,
|
||||
""Descriptor"" text NOT NULL DEFAULT '',
|
||||
""CreatedByPlayerId"" bigint NOT NULL DEFAULT 0,
|
||||
""CreatedAt"" timestamp with time zone NOT NULL DEFAULT now(),
|
||||
""ExpiresAt"" timestamp with time zone NULL,
|
||||
""RedeemedByPlayerId"" bigint NULL,
|
||||
""RedeemedAt"" timestamp with time zone NULL,
|
||||
""Revoked"" boolean NOT NULL DEFAULT false
|
||||
);");
|
||||
await RunPostgresPatchAsync(conn, bootstrapLogger, "SignupCodes Code unique index",
|
||||
@"CREATE UNIQUE INDEX IF NOT EXISTS ""IX_SignupCodes_Code"" ON ""SignupCodes"" (""Code"");");
|
||||
await RunPostgresPatchAsync(conn, bootstrapLogger, "PendingDevices table",
|
||||
@"CREATE TABLE IF NOT EXISTS ""PendingDevices"" (
|
||||
""Id"" bigserial PRIMARY KEY,
|
||||
""DeviceId"" text NOT NULL,
|
||||
""Platform"" integer NOT NULL DEFAULT 0,
|
||||
""PlatformId"" text NOT NULL DEFAULT '',
|
||||
""LastIp"" text NULL,
|
||||
""FirstSeenAt"" timestamp with time zone NOT NULL DEFAULT now(),
|
||||
""LastSeenAt"" timestamp with time zone NOT NULL DEFAULT now()
|
||||
);");
|
||||
await RunPostgresPatchAsync(conn, bootstrapLogger, "PendingDevices DeviceId unique index",
|
||||
@"CREATE UNIQUE INDEX IF NOT EXISTS ""IX_PendingDevices_DeviceId"" ON ""PendingDevices"" (""DeviceId"");");
|
||||
|
||||
await conn.CloseAsync();
|
||||
}
|
||||
|
||||
private static async Task RunPostgresPatchAsync(DbConnection conn, Microsoft.Extensions.Logging.ILogger logger, string label, string sql)
|
||||
{
|
||||
await using var cmd = conn.CreateCommand();
|
||||
cmd.CommandText = sql;
|
||||
try { await cmd.ExecuteNonQueryAsync(); }
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogWarning(ex, "[schema-patch] {Label} failed", label);
|
||||
}
|
||||
}
|
||||
|
||||
private static void ApplySqliteCompatibilityPatches(DorkNetDbContext db)
|
||||
{
|
||||
if (!db.Database.IsSqlite()) return;
|
||||
AddSqliteColumnIfMissing(db, "Rooms", "HiddenFromBrowse",
|
||||
@"""HiddenFromBrowse"" INTEGER NOT NULL DEFAULT 0");
|
||||
AddSqliteColumnIfMissing(db, "ServerSettings", "WeeklyChallengesCompletedRequired",
|
||||
@"""WeeklyChallengesCompletedRequired"" INTEGER NOT NULL DEFAULT 1");
|
||||
AddSqliteColumnIfMissing(db, "ServerSettings", "WeeklyChallengesJson",
|
||||
@"""WeeklyChallengesJson"" TEXT NOT NULL DEFAULT ''");
|
||||
AddSqliteColumnIfMissing(db, "ServerSettings", "WeeklyChallengeRewardJson",
|
||||
@"""WeeklyChallengeRewardJson"" TEXT NOT NULL DEFAULT ''");
|
||||
AddSqliteColumnIfMissing(db, "ServerSettings", "GlobalFriendsEnabled",
|
||||
@"""GlobalFriendsEnabled"" INTEGER NOT NULL DEFAULT 0");
|
||||
}
|
||||
|
||||
private static void AddSqliteColumnIfMissing(DorkNetDbContext db, string table, string column, string definition)
|
||||
{
|
||||
var conn = db.Database.GetDbConnection();
|
||||
var shouldClose = conn.State == ConnectionState.Closed;
|
||||
if (shouldClose) conn.Open();
|
||||
try
|
||||
{
|
||||
var exists = false;
|
||||
using var check = conn.CreateCommand();
|
||||
check.CommandText = $"PRAGMA table_info(\"{table.Replace("\"", "\"\"")}\");";
|
||||
using (var reader = check.ExecuteReader())
|
||||
{
|
||||
while (reader.Read())
|
||||
{
|
||||
if (!string.Equals(reader.GetString(1), column, StringComparison.OrdinalIgnoreCase)) continue;
|
||||
exists = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (exists)
|
||||
{
|
||||
Log.Information("[sqlite-compat] {Table}.{Column} already exists", table, column);
|
||||
return;
|
||||
}
|
||||
|
||||
using var alter = conn.CreateCommand();
|
||||
alter.CommandText = $"ALTER TABLE \"{table.Replace("\"", "\"\"")}\" ADD COLUMN {definition};";
|
||||
alter.ExecuteNonQuery();
|
||||
Log.Information("[sqlite-compat] Added {Table}.{Column}", table, column);
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (shouldClose) conn.Close();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
using DorkNet.Server.Auth;
|
||||
using DorkNet.Server.Hubs;
|
||||
using DorkNet.Server.Services;
|
||||
using Microsoft.AspNetCore.Http.Connections;
|
||||
|
||||
namespace DorkNet.Server.Startup;
|
||||
|
||||
/// <summary>Assembles the DorkNet middleware pipeline in runtime order.</summary>
|
||||
public static class MiddlewarePipelineExtensions
|
||||
{
|
||||
public static void UseDorkNetPipeline(this WebApplication app)
|
||||
{
|
||||
var domainCfg = app.Services.GetRequiredService<DomainConfig>();
|
||||
|
||||
// Easy Launcher single-origin mode. localtunnel gives us one public
|
||||
// hostname, so /__dn/{service}/... is mapped to the internal subdomain
|
||||
// host shape before endpoint selection runs.
|
||||
app.Use(async (ctx, next) =>
|
||||
{
|
||||
if (domainCfg.SingleOriginEnabled &&
|
||||
TryMapSingleOriginPath(ctx.Request.Path, out var service, out var rest))
|
||||
{
|
||||
ctx.Request.Host = new HostString(service == "www" ? domainCfg.Apex : domainCfg.Sub(service));
|
||||
ctx.Request.Path = string.IsNullOrEmpty(rest) ? "/" : rest;
|
||||
}
|
||||
|
||||
await next();
|
||||
});
|
||||
|
||||
app.UseRouting();
|
||||
|
||||
if (app.Environment.IsDevelopment())
|
||||
{
|
||||
app.UseSwagger();
|
||||
app.UseSwaggerUI();
|
||||
}
|
||||
|
||||
app.UseHttpsRedirection();
|
||||
|
||||
app.UseWebSockets(new Microsoft.AspNetCore.Builder.WebSocketOptions
|
||||
{
|
||||
KeepAliveInterval = TimeSpan.FromSeconds(30),
|
||||
});
|
||||
|
||||
app.MountStaticHost(domainCfg.Sub("admin"), "admin", spaFallback: true);
|
||||
app.MountStaticHost(domainCfg.Apex, "site", spaFallback: true);
|
||||
app.MountStaticHost($"www.{domainCfg.Apex}", "site", spaFallback: true);
|
||||
app.MountStaticHost(domainCfg.Sub("feed"), "feed");
|
||||
|
||||
app.UseRequestTracing();
|
||||
|
||||
app.Use(async (ctx, next) =>
|
||||
{
|
||||
if (ctx.Request.Host.Host.Equals(domainCfg.Sub("api"), StringComparison.OrdinalIgnoreCase) &&
|
||||
(ctx.Request.Path == "/admin" || ctx.Request.Path.StartsWithSegments("/admin")))
|
||||
{
|
||||
ctx.Response.Redirect($"https://{domainCfg.Sub("admin")}/", permanent: false);
|
||||
return;
|
||||
}
|
||||
await next();
|
||||
});
|
||||
|
||||
app.UseMiddleware<IpBanCheckMiddleware>();
|
||||
|
||||
app.UseMiddleware<IdentityServerGameTokenRequestMiddleware>();
|
||||
app.UseIdentityServer();
|
||||
|
||||
app.UseAuthentication();
|
||||
app.UseAuthorization();
|
||||
app.UseMiddleware<BanCheckMiddleware>();
|
||||
app.MapControllers();
|
||||
|
||||
app.MapHub<NotifyHub>("/hub/v1", opts =>
|
||||
{
|
||||
opts.Transports = HttpTransportType.WebSockets;
|
||||
opts.TransportSendTimeout = TimeSpan.FromSeconds(30);
|
||||
}).RequireHost(domainCfg.Sub("notify"));
|
||||
}
|
||||
|
||||
private static bool TryMapSingleOriginPath(PathString path, out string service, out PathString rest)
|
||||
{
|
||||
service = "";
|
||||
rest = PathString.Empty;
|
||||
if (!path.StartsWithSegments("/__dn", out var tail)) return false;
|
||||
|
||||
var value = tail.Value ?? "";
|
||||
if (value.Length <= 1) return false;
|
||||
var slash = value.IndexOf('/', 1);
|
||||
service = slash < 0 ? value[1..] : value[1..slash];
|
||||
if (string.IsNullOrWhiteSpace(service)) return false;
|
||||
|
||||
rest = slash < 0 ? "/" : value[slash..];
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,193 @@
|
||||
using System.Text.RegularExpressions;
|
||||
using DorkNet.Server.Auth;
|
||||
using DorkNet.Server.Services;
|
||||
|
||||
namespace DorkNet.Server.Startup;
|
||||
|
||||
/// <summary>Per-request observability middleware for HTTP traces and player logs.</summary>
|
||||
public static class RequestTracingExtensions
|
||||
{
|
||||
public static void UseRequestTracing(this WebApplication app)
|
||||
{
|
||||
var reqLogger = app.Services.GetRequiredService<ILoggerFactory>()
|
||||
.CreateLogger("DorkNet.Server.HttpTrace");
|
||||
var playerLog = app.Services.GetRequiredService<PlayerLogService>();
|
||||
|
||||
app.Use(async (ctx, next) =>
|
||||
{
|
||||
if (IsHealthProbe(ctx.Request))
|
||||
{
|
||||
await next();
|
||||
return;
|
||||
}
|
||||
|
||||
if (ctx.WebSockets.IsWebSocketRequest)
|
||||
{
|
||||
reqLogger.LogInformation(
|
||||
"[ws] {Method} {Host}{Path}{Query}",
|
||||
ctx.Request.Method, ctx.Request.Host, ctx.Request.Path,
|
||||
ctx.Request.QueryString.Value ?? "");
|
||||
await next();
|
||||
return;
|
||||
}
|
||||
|
||||
var host = ctx.Request.Host.Host;
|
||||
var path = ctx.Request.Path.Value ?? "";
|
||||
var isStorageLike = host.StartsWith("storage.", StringComparison.OrdinalIgnoreCase)
|
||||
|| host.StartsWith("cdn.", StringComparison.OrdinalIgnoreCase)
|
||||
|| path.Contains("upload", StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
if (isStorageLike)
|
||||
{
|
||||
reqLogger.LogInformation(
|
||||
"[req:start] {Method} {Host}{Path}{Query} contentType={ContentType} contentLength={ContentLength}",
|
||||
ctx.Request.Method, ctx.Request.Host, ctx.Request.Path,
|
||||
ctx.Request.QueryString.Value ?? "", ctx.Request.ContentType ?? "",
|
||||
ctx.Request.ContentLength);
|
||||
}
|
||||
|
||||
var sw = System.Diagnostics.Stopwatch.StartNew();
|
||||
var originalBody = ctx.Response.Body;
|
||||
var captureResponse = ShouldCaptureResponse(ctx.Request);
|
||||
MemoryStream? capture = null;
|
||||
if (captureResponse)
|
||||
{
|
||||
capture = new MemoryStream();
|
||||
ctx.Response.Body = capture;
|
||||
}
|
||||
|
||||
string? requestBody = null;
|
||||
if (ctx.Request.ContentLength is > 0 and < 4096 && IsTextLikeRequest(ctx.Request.ContentType))
|
||||
{
|
||||
ctx.Request.EnableBuffering();
|
||||
using var reader = new StreamReader(ctx.Request.Body, leaveOpen: true);
|
||||
requestBody = await reader.ReadToEndAsync();
|
||||
ctx.Request.Body.Position = 0;
|
||||
}
|
||||
|
||||
Exception? thrown = null;
|
||||
try
|
||||
{
|
||||
await next();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
thrown = ex;
|
||||
throw;
|
||||
}
|
||||
finally
|
||||
{
|
||||
sw.Stop();
|
||||
var status = thrown is null ? ctx.Response.StatusCode : 500;
|
||||
var responseBody = "";
|
||||
|
||||
if (capture is not null)
|
||||
{
|
||||
try
|
||||
{
|
||||
capture.Seek(0, SeekOrigin.Begin);
|
||||
if (ShouldReadResponseBody(ctx.Response.ContentType, status))
|
||||
responseBody = await new StreamReader(capture, leaveOpen: true).ReadToEndAsync();
|
||||
capture.Seek(0, SeekOrigin.Begin);
|
||||
await capture.CopyToAsync(originalBody);
|
||||
}
|
||||
finally
|
||||
{
|
||||
ctx.Response.Body = originalBody;
|
||||
await capture.DisposeAsync();
|
||||
}
|
||||
}
|
||||
|
||||
var safeRequest = RedactSecrets(requestBody);
|
||||
var safeResponse = RedactSecrets(responseBody) ?? "";
|
||||
var trimmedRequest = safeRequest is null ? null
|
||||
: (safeRequest.Length > 200 ? safeRequest[..200] + "..." : safeRequest);
|
||||
var trimmedResponse = safeResponse.Length > 300 ? safeResponse[..300] + "..." : safeResponse;
|
||||
var level = thrown is not null || status >= 500 ? LogLevel.Error
|
||||
: status >= 400 ? LogLevel.Warning
|
||||
: LogLevel.Information;
|
||||
|
||||
reqLogger.Log(level, thrown,
|
||||
"[req] {Status} {Method} {Host}{Path}{Query} {ElapsedMs}ms contentType={ContentType} contentLength={ContentLength} req={ReqBody} resp={RespBody}",
|
||||
status, ctx.Request.Method, ctx.Request.Host, ctx.Request.Path,
|
||||
ctx.Request.QueryString.Value ?? "", sw.ElapsedMilliseconds,
|
||||
ctx.Request.ContentType ?? "", ctx.Request.ContentLength,
|
||||
trimmedRequest ?? "", trimmedResponse);
|
||||
|
||||
var pid = ControllerBaseExtensions.CurrentPlayerId(ctx.User);
|
||||
if (pid is long playerId)
|
||||
{
|
||||
playerLog.Record(new PlayerLogEntry
|
||||
{
|
||||
Timestamp = DateTime.UtcNow,
|
||||
PlayerId = playerId,
|
||||
Method = ctx.Request.Method,
|
||||
Host = ctx.Request.Host.Host,
|
||||
Path = ctx.Request.Path.Value ?? string.Empty,
|
||||
Query = ctx.Request.QueryString.Value ?? string.Empty,
|
||||
Status = status,
|
||||
ElapsedMs = sw.ElapsedMilliseconds,
|
||||
ReqBody = trimmedRequest,
|
||||
RespBody = trimmedResponse,
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private const string SecretKeys =
|
||||
"password|passwd|pwd|passwordhash|token|accesstoken|refreshtoken|idtoken|" +
|
||||
"secret|clientsecret|apikey|api_key|authorization|deviceauth|sessionkey";
|
||||
|
||||
private static readonly Regex JsonSecretRegex = new(
|
||||
"(\"(?:" + SecretKeys + ")\"\\s*:\\s*)\"(?:[^\"\\\\]|\\\\.)*\"",
|
||||
RegexOptions.IgnoreCase | RegexOptions.Compiled);
|
||||
|
||||
private static readonly Regex FormSecretRegex = new(
|
||||
"(?<=^|&)(?<k>" + SecretKeys + ")=[^&]*",
|
||||
RegexOptions.IgnoreCase | RegexOptions.Compiled);
|
||||
|
||||
private static string? RedactSecrets(string? body)
|
||||
{
|
||||
if (string.IsNullOrEmpty(body)) return body;
|
||||
if (body.IndexOf('"') < 0 && body.IndexOf('=') < 0) return body;
|
||||
var redacted = JsonSecretRegex.Replace(body, "$1\"***\"");
|
||||
redacted = FormSecretRegex.Replace(redacted, "${k}=***");
|
||||
return redacted;
|
||||
}
|
||||
|
||||
private static bool IsHealthProbe(HttpRequest request)
|
||||
=> request.Path.Equals("/healthz", StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
private static bool IsTextLikeRequest(string? contentType)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(contentType)) return false;
|
||||
return contentType.StartsWith("application/json", StringComparison.OrdinalIgnoreCase)
|
||||
|| contentType.StartsWith("application/x-www-form-urlencoded", StringComparison.OrdinalIgnoreCase)
|
||||
|| contentType.StartsWith("text/", StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
private static bool ShouldCaptureResponse(HttpRequest request)
|
||||
{
|
||||
var host = request.Host.Host;
|
||||
if (host.StartsWith("cdn.", StringComparison.OrdinalIgnoreCase))
|
||||
return false;
|
||||
|
||||
var path = request.Path.Value ?? "";
|
||||
var ext = Path.GetExtension(path);
|
||||
if (string.IsNullOrEmpty(ext))
|
||||
return true;
|
||||
|
||||
return ext.Equals(".json", StringComparison.OrdinalIgnoreCase)
|
||||
|| ext.Equals(".txt", StringComparison.OrdinalIgnoreCase)
|
||||
|| ext.Equals(".log", StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
private static bool ShouldReadResponseBody(string? contentType, int status)
|
||||
{
|
||||
if (status < 400) return false;
|
||||
if (string.IsNullOrWhiteSpace(contentType)) return true;
|
||||
return contentType.StartsWith("application/json", StringComparison.OrdinalIgnoreCase)
|
||||
|| contentType.StartsWith("text/", StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,232 @@
|
||||
using DorkNet.Server.Auth;
|
||||
using DorkNet.Server.Data;
|
||||
using DorkNet.Server.Services;
|
||||
using Microsoft.AspNetCore.Authentication.JwtBearer;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.IdentityModel.Tokens;
|
||||
using Serilog;
|
||||
using Serilog.Formatting.Compact;
|
||||
using StackExchange.Redis;
|
||||
|
||||
namespace DorkNet.Server.Startup;
|
||||
|
||||
/// <summary>Central DI and host configuration for the DorkNet server.</summary>
|
||||
public static class ServiceCollectionExtensions
|
||||
{
|
||||
public static void AddDorkNetServices(this WebApplicationBuilder builder)
|
||||
{
|
||||
ConfigureLogging(builder);
|
||||
AddDatabase(builder);
|
||||
var domain = AddDomain(builder);
|
||||
AddCoreServices(builder.Services);
|
||||
var redisConn = AddRedis(builder);
|
||||
AddAuthentication(builder, domain);
|
||||
AddKestrelAndSignalR(builder, redisConn);
|
||||
builder.Services.AddEndpointsApiExplorer();
|
||||
builder.Services.AddSwaggerGen();
|
||||
}
|
||||
|
||||
private static void ConfigureLogging(WebApplicationBuilder builder)
|
||||
{
|
||||
builder.Host.UseSerilog((ctx, services, cfg) =>
|
||||
{
|
||||
cfg.ReadFrom.Configuration(ctx.Configuration)
|
||||
.ReadFrom.Services(services)
|
||||
.Enrich.FromLogContext();
|
||||
|
||||
if (ctx.HostingEnvironment.IsDevelopment())
|
||||
cfg.WriteTo.Console();
|
||||
else
|
||||
cfg.WriteTo.Console(new RenderedCompactJsonFormatter());
|
||||
});
|
||||
}
|
||||
|
||||
private static void AddDatabase(WebApplicationBuilder builder)
|
||||
{
|
||||
var dbProvider = (builder.Configuration["Database:Provider"] ?? "sqlite").ToLowerInvariant();
|
||||
|
||||
builder.Services.AddDbContext<DorkNetDbContext>(opt =>
|
||||
{
|
||||
opt.ConfigureWarnings(w => w.Ignore(
|
||||
Microsoft.EntityFrameworkCore.Diagnostics.RelationalEventId.PendingModelChangesWarning));
|
||||
|
||||
if (dbProvider == "postgres")
|
||||
{
|
||||
var conn = builder.Configuration.GetConnectionString("Default")
|
||||
?? throw new InvalidOperationException(
|
||||
"Database:Provider=postgres but ConnectionStrings:Default is not set. " +
|
||||
"Set the ConnectionStrings__Default env var or `ConnectionStrings:Default` " +
|
||||
"in appsettings.Local.json.");
|
||||
opt.UseNpgsql(conn, npg => npg.MigrationsAssembly("DorkNet.Server"));
|
||||
}
|
||||
else
|
||||
{
|
||||
var dbPath = builder.Configuration["Database:SqlitePath"];
|
||||
if (string.IsNullOrWhiteSpace(dbPath))
|
||||
dbPath = Path.Combine(AppContext.BaseDirectory, "data", "dorknet.db");
|
||||
Directory.CreateDirectory(Path.GetDirectoryName(dbPath)!);
|
||||
opt.UseSqlite($"Data Source={dbPath}", lite => lite.MigrationsAssembly("DorkNet.Server"));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private static DomainConfig AddDomain(WebApplicationBuilder builder)
|
||||
{
|
||||
var apex =
|
||||
builder.Configuration["Domain:Apex"]
|
||||
?? Environment.GetEnvironmentVariable("DORKNET_DOMAIN")
|
||||
?? "localhost";
|
||||
var domainScheme =
|
||||
builder.Configuration["Domain:Scheme"]
|
||||
?? Environment.GetEnvironmentVariable("DORKNET_DOMAIN_SCHEME")
|
||||
?? "https";
|
||||
var domainPort =
|
||||
builder.Configuration["Domain:Port"]
|
||||
?? Environment.GetEnvironmentVariable("DORKNET_DOMAIN_PORT");
|
||||
var singleOriginBaseUrl =
|
||||
builder.Configuration["Domain:SingleOriginBaseUrl"]
|
||||
?? Environment.GetEnvironmentVariable("DORKNET_SINGLE_ORIGIN_BASE_URL");
|
||||
|
||||
var domain = new DomainConfig(apex, domainScheme, domainPort, singleOriginBaseUrl);
|
||||
builder.Services.AddSingleton(domain);
|
||||
builder.Services.Configure<Microsoft.AspNetCore.HostFiltering.HostFilteringOptions>(opt =>
|
||||
{
|
||||
opt.AllowedHosts = new[] { apex, $"*.{apex}", "localhost", "127.0.0.1" };
|
||||
opt.AllowEmptyHosts = true;
|
||||
opt.IncludeFailureMessage = true;
|
||||
});
|
||||
Console.WriteLine($"[domain] apex={apex}, scheme={domainScheme}, port={domainPort}, singleOrigin={singleOriginBaseUrl}, allowedHosts=[{apex}, *.{apex}, localhost]");
|
||||
return domain;
|
||||
}
|
||||
|
||||
private static void AddCoreServices(IServiceCollection services)
|
||||
{
|
||||
services.AddScoped<PlayerService>();
|
||||
services.AddScoped<ConfigService>();
|
||||
services.AddScoped<AuthService>();
|
||||
services.AddScoped<RoomService>();
|
||||
services.AddScoped<LevelService>();
|
||||
services.AddScoped<StoreService>();
|
||||
services.AddSingleton<RoomDataBlobService>();
|
||||
services.AddSingleton<RoomBlobNormalizerService>();
|
||||
services.AddHttpClient();
|
||||
services.AddSingleton<HtrAssetMirrorService>();
|
||||
services.AddScoped<GameSessionService>();
|
||||
services.AddScoped<PrivateInstanceService>();
|
||||
services.AddScoped<CommunityBoardService>();
|
||||
services.AddScoped<ServerSettingsService>();
|
||||
services.AddScoped<SignupCodeService>();
|
||||
services.AddSingleton<NotificationService>();
|
||||
services.AddSingleton<OrphanAccountTracker>();
|
||||
services.AddSingleton<PlayerPresenceService>();
|
||||
services.AddSingleton<OnlinePresenceService>();
|
||||
services.AddSingleton<PlayerLogService>();
|
||||
services.AddSingleton<IObjectStorage, ObjectStorageService>();
|
||||
services.AddSingleton<ImageSignatureService>();
|
||||
}
|
||||
|
||||
private static string? AddRedis(WebApplicationBuilder builder)
|
||||
{
|
||||
var redisConn = builder.Configuration.GetConnectionString("Redis");
|
||||
if (string.IsNullOrWhiteSpace(redisConn)) return null;
|
||||
|
||||
var seRedisConfig = NormalizeRedisConn(redisConn);
|
||||
var mux = ConnectionMultiplexer.Connect(seRedisConfig);
|
||||
builder.Services.AddSingleton<IConnectionMultiplexer>(mux);
|
||||
return redisConn;
|
||||
}
|
||||
|
||||
internal static string NormalizeRedisConn(string raw)
|
||||
{
|
||||
if (!raw.StartsWith("redis://", StringComparison.OrdinalIgnoreCase) &&
|
||||
!raw.StartsWith("rediss://", StringComparison.OrdinalIgnoreCase))
|
||||
return raw;
|
||||
|
||||
var uri = new Uri(raw);
|
||||
var host = uri.Host;
|
||||
var port = uri.Port > 0 ? uri.Port : 6379;
|
||||
string? user = null, pass = null;
|
||||
if (!string.IsNullOrEmpty(uri.UserInfo))
|
||||
{
|
||||
var parts = uri.UserInfo.Split(':', 2);
|
||||
user = Uri.UnescapeDataString(parts[0]);
|
||||
if (parts.Length == 2) pass = Uri.UnescapeDataString(parts[1]);
|
||||
}
|
||||
var opts = new ConfigurationOptions
|
||||
{
|
||||
EndPoints = { { host, port } },
|
||||
AbortOnConnectFail = false,
|
||||
Ssl = raw.StartsWith("rediss://", StringComparison.OrdinalIgnoreCase),
|
||||
};
|
||||
if (!string.IsNullOrEmpty(user)) opts.User = user;
|
||||
if (!string.IsNullOrEmpty(pass)) opts.Password = pass;
|
||||
return opts.ToString();
|
||||
}
|
||||
|
||||
private static void AddAuthentication(WebApplicationBuilder builder, DomainConfig domain)
|
||||
{
|
||||
var signingKeyProvider = new IdentityServerSigningKeyProvider(builder.Configuration);
|
||||
builder.Services.AddSingleton(signingKeyProvider);
|
||||
builder.Services.AddRecRoomIdentityServer(builder.Configuration, domain, signingKeyProvider);
|
||||
|
||||
builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
|
||||
.AddJwtBearer(opt =>
|
||||
{
|
||||
opt.TokenValidationParameters = new TokenValidationParameters
|
||||
{
|
||||
ValidateIssuerSigningKey = true,
|
||||
IssuerSigningKeys = signingKeyProvider.ValidationKeys,
|
||||
ValidateIssuer = true,
|
||||
ValidIssuers = new[] { domain.AuthIssuer, AuthService.LegacyIssuer },
|
||||
ValidateAudience = false,
|
||||
ClockSkew = TimeSpan.Zero,
|
||||
};
|
||||
opt.Events = new JwtBearerEvents
|
||||
{
|
||||
OnMessageReceived = ctx =>
|
||||
{
|
||||
var accessToken = ctx.Request.Query["access_token"];
|
||||
var path = ctx.HttpContext.Request.Path;
|
||||
if (!string.IsNullOrEmpty(accessToken) && path.StartsWithSegments("/hub"))
|
||||
ctx.Token = accessToken;
|
||||
if (string.IsNullOrEmpty(ctx.Token) &&
|
||||
ctx.Request.Cookies.TryGetValue(AuthService.AccessCookieName, out var cookieToken) &&
|
||||
!string.IsNullOrWhiteSpace(cookieToken))
|
||||
{
|
||||
ctx.Token = cookieToken;
|
||||
}
|
||||
return Task.CompletedTask;
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
builder.Services.AddAuthorization();
|
||||
builder.Services.AddControllers().AddJsonOptions(opt =>
|
||||
opt.JsonSerializerOptions.PropertyNamingPolicy = null);
|
||||
}
|
||||
|
||||
private static void AddKestrelAndSignalR(WebApplicationBuilder builder, string? redisConn)
|
||||
{
|
||||
builder.WebHost.ConfigureKestrel(kopts =>
|
||||
{
|
||||
kopts.Limits.KeepAliveTimeout = TimeSpan.FromMinutes(10);
|
||||
kopts.Limits.MinRequestBodyDataRate = null;
|
||||
kopts.Limits.MinResponseDataRate = null;
|
||||
});
|
||||
|
||||
var signalR = builder.Services.AddSignalR(opts =>
|
||||
{
|
||||
opts.KeepAliveInterval = TimeSpan.FromSeconds(10);
|
||||
opts.ClientTimeoutInterval = TimeSpan.FromSeconds(60);
|
||||
opts.HandshakeTimeout = TimeSpan.FromSeconds(15);
|
||||
opts.MaximumReceiveMessageSize = 1024 * 1024;
|
||||
opts.EnableDetailedErrors = true;
|
||||
});
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(redisConn))
|
||||
signalR.AddStackExchangeRedis(NormalizeRedisConn(redisConn), opts =>
|
||||
{
|
||||
opts.Configuration.ChannelPrefix = RedisChannel.Literal("dorknet-signalr");
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
using Microsoft.AspNetCore.Builder;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.Extensions.FileProviders;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace DorkNet.Server.Startup;
|
||||
|
||||
/// <summary>Per-host static-files branching for admin, public site, and feed hosts.</summary>
|
||||
public static class StaticHostExtensions
|
||||
{
|
||||
public static void MountStaticHost(this WebApplication app, string host, string subdir, bool spaFallback = false)
|
||||
{
|
||||
var staticHostLogger = app.Services.GetRequiredService<ILoggerFactory>()
|
||||
.CreateLogger("DorkNet.Server.StaticHost");
|
||||
var root = Path.Combine(app.Environment.ContentRootPath, "wwwroot", subdir);
|
||||
staticHostLogger.LogInformation(
|
||||
"[static-host] {Host} -> {Root} (exists: {Exists}, spaFallback: {Spa})",
|
||||
host, root, Directory.Exists(root), spaFallback);
|
||||
if (!Directory.Exists(root)) return;
|
||||
var files = new PhysicalFileProvider(root);
|
||||
|
||||
var defaultFilesOpts = new DefaultFilesOptions { FileProvider = files };
|
||||
var staticFilesOpts = new StaticFileOptions
|
||||
{
|
||||
FileProvider = files,
|
||||
OnPrepareResponse = ctx =>
|
||||
{
|
||||
var headers = ctx.Context.Response.Headers;
|
||||
headers.CacheControl = "no-cache, no-store, must-revalidate";
|
||||
headers.Pragma = "no-cache";
|
||||
headers.Expires = "0";
|
||||
},
|
||||
};
|
||||
|
||||
app.UseWhen(ctx => ctx.Request.Host.Host.Equals(host, StringComparison.OrdinalIgnoreCase),
|
||||
branch =>
|
||||
{
|
||||
branch.Use(async (ctx, next) =>
|
||||
{
|
||||
var p = ctx.Request.Path.Value ?? "/";
|
||||
var probe = files.GetFileInfo(p == "/" ? "/index.html" : p);
|
||||
staticHostLogger.LogInformation(
|
||||
"[static-host:{Host}] path={Path} probe={Probe} exists={Exists}",
|
||||
host, p, probe.PhysicalPath ?? "<null>", probe.Exists);
|
||||
if (!p.StartsWith("/api/", StringComparison.OrdinalIgnoreCase))
|
||||
ctx.SetEndpoint(null);
|
||||
await next();
|
||||
});
|
||||
branch.UseDefaultFiles(defaultFilesOpts);
|
||||
branch.UseStaticFiles(staticFilesOpts);
|
||||
|
||||
branch.Use(async (ctx, next) =>
|
||||
{
|
||||
if (ctx.Response.HasStarted) return;
|
||||
|
||||
var path = ctx.Request.Path.Value ?? "/";
|
||||
|
||||
if (path.StartsWith("/api/", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
await next();
|
||||
return;
|
||||
}
|
||||
|
||||
if (ctx.Request.Method != "GET" && ctx.Request.Method != "HEAD")
|
||||
{
|
||||
await next();
|
||||
return;
|
||||
}
|
||||
|
||||
if (Path.HasExtension(path))
|
||||
{
|
||||
ctx.Response.StatusCode = StatusCodes.Status404NotFound;
|
||||
return;
|
||||
}
|
||||
|
||||
if (!spaFallback)
|
||||
{
|
||||
ctx.Response.StatusCode = StatusCodes.Status404NotFound;
|
||||
return;
|
||||
}
|
||||
|
||||
var index = files.GetFileInfo("/index.html");
|
||||
if (!index.Exists)
|
||||
{
|
||||
ctx.Response.StatusCode = StatusCodes.Status404NotFound;
|
||||
return;
|
||||
}
|
||||
|
||||
ctx.Response.ContentType = "text/html; charset=utf-8";
|
||||
var hdrs = ctx.Response.Headers;
|
||||
hdrs.CacheControl = "no-cache, no-store, must-revalidate";
|
||||
hdrs.Pragma = "no-cache";
|
||||
hdrs.Expires = "0";
|
||||
await using var stream = index.CreateReadStream();
|
||||
await stream.CopyToAsync(ctx.Response.Body);
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,20 @@
|
||||
import { clearSession, getToken, notifyAuthChange } from './auth';
|
||||
|
||||
const API_BASE = '/api/admin/v1';
|
||||
const API_BASE = adminApiBase();
|
||||
|
||||
function adminApiBase(): string {
|
||||
if (typeof window === 'undefined') return '/api/admin/v1';
|
||||
|
||||
const marker = '/__dn/admin';
|
||||
const path = window.location.pathname;
|
||||
const lower = path.toLowerCase();
|
||||
const markerAt = lower.indexOf(marker);
|
||||
if (markerAt >= 0) {
|
||||
return path.slice(0, markerAt + marker.length) + '/api/admin/v1';
|
||||
}
|
||||
|
||||
return '/api/admin/v1';
|
||||
}
|
||||
|
||||
export class ApiError extends Error {
|
||||
constructor(public status: number, message: string, public body?: unknown) {
|
||||
|
||||
@@ -0,0 +1,209 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { get } from '../lib/api';
|
||||
import { RefreshCw } from '../components/Icons';
|
||||
|
||||
interface IdentityServerSettings {
|
||||
issuer: string;
|
||||
discoveryUrl: string;
|
||||
tokenEndpoint: string;
|
||||
userInfoEndpoint: string;
|
||||
client: {
|
||||
clientId: string;
|
||||
clientName: string;
|
||||
allowedGrantTypes: string[];
|
||||
allowedScopes: string[];
|
||||
allowOfflineAccess: boolean;
|
||||
accessTokenLifetime: number;
|
||||
absoluteRefreshTokenLifetime: number;
|
||||
slidingRefreshTokenLifetime: number;
|
||||
refreshTokenUsage: string;
|
||||
refreshTokenExpiration: string;
|
||||
};
|
||||
signing: {
|
||||
certificateSource: string;
|
||||
loadedFromBase64: boolean;
|
||||
certificatePath: string;
|
||||
certificateExists: boolean;
|
||||
thumbprint: string;
|
||||
notBeforeUtc: string;
|
||||
notAfterUtc: string;
|
||||
algorithm: string;
|
||||
};
|
||||
persistedGrants: {
|
||||
store: string;
|
||||
warning: string;
|
||||
};
|
||||
}
|
||||
|
||||
export function IdentityServer() {
|
||||
const [settings, setSettings] = useState<IdentityServerSettings | null>(null);
|
||||
const [err, setErr] = useState<string | null>(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
const load = async () => {
|
||||
setBusy(true);
|
||||
try {
|
||||
const data = await get<IdentityServerSettings>('/identityserver');
|
||||
setSettings(data);
|
||||
setErr(null);
|
||||
} catch (e) {
|
||||
setErr((e as Error).message);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => { void load(); }, []);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="mb-3 flex justify-end">
|
||||
<button onClick={load} className="btn-secondary text-xs" disabled={busy}>
|
||||
<RefreshCw className={busy ? 'animate-spin' : ''} />
|
||||
Refresh
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{err && (
|
||||
<div className="card border-danger/30 bg-danger/5 px-4 py-3 text-sm text-danger mb-4">{err}</div>
|
||||
)}
|
||||
|
||||
{!settings && !err && (
|
||||
<div className="card !p-6 text-center text-xs text-ink-400">Loading...</div>
|
||||
)}
|
||||
|
||||
{settings && (
|
||||
<div className="grid gap-5 xl:grid-cols-[minmax(0,1fr)_360px]">
|
||||
<div className="space-y-5">
|
||||
<div className="card !p-5">
|
||||
<div className="flex flex-wrap items-start justify-between gap-4">
|
||||
<div>
|
||||
<h2 className="text-sm font-semibold text-ink-50">IdentityServer</h2>
|
||||
<p className="mt-1 break-all font-mono text-xs text-ink-300">{settings.issuer}</p>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2 text-xs">
|
||||
<StatusBadge ok={settings.signing.certificateExists} on="Signing key" off="Missing key" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-5 grid gap-3 md:grid-cols-3">
|
||||
<Endpoint label="Discovery" value={settings.discoveryUrl} />
|
||||
<Endpoint label="Token" value={settings.tokenEndpoint} />
|
||||
<Endpoint label="UserInfo" value={settings.userInfoEndpoint} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="card !p-5">
|
||||
<div className="mb-4 flex flex-wrap items-center justify-between gap-3">
|
||||
<div>
|
||||
<h2 className="text-sm font-semibold text-ink-50">Client</h2>
|
||||
<p className="mt-1 font-mono text-xs text-ink-400">{settings.client.clientId}</p>
|
||||
</div>
|
||||
<span className={settings.client.allowOfflineAccess ? 'badge-online' : 'badge-neutral'}>
|
||||
Offline access {settings.client.allowOfflineAccess ? 'on' : 'off'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 lg:grid-cols-2">
|
||||
<ChipList title="Grant types" values={settings.client.allowedGrantTypes} />
|
||||
<ChipList title="Scopes" values={settings.client.allowedScopes} />
|
||||
</div>
|
||||
|
||||
<div className="mt-5 grid gap-3 sm:grid-cols-3">
|
||||
<Metric label="Access token" value={duration(settings.client.accessTokenLifetime)} />
|
||||
<Metric label="Refresh token" value={duration(settings.client.absoluteRefreshTokenLifetime)} />
|
||||
<Metric label="Refresh mode" value={`${settings.client.refreshTokenUsage} / ${settings.client.refreshTokenExpiration}`} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<div className="space-y-5">
|
||||
<div className="card !p-5">
|
||||
<h2 className="text-sm font-semibold text-ink-50">Signing certificate</h2>
|
||||
<div className="mt-4 space-y-3">
|
||||
<Detail label="Source" value={settings.signing.certificateSource} mono />
|
||||
{!settings.signing.loadedFromBase64 && (
|
||||
<Detail label="Path" value={settings.signing.certificatePath} mono />
|
||||
)}
|
||||
<Detail label="Thumbprint" value={settings.signing.thumbprint} mono />
|
||||
<Detail label="Algorithm" value={settings.signing.algorithm} mono />
|
||||
<Detail label="Valid from" value={formatDate(settings.signing.notBeforeUtc)} />
|
||||
<Detail label="Valid until" value={formatDate(settings.signing.notAfterUtc)} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="card !p-5">
|
||||
<h2 className="text-sm font-semibold text-ink-50">Operational store</h2>
|
||||
<div className="mt-3 flex items-center justify-between gap-3">
|
||||
<span className="text-xs text-ink-400">Persisted grants</span>
|
||||
<span className="badge-junior">{settings.persistedGrants.store}</span>
|
||||
</div>
|
||||
<p className="mt-4 text-xs leading-5 text-ink-400">{settings.persistedGrants.warning}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Endpoint({ label, value }: { label: string; value: string }) {
|
||||
return (
|
||||
<div className="rounded-lg border border-ink-800 bg-ink-950/40 p-3">
|
||||
<div className="label">{label}</div>
|
||||
<div className="mt-2 break-all font-mono text-xs text-ink-100">{value}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ChipList({ title, values }: { title: string; values: string[] }) {
|
||||
return (
|
||||
<div className="rounded-lg border border-ink-800 bg-ink-950/30 p-4">
|
||||
<h3 className="text-xs font-semibold uppercase text-ink-400">{title}</h3>
|
||||
<div className="mt-3 flex flex-wrap gap-2">
|
||||
{values.map((value) => (
|
||||
<span key={value} className="badge-neutral font-mono normal-case tracking-normal">{value}</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Metric({ label, value }: { label: string; value: string }) {
|
||||
return (
|
||||
<div className="rounded-lg border border-ink-800 bg-ink-950/30 p-3">
|
||||
<div className="text-[11px] uppercase text-ink-500">{label}</div>
|
||||
<div className="mt-1 text-sm font-semibold text-ink-50">{value}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Detail({ label, value, mono }: { label: string; value: string; mono?: boolean }) {
|
||||
return (
|
||||
<div>
|
||||
<div className="label">{label}</div>
|
||||
<div className={(mono ? 'font-mono ' : '') + 'mt-1 break-all text-xs text-ink-100'}>{value}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function StatusBadge({ ok, on, off }: { ok: boolean; on: string; off: string }) {
|
||||
return <span className={ok ? 'badge-online' : 'badge-junior'}>{ok ? on : off}</span>;
|
||||
}
|
||||
|
||||
function formatDate(value: string): string {
|
||||
const date = new Date(value);
|
||||
return Number.isNaN(date.getTime()) ? value : date.toLocaleString();
|
||||
}
|
||||
|
||||
function duration(seconds: number): string {
|
||||
if (!Number.isFinite(seconds) || seconds <= 0) return '0s';
|
||||
const days = Math.floor(seconds / 86400);
|
||||
const hours = Math.floor((seconds % 86400) / 3600);
|
||||
const minutes = Math.floor((seconds % 3600) / 60);
|
||||
if (days > 0) return `${days}d ${hours}h`;
|
||||
if (hours > 0) return `${hours}h ${minutes}m`;
|
||||
if (minutes > 0) return `${minutes}m`;
|
||||
return `${seconds}s`;
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Settings } from './Settings';
|
||||
import { IdentityServer } from './IdentityServer';
|
||||
import { SignupCodes } from './SignupCodes';
|
||||
import { PageHeader } from '../components/PageHeader';
|
||||
import { Tabs, useTabParam } from '../components/Tabs';
|
||||
@@ -7,19 +8,21 @@ import { Tabs, useTabParam } from '../components/Tabs';
|
||||
// invite codes (an access-control setting in its own right). Old
|
||||
// /signup-codes route redirects here with ?tab=signup.
|
||||
export function SettingsHome() {
|
||||
const [tab, setTab] = useTabParam<'server' | 'signup'>('server', ['server', 'signup']);
|
||||
const [tab, setTab] = useTabParam<'server' | 'identity' | 'signup'>('server', ['server', 'identity', 'signup']);
|
||||
return (
|
||||
<div>
|
||||
<PageHeader title="Settings" blurb="Runtime server toggles and single-use signup invite codes." />
|
||||
<PageHeader title="Settings" blurb="Runtime server toggles, authentication, and single-use signup invite codes." />
|
||||
<Tabs
|
||||
tabs={[
|
||||
{ key: 'server', label: 'Server' },
|
||||
{ key: 'identity', label: 'IdentityServer' },
|
||||
{ key: 'signup', label: 'Signup codes' },
|
||||
]}
|
||||
active={tab}
|
||||
onChange={setTab}
|
||||
/>
|
||||
{tab === 'server' && <Settings embedded />}
|
||||
{tab === 'identity' && <IdentityServer />}
|
||||
{tab === 'signup' && <SignupCodes embedded />}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -32,7 +32,7 @@ export function App() {
|
||||
<div className="mx-auto max-w-6xl px-4 sm:px-6 py-4 flex flex-wrap items-center justify-between gap-2">
|
||||
<span>DorkNet — a private Rec Room server.</span>
|
||||
<span>
|
||||
<a className="hover:text-ink-200" href="https://github.com/Alexa-RR/Dorknet">GitHub</a>
|
||||
<a className="hover:text-ink-200" href="https://github.com/DorkSquad/Dorknet">GitHub</a>
|
||||
<span className="mx-2">·</span>
|
||||
<a className="hover:text-ink-200" href="/admin">Admin</a>
|
||||
</span>
|
||||
|
||||
@@ -10,6 +10,8 @@ import { num, relativeTime } from '../lib/format';
|
||||
// inner anchor preserves that even on touch devices where iOS
|
||||
// occasionally fires both handlers.
|
||||
export function PhotoCard({ photo }: { photo: SitePhoto }) {
|
||||
const tagged = photo.taggedPlayers ?? [];
|
||||
|
||||
return (
|
||||
<Link
|
||||
to={`/photo/${photo.id}`}
|
||||
@@ -42,12 +44,34 @@ export function PhotoCard({ photo }: { photo: SitePhoto }) {
|
||||
{photo.caption && (
|
||||
<p className="mt-2 text-xs text-ink-300 line-clamp-2">{photo.caption}</p>
|
||||
)}
|
||||
<div className="mt-2 flex items-center justify-between text-[11px] text-ink-400">
|
||||
{tagged.length > 0 && (
|
||||
<div className="mt-2 flex items-center gap-1.5 text-[11px] text-ink-400">
|
||||
<span className="shrink-0 text-ink-500">With</span>
|
||||
<div className="min-w-0 flex flex-wrap items-center gap-1">
|
||||
{tagged.slice(0, 3).map(p => (
|
||||
<Link
|
||||
key={p.id}
|
||||
to={`/players/${p.id}`}
|
||||
onClick={e => e.stopPropagation()}
|
||||
className="inline-flex max-w-[8rem] items-center gap-1 rounded-md bg-ink-800/70 px-1.5 py-0.5 text-ink-200 hover:text-ink-50"
|
||||
>
|
||||
<PlayerAvatar name={p.profileImageName} displayName={p.displayName || p.username} size={16} />
|
||||
<span className="truncate">{p.displayName || p.username}</span>
|
||||
</Link>
|
||||
))}
|
||||
{tagged.length > 3 && <span className="text-ink-500">+{tagged.length - 3}</span>}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div className="mt-2 flex items-center justify-between gap-2 text-[11px] text-ink-400">
|
||||
<span title="cheers">♥ {num(photo.cheerCount)}</span>
|
||||
{photo.roomName
|
||||
? <span className="font-mono truncate ml-2" title={`Room: ${photo.roomName}`}>^{photo.roomName}</span>
|
||||
: <span>{relativeTime(photo.createdAt)}</span>
|
||||
}
|
||||
<span className="min-w-0 truncate text-right">
|
||||
{photo.roomName && (
|
||||
<span className="font-mono text-ink-300" title={`Room: ${photo.roomName}`}>^{photo.roomName}</span>
|
||||
)}
|
||||
{photo.roomName && <span className="text-ink-600"> · </span>}
|
||||
<span>{relativeTime(photo.createdAt)}</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
|
||||
@@ -20,6 +20,17 @@ export interface SitePlayerDetail extends SitePlayerCard {
|
||||
xp: number;
|
||||
createdAt: string;
|
||||
photoCount: number;
|
||||
photosTakenCount: number;
|
||||
photosOfPlayerCount: number;
|
||||
friendCount: number;
|
||||
publicRoomCount: number;
|
||||
}
|
||||
|
||||
export interface SitePhotoPlayer {
|
||||
id: number;
|
||||
username: string;
|
||||
displayName: string;
|
||||
profileImageName: string | null;
|
||||
}
|
||||
|
||||
export interface SitePhoto {
|
||||
@@ -33,6 +44,7 @@ export interface SitePhoto {
|
||||
caption: string;
|
||||
roomId: number;
|
||||
roomName: string;
|
||||
taggedPlayers: SitePhotoPlayer[];
|
||||
isPublic: boolean;
|
||||
cheerCount: number;
|
||||
viewCount: number;
|
||||
|
||||
@@ -46,7 +46,7 @@
|
||||
</main>
|
||||
|
||||
<footer class="site-footer">
|
||||
<p>DorkNet · a private Rec Room server · <a href="https://github.com/Alexa-RR/Dorknet">GitHub</a></p>
|
||||
<p>DorkNet · a private Rec Room server · <a href="https://github.com/DorkSquad/Dorknet">GitHub</a></p>
|
||||
</footer>
|
||||
|
||||
<div id="toast" class="toast" hidden></div>
|
||||
|
||||
+15
@@ -11,6 +11,8 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Tools", "Tools", "{07C2787E
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MigrateSqliteToPostgres", "Tools\MigrateSqliteToPostgres\MigrateSqliteToPostgres.csproj", "{70C60098-1B11-4C13-943B-A8A056429EAE}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "DorkNet.ApiRouteCoverage", "tools\DorkNet.ApiRouteCoverage\DorkNet.ApiRouteCoverage.csproj", "{AB8D33A2-8F4C-4E10-BD25-51343D20F7ED}"
|
||||
EndProject
|
||||
Global
|
||||
GlobalSection(SolutionConfigurationPlatforms) = preSolution
|
||||
Debug|Any CPU = Debug|Any CPU
|
||||
@@ -57,11 +59,24 @@ Global
|
||||
{70C60098-1B11-4C13-943B-A8A056429EAE}.Release|x64.Build.0 = Release|Any CPU
|
||||
{70C60098-1B11-4C13-943B-A8A056429EAE}.Release|x86.ActiveCfg = Release|Any CPU
|
||||
{70C60098-1B11-4C13-943B-A8A056429EAE}.Release|x86.Build.0 = Release|Any CPU
|
||||
{AB8D33A2-8F4C-4E10-BD25-51343D20F7ED}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{AB8D33A2-8F4C-4E10-BD25-51343D20F7ED}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{AB8D33A2-8F4C-4E10-BD25-51343D20F7ED}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||
{AB8D33A2-8F4C-4E10-BD25-51343D20F7ED}.Debug|x64.Build.0 = Debug|Any CPU
|
||||
{AB8D33A2-8F4C-4E10-BD25-51343D20F7ED}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||
{AB8D33A2-8F4C-4E10-BD25-51343D20F7ED}.Debug|x86.Build.0 = Debug|Any CPU
|
||||
{AB8D33A2-8F4C-4E10-BD25-51343D20F7ED}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{AB8D33A2-8F4C-4E10-BD25-51343D20F7ED}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{AB8D33A2-8F4C-4E10-BD25-51343D20F7ED}.Release|x64.ActiveCfg = Release|Any CPU
|
||||
{AB8D33A2-8F4C-4E10-BD25-51343D20F7ED}.Release|x64.Build.0 = Release|Any CPU
|
||||
{AB8D33A2-8F4C-4E10-BD25-51343D20F7ED}.Release|x86.ActiveCfg = Release|Any CPU
|
||||
{AB8D33A2-8F4C-4E10-BD25-51343D20F7ED}.Release|x86.Build.0 = Release|Any CPU
|
||||
EndGlobalSection
|
||||
GlobalSection(SolutionProperties) = preSolution
|
||||
HideSolutionNode = FALSE
|
||||
EndGlobalSection
|
||||
GlobalSection(NestedProjects) = preSolution
|
||||
{70C60098-1B11-4C13-943B-A8A056429EAE} = {07C2787E-EAC7-C090-1BA3-A61EC2A24D84}
|
||||
{AB8D33A2-8F4C-4E10-BD25-51343D20F7ED} = {07C2787E-EAC7-C090-1BA3-A61EC2A24D84}
|
||||
EndGlobalSection
|
||||
EndGlobal
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
DorkNet — Rec Room Private Server
|
||||
Copyright (C) 2026 Alexa <https://github.com/Alexa-RR/Dorknet>
|
||||
Copyright (C) 2026 DorkSquad <https://github.com/DorkSquad/Dorknet>
|
||||
|
||||
This program is free software: you can redistribute it and/or modify it
|
||||
under the terms of the GNU Affero General Public License as published
|
||||
@@ -28,7 +28,7 @@ covered by AGPL §13 — you must:
|
||||
2. Preserve this NOTICE file in your distribution.
|
||||
|
||||
3. Credit the upstream project visibly. At minimum:
|
||||
"Based on DorkNet (https://github.com/Alexa-RR/Dorknet) by Alexa,
|
||||
"Based on DorkNet (https://github.com/DorkSquad/Dorknet) by DorkSquad,
|
||||
licensed AGPL-3.0."
|
||||
Acceptable placements: README, server console banner, in-game
|
||||
credits screen, or web-facing footer of any deployment dashboard.
|
||||
|
||||
@@ -72,13 +72,17 @@ cd DorkNet
|
||||
| `ConnectionStrings:Redis` | `ConnectionStrings__Redis` | no | enables SignalR backplane |
|
||||
| `Domain:Apex` | `DORKNET_DOMAIN` | no | defaults to `localhost`; set for production |
|
||||
|
||||
`/connect/token` issues access and refresh tokens with the configured
|
||||
auth host as the issuer. Set `DORKNET_DOMAIN` for production so OpenID
|
||||
discovery and JWT validation agree on `https://auth.<domain>`.
|
||||
|
||||
**4. Boot:**
|
||||
|
||||
```pwsh
|
||||
dotnet run --project DorkNet.Server
|
||||
```
|
||||
|
||||
First boot creates `bin/Debug/net9.0/data/dorknet.db`, applies all EF
|
||||
First boot creates `bin/Debug/net10.0/data/dorknet.db`, applies all EF
|
||||
migrations under `DorkNet.Server/Migrations/`, seeds canonical rooms +
|
||||
store catalog, and starts listening. You should see `Application
|
||||
started.` in the log; `curl http://localhost:8080/healthz` returns 200.
|
||||
@@ -104,6 +108,38 @@ AppIds, and bypasses TLS verification on the client's HTTPS calls so
|
||||
self-signed certs work. (First run prints "launch the game once"; do
|
||||
that, then re-run with `-ResumeBuild`.)
|
||||
|
||||
### Standalone Quest build (experimental)
|
||||
|
||||
The March Quest APK is also IL2CPP and can build the same
|
||||
`DorkNet.ClientMod` source against LemonLoader's Android runtime. The
|
||||
current Quest path is still device-tested manually: build the mod as a
|
||||
net8 LemonLoader DLL, install it into a LemonLoader-patched APK, then
|
||||
sideload the resigned APK.
|
||||
|
||||
From this branch:
|
||||
|
||||
```pwsh
|
||||
dotnet build .\DorkNet.ClientMod\DorkNet.ClientMod.csproj `
|
||||
-p:TargetFrameworks=net8.0 `
|
||||
-p:MelonLoaderDir="C:\path\to\LemonLoader\melon_data\MelonLoader"
|
||||
```
|
||||
|
||||
Output lands at
|
||||
`DorkNet.ClientMod\bin\Debug\net8.0\DorkNet.ClientMod.dll`. Desktop
|
||||
builds still default to net6 and keep the historical flat output path.
|
||||
|
||||
Quest notes:
|
||||
|
||||
- Target package: `com.AgainstGravity.RecRoom`.
|
||||
- Use a public HTTPS DorkNet host; keep `"EnableTlsTrustBypass": false`
|
||||
for Quest builds.
|
||||
- March uses the older PhotonServerSettings path: the ClientMod polls
|
||||
until `PhotonNetwork.PhotonServerSettings` is loaded, then rewrites
|
||||
`AppID`, `VoiceAppID`, and the fixed region by reflection.
|
||||
- Standalone APK patching/resigning is not yet wrapped by
|
||||
`install-melon.ps1`; this is the porting path, not a public one-click
|
||||
installer yet.
|
||||
|
||||
### Debug console (opt-in)
|
||||
|
||||
The 2020 client ships a built-in dev console
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<OutputType>Exe</OutputType>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<FrameworkReference Include="Microsoft.AspNetCore.App" />
|
||||
<ProjectReference Include="..\..\DorkNet.Server\DorkNet.Server.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,145 @@
|
||||
using System.Reflection;
|
||||
using System.Text.RegularExpressions;
|
||||
using DorkNet.Server.Services;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.AspNetCore.Mvc.Routing;
|
||||
|
||||
var routes = DiscoverRoutes().OrderBy(r => r.Method).ThenBy(r => r.Template).ToArray();
|
||||
var failures = new List<string>();
|
||||
|
||||
if (routes.Length == 0)
|
||||
failures.Add("No HTTP controller routes were discovered.");
|
||||
|
||||
foreach (var route in routes)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(route.Template))
|
||||
failures.Add($"{route.Source}: empty route template");
|
||||
|
||||
if (route.SamplePath.Contains('{') || route.SamplePath.Contains('}'))
|
||||
failures.Add($"{route.Source}: unresolved route parameter in {route.SamplePath}");
|
||||
|
||||
if (route.SamplePath.Contains('[') || route.SamplePath.Contains(']'))
|
||||
failures.Add($"{route.Source}: unresolved token in {route.SamplePath}");
|
||||
|
||||
if (!route.SamplePath.StartsWith('/'))
|
||||
failures.Add($"{route.Source}: sample path must be absolute: {route.SamplePath}");
|
||||
}
|
||||
|
||||
Console.WriteLine($"Discovered {routes.Length} HTTP route cases.");
|
||||
foreach (var route in routes)
|
||||
Console.WriteLine($"{route.Method,-6} {route.SamplePath,-72} {route.Source}");
|
||||
|
||||
if (failures.Count == 0)
|
||||
return 0;
|
||||
|
||||
Console.Error.WriteLine();
|
||||
Console.Error.WriteLine("Route coverage failed:");
|
||||
foreach (var failure in failures)
|
||||
Console.Error.WriteLine($"- {failure}");
|
||||
return 1;
|
||||
|
||||
static IEnumerable<RouteCase> DiscoverRoutes()
|
||||
{
|
||||
var controllerBaseType = typeof(ControllerBase);
|
||||
var serverAssembly = typeof(DomainConfig).Assembly;
|
||||
|
||||
foreach (var controller in serverAssembly.GetTypes()
|
||||
.Where(t => !t.IsAbstract && controllerBaseType.IsAssignableFrom(t)))
|
||||
{
|
||||
var controllerName = ControllerName(controller);
|
||||
var controllerRouteTemplates = controller.GetCustomAttributes(inherit: true)
|
||||
.OfType<IRouteTemplateProvider>()
|
||||
.Select(a => a.Template)
|
||||
.Where(t => !string.IsNullOrWhiteSpace(t))
|
||||
.Cast<string>()
|
||||
.DefaultIfEmpty("")
|
||||
.ToArray();
|
||||
|
||||
foreach (var method in controller.GetMethods(BindingFlags.Instance | BindingFlags.Public | BindingFlags.DeclaredOnly))
|
||||
{
|
||||
var httpAttrs = method.GetCustomAttributes(inherit: true)
|
||||
.OfType<HttpMethodAttribute>()
|
||||
.ToArray();
|
||||
if (httpAttrs.Length == 0) continue;
|
||||
|
||||
foreach (var attr in httpAttrs)
|
||||
{
|
||||
IEnumerable<string> attrMethods = attr.HttpMethods.Any()
|
||||
? attr.HttpMethods
|
||||
: new[] { "ANY" };
|
||||
var actionName = method.Name;
|
||||
foreach (var httpMethod in attrMethods)
|
||||
{
|
||||
foreach (var controllerTemplate in controllerRouteTemplates)
|
||||
{
|
||||
var template = CombineTemplates(controllerTemplate, attr.Template);
|
||||
template = ReplaceRouteTokens(template, controllerName, actionName);
|
||||
yield return new RouteCase(
|
||||
httpMethod.ToUpperInvariant(),
|
||||
template,
|
||||
ToSamplePath(template),
|
||||
$"{controller.FullName}.{method.Name}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
static string ControllerName(Type controller)
|
||||
{
|
||||
const string suffix = "Controller";
|
||||
return controller.Name.EndsWith(suffix, StringComparison.Ordinal)
|
||||
? controller.Name[..^suffix.Length]
|
||||
: controller.Name;
|
||||
}
|
||||
|
||||
static string CombineTemplates(string? controllerTemplate, string? actionTemplate)
|
||||
{
|
||||
controllerTemplate ??= "";
|
||||
actionTemplate ??= "";
|
||||
|
||||
if (actionTemplate.StartsWith("~/", StringComparison.Ordinal))
|
||||
return "/" + actionTemplate[2..].TrimStart('/');
|
||||
|
||||
if (actionTemplate.StartsWith("/", StringComparison.Ordinal))
|
||||
return "/" + actionTemplate.TrimStart('/');
|
||||
|
||||
var left = controllerTemplate.Trim('/');
|
||||
var right = actionTemplate.Trim('/');
|
||||
|
||||
if (string.IsNullOrEmpty(left) && string.IsNullOrEmpty(right)) return "/";
|
||||
if (string.IsNullOrEmpty(left)) return "/" + right;
|
||||
if (string.IsNullOrEmpty(right)) return "/" + left;
|
||||
return "/" + left + "/" + right;
|
||||
}
|
||||
|
||||
static string ReplaceRouteTokens(string template, string controllerName, string actionName)
|
||||
{
|
||||
return template
|
||||
.Replace("[controller]", controllerName, StringComparison.OrdinalIgnoreCase)
|
||||
.Replace("[action]", actionName, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
static string ToSamplePath(string template)
|
||||
{
|
||||
var path = RouteParameterRegex().Replace(template, match =>
|
||||
{
|
||||
var constraint = match.Groups["constraint"].Value.ToLowerInvariant();
|
||||
if (constraint.Contains("long") || constraint.Contains("int")) return "1";
|
||||
if (constraint.Contains("bool")) return "true";
|
||||
if (constraint.Contains("guid")) return "00000000-0000-0000-0000-000000000001";
|
||||
return "sample";
|
||||
});
|
||||
|
||||
path = Regex.Replace(path, "/+", "/");
|
||||
return path.StartsWith('/') ? path : "/" + path;
|
||||
}
|
||||
|
||||
internal sealed record RouteCase(string Method, string Template, string SamplePath, string Source);
|
||||
|
||||
partial class Program
|
||||
{
|
||||
[GeneratedRegex(@"\{[*]?(?<name>[^}:=\?]+)(?<constraint>:[^}=\?]+)?(?<optional>\?)?(?<default>=[^}]+)?\}")]
|
||||
private static partial Regex RouteParameterRegex();
|
||||
}
|
||||
Reference in New Issue
Block a user