mirror of
https://github.com/DorkSquadRR/DorkNet.git
synced 2026-10-26 07:28:45 -07:00
Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| a07360ee3f |
+4
-4
@@ -1,14 +1,14 @@
|
|||||||
# DorkNet.Server container image — Coolify deploy target.
|
# DorkNet.Server container image — Coolify deploy target.
|
||||||
# ────────────────────────────────────────────────────────────────────
|
# ────────────────────────────────────────────────────────────────────
|
||||||
# Multi-stage: SDK image builds + publishes, runtime image is the
|
# Multi-stage: SDK image builds + publishes, runtime image is the
|
||||||
# slimmer aspnet:10.0 with only the published output.
|
# slimmer aspnet:9.0 with only the published output.
|
||||||
#
|
#
|
||||||
# Build context: repo root (so Tools/ and DorkNet.Models/ are visible
|
# Build context: repo root (so Tools/ and DorkNet.Models/ are visible
|
||||||
# to ProjectReference + dotnet restore). The .dockerignore at the
|
# to ProjectReference + dotnet restore). The .dockerignore at the
|
||||||
# repo root keeps the bin/, obj/, and other dev junk out of the
|
# repo root keeps the bin/, obj/, and other dev junk out of the
|
||||||
# context.
|
# context.
|
||||||
|
|
||||||
ARG DOTNET_VERSION=10.0
|
ARG DOTNET_VERSION=9.0
|
||||||
ARG NODE_VERSION=20
|
ARG NODE_VERSION=20
|
||||||
|
|
||||||
# ── admin SPA build stage ──────────────────────────────────────────
|
# ── admin SPA build stage ──────────────────────────────────────────
|
||||||
@@ -76,13 +76,13 @@ WORKDIR /app
|
|||||||
|
|
||||||
# curl + wget for healthcheck probes — Coolify v4 defaults to wget for
|
# curl + wget for healthcheck probes — Coolify v4 defaults to wget for
|
||||||
# its own probe regardless of what the image's HEALTHCHECK uses, so
|
# its own probe regardless of what the image's HEALTHCHECK uses, so
|
||||||
# both have to be present. The aspnet:10.0 base image strips both by
|
# both have to be present. The aspnet:9.0 base image strips both by
|
||||||
# default to keep the surface area small.
|
# default to keep the surface area small.
|
||||||
RUN apt-get update \
|
RUN apt-get update \
|
||||||
&& apt-get install -y --no-install-recommends curl wget \
|
&& apt-get install -y --no-install-recommends curl wget \
|
||||||
&& rm -rf /var/lib/apt/lists/*
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
# Run as a non-root user — aspnet:10.0 ships with the `app` UID 1654.
|
# Run as a non-root user — aspnet:9.0 ships with the `app` UID 1654.
|
||||||
# Coolify mounts persistent volumes with write permission for this UID.
|
# Coolify mounts persistent volumes with write permission for this UID.
|
||||||
USER app
|
USER app
|
||||||
|
|
||||||
|
|||||||
@@ -1,13 +1,12 @@
|
|||||||
<Project Sdk="Microsoft.NET.Sdk">
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
|
||||||
<PropertyGroup>
|
<PropertyGroup>
|
||||||
<TargetFramework Condition="'$(TargetFramework)' == '' and '$(TargetFrameworks)' == ''">net6.0</TargetFramework>
|
<TargetFramework>net6.0</TargetFramework>
|
||||||
<LangVersion>latest</LangVersion>
|
<LangVersion>latest</LangVersion>
|
||||||
<Nullable>enable</Nullable>
|
<Nullable>enable</Nullable>
|
||||||
<RootNamespace>DorkNet.ClientMod</RootNamespace>
|
<RootNamespace>DorkNet.ClientMod</RootNamespace>
|
||||||
<AssemblyName>DorkNet.ClientMod</AssemblyName>
|
<AssemblyName>DorkNet.ClientMod</AssemblyName>
|
||||||
<AppendTargetFrameworkToOutputPath Condition="'$(TargetFrameworks)' == ''">false</AppendTargetFrameworkToOutputPath>
|
<AppendTargetFrameworkToOutputPath>false</AppendTargetFrameworkToOutputPath>
|
||||||
<AppendTargetFrameworkToOutputPath Condition="'$(TargetFrameworks)' != ''">true</AppendTargetFrameworkToOutputPath>
|
|
||||||
<CopyLocalLockFileAssemblies>false</CopyLocalLockFileAssemblies>
|
<CopyLocalLockFileAssemblies>false</CopyLocalLockFileAssemblies>
|
||||||
<NoWarn>$(NoWarn);CS8602;CS8604;CS0436</NoWarn>
|
<NoWarn>$(NoWarn);CS8602;CS8604;CS0436</NoWarn>
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
@@ -20,28 +19,21 @@
|
|||||||
they would be derivative works of the game anyway, so we never
|
they would be derivative works of the game anyway, so we never
|
||||||
reference them. Only MelonLoader's own DLLs are needed.
|
reference them. Only MelonLoader's own DLLs are needed.
|
||||||
|
|
||||||
By default the project downloads the pinned desktop MelonLoader release
|
By default the project downloads the pinned MelonLoader release
|
||||||
into DorkNet.ClientMod/build/ and references its net6 DLLs. For Quest /
|
into DorkNet.ClientMod/build/ and references those DLLs. Pass
|
||||||
LemonLoader builds, pass:
|
-p:MelonLoaderDir=<path-to-extracted-MelonLoader> only when you
|
||||||
|
deliberately want to use a local override.
|
||||||
-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>
|
<PropertyGroup>
|
||||||
<MelonLoaderVersion Condition="'$(MelonLoaderVersion)' == ''">v0.6.6</MelonLoaderVersion>
|
<MelonLoaderVersion Condition="'$(MelonLoaderVersion)' == ''">v0.6.6</MelonLoaderVersion>
|
||||||
<MelonLoaderCacheRoot>$(MSBuildThisFileDirectory)build\melonloader</MelonLoaderCacheRoot>
|
<MelonLoaderCacheRoot>$(MSBuildThisFileDirectory)build\melonloader</MelonLoaderCacheRoot>
|
||||||
<MelonLoaderZip>$(MelonLoaderCacheRoot)\MelonLoader.x64.$(MelonLoaderVersion).zip</MelonLoaderZip>
|
<MelonLoaderZip>$(MelonLoaderCacheRoot)\MelonLoader.x64.$(MelonLoaderVersion).zip</MelonLoaderZip>
|
||||||
<MelonLoaderDir Condition="'$(MelonLoaderDir)' == ''">$(MelonLoaderCacheRoot)\MelonLoader</MelonLoaderDir>
|
<MelonLoaderDir Condition="'$(MelonLoaderDir)' == ''">$(MelonLoaderCacheRoot)\MelonLoader</MelonLoaderDir>
|
||||||
<MelonLoaderReferenceTfm Condition="'$(MelonLoaderReferenceTfm)' == '' and '$(TargetFramework)' == 'net8.0'">net8</MelonLoaderReferenceTfm>
|
|
||||||
<MelonLoaderReferenceTfm Condition="'$(MelonLoaderReferenceTfm)' == ''">net6</MelonLoaderReferenceTfm>
|
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
|
||||||
<Target Name="EnsureMelonLoaderReferences"
|
<Target Name="EnsureMelonLoaderReferences"
|
||||||
BeforeTargets="ResolveAssemblyReferences"
|
BeforeTargets="ResolveAssemblyReferences"
|
||||||
Condition="'$(MelonLoaderReferenceTfm)' == 'net6' and !Exists('$(MelonLoaderDir)\$(MelonLoaderReferenceTfm)\MelonLoader.dll')">
|
Condition="!Exists('$(MelonLoaderDir)\net6\MelonLoader.dll')">
|
||||||
<MakeDir Directories="$(MelonLoaderCacheRoot)" />
|
<MakeDir Directories="$(MelonLoaderCacheRoot)" />
|
||||||
<DownloadFile
|
<DownloadFile
|
||||||
SourceUrl="https://github.com/LavaGang/MelonLoader/releases/download/$(MelonLoaderVersion)/MelonLoader.x64.zip"
|
SourceUrl="https://github.com/LavaGang/MelonLoader/releases/download/$(MelonLoaderVersion)/MelonLoader.x64.zip"
|
||||||
@@ -55,19 +47,19 @@
|
|||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<Reference Include="MelonLoader">
|
<Reference Include="MelonLoader">
|
||||||
<HintPath>$(MelonLoaderDir)\$(MelonLoaderReferenceTfm)\MelonLoader.dll</HintPath>
|
<HintPath>$(MelonLoaderDir)\net6\MelonLoader.dll</HintPath>
|
||||||
<Private>false</Private>
|
<Private>false</Private>
|
||||||
</Reference>
|
</Reference>
|
||||||
<Reference Include="0Harmony">
|
<Reference Include="0Harmony">
|
||||||
<HintPath>$(MelonLoaderDir)\$(MelonLoaderReferenceTfm)\0Harmony.dll</HintPath>
|
<HintPath>$(MelonLoaderDir)\net6\0Harmony.dll</HintPath>
|
||||||
<Private>false</Private>
|
<Private>false</Private>
|
||||||
</Reference>
|
</Reference>
|
||||||
<Reference Include="Il2CppInterop.Runtime">
|
<Reference Include="Il2CppInterop.Runtime">
|
||||||
<HintPath>$(MelonLoaderDir)\$(MelonLoaderReferenceTfm)\Il2CppInterop.Runtime.dll</HintPath>
|
<HintPath>$(MelonLoaderDir)\net6\Il2CppInterop.Runtime.dll</HintPath>
|
||||||
<Private>false</Private>
|
<Private>false</Private>
|
||||||
</Reference>
|
</Reference>
|
||||||
<Reference Include="Il2CppInterop.Common">
|
<Reference Include="Il2CppInterop.Common">
|
||||||
<HintPath>$(MelonLoaderDir)\$(MelonLoaderReferenceTfm)\Il2CppInterop.Common.dll</HintPath>
|
<HintPath>$(MelonLoaderDir)\net6\Il2CppInterop.Common.dll</HintPath>
|
||||||
<Private>false</Private>
|
<Private>false</Private>
|
||||||
</Reference>
|
</Reference>
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
<Project Sdk="Microsoft.NET.Sdk">
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
|
||||||
<PropertyGroup>
|
<PropertyGroup>
|
||||||
<TargetFramework>net10.0</TargetFramework>
|
<TargetFramework>net9.0</TargetFramework>
|
||||||
<ImplicitUsings>enable</ImplicitUsings>
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
<Nullable>enable</Nullable>
|
<Nullable>enable</Nullable>
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
|||||||
@@ -1,590 +0,0 @@
|
|||||||
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,7 +1,6 @@
|
|||||||
using Microsoft.AspNetCore.Authorization;
|
using Microsoft.AspNetCore.Authorization;
|
||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
using Microsoft.IdentityModel.Tokens;
|
|
||||||
using DorkNet.Models.Notification;
|
using DorkNet.Models.Notification;
|
||||||
using DorkNet.Server.Auth;
|
using DorkNet.Server.Auth;
|
||||||
using DorkNet.Server.Data;
|
using DorkNet.Server.Data;
|
||||||
@@ -40,7 +39,6 @@ public class AdminController(
|
|||||||
SignupCodeService signupCodes,
|
SignupCodeService signupCodes,
|
||||||
IObjectStorage storage,
|
IObjectStorage storage,
|
||||||
DomainConfig domain,
|
DomainConfig domain,
|
||||||
IdentityServerSigningKeyProvider signingKeys,
|
|
||||||
ILogger<AdminController> adminLogger) : ControllerBase
|
ILogger<AdminController> adminLogger) : ControllerBase
|
||||||
{
|
{
|
||||||
private long CurrentAdminId => this.RequireCurrentPlayerId();
|
private long CurrentAdminId => this.RequireCurrentPlayerId();
|
||||||
@@ -1161,52 +1159,6 @@ public class AdminController(
|
|||||||
|
|
||||||
// ── Server settings (runtime toggles) ────────────────────────────────
|
// ── 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
|
/// <summary>GET <c>api/admin/v1/settings</c> — current server-wide
|
||||||
/// toggle state. Single row, single round trip.</summary>
|
/// toggle state. Single row, single round trip.</summary>
|
||||||
[HttpGet("settings")]
|
[HttpGet("settings")]
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
using DorkNet.Models.Auth;
|
using DorkNet.Models.Auth;
|
||||||
using DorkNet.Server.Data.Entities;
|
using DorkNet.Server.Data.Entities;
|
||||||
using DorkNet.Server.Services;
|
using DorkNet.Server.Services;
|
||||||
@@ -6,26 +7,242 @@ using DorkNet.Server.Services;
|
|||||||
namespace DorkNet.Server.Controllers.Auth;
|
namespace DorkNet.Server.Controllers.Auth;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// auth.rec.net game-specific auth helpers. OAuth/OIDC endpoints are owned by IdentityServer.
|
/// auth.rec.net — OAuth-style token endpoint used by 2019+ builds alongside PlatformLogin.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
[ApiController]
|
[ApiController]
|
||||||
public class AuthController(
|
public class AuthController(
|
||||||
PlayerService playerService,
|
PlayerService playerService,
|
||||||
AuthService authService) : ControllerBase
|
AuthService authService,
|
||||||
|
LevelService level,
|
||||||
|
OrphanAccountTracker orphans,
|
||||||
|
ServerSettingsService settings,
|
||||||
|
DorkNet.Server.Data.DorkNetDbContext db,
|
||||||
|
DomainConfig domain,
|
||||||
|
ILogger<AuthController> logger) : ControllerBase
|
||||||
{
|
{
|
||||||
// 2020.12 added an EAC challenge step in the login flow. The client GETs
|
/// <summary>Award the per-day first-login XP if the calling
|
||||||
// a challenge string and passes it to EACManager.GenerateChallengeResponse,
|
/// player hasn't been seen yet today (UTC).</summary>
|
||||||
// which calls System.Convert.FromBase64CharPtr on the string. So the
|
private async Task TryAwardLoginXpAsync(long playerId)
|
||||||
// returned challenge MUST be valid base64 — sending arbitrary text throws
|
{
|
||||||
// FormatException client-side and kicks the login.
|
var lastSeen = await db.Players
|
||||||
[HttpGet("/eac/challenge")]
|
.Where(p => p.Id == playerId)
|
||||||
[HttpPost("/eac/challenge")]
|
.Select(p => p.LastSeenAt)
|
||||||
public IActionResult EacChallenge()
|
.FirstOrDefaultAsync();
|
||||||
=> Content("\"AAAAAAAAAAAAAAAAAAAAAAAA\"", "application/json");
|
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",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// Cached-logins lookup — boot-sequence call from ScreenAccountSelectionManager.
|
// Cached-logins lookup — boot-sequence call from ScreenAccountSelectionManager.
|
||||||
// URL: GET auth.rec.net/cachedlogin/forplatformid/{platform}/{platformId}
|
// URL: GET auth.rec.net/cachedlogin/forplatformid/{platform}/{platformId}
|
||||||
// Returns List<Login.CachedLogin>. Empty array = no remembered accounts.
|
// 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}")]
|
[HttpGet("/cachedlogin/forplatformid/{platform:int}/{platformId}")]
|
||||||
public async Task<IActionResult> CachedLoginsForPlatform(int platform, string platformId)
|
public async Task<IActionResult> CachedLoginsForPlatform(int platform, string platformId)
|
||||||
{
|
{
|
||||||
@@ -35,8 +252,10 @@ public class AuthController(
|
|||||||
|
|
||||||
/// <summary>POST <c>cachedlogin/forplatformids</c> — bulk variant
|
/// <summary>POST <c>cachedlogin/forplatformids</c> — bulk variant
|
||||||
/// the watch uses when hydrating multiple platform-id mappings
|
/// the watch uses when hydrating multiple platform-id mappings
|
||||||
/// at once. Body is form-urlencoded <c>platform=&platformIds=a,b,c</c>
|
/// at once (e.g. populating a friend list from Steam ids). Body
|
||||||
/// or JSON with the same shape.</summary>
|
/// 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>
|
||||||
[HttpPost("/cachedlogin/forplatformids")]
|
[HttpPost("/cachedlogin/forplatformids")]
|
||||||
[HttpGet("/cachedlogin/forplatformids")]
|
[HttpGet("/cachedlogin/forplatformids")]
|
||||||
public async Task<IActionResult> CachedLoginsForPlatformIds()
|
public async Task<IActionResult> CachedLoginsForPlatformIds()
|
||||||
@@ -54,7 +273,6 @@ public class AuthController(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
catch { /* not form */ }
|
catch { /* not form */ }
|
||||||
|
|
||||||
if (string.IsNullOrWhiteSpace(rawIds))
|
if (string.IsNullOrWhiteSpace(rawIds))
|
||||||
{
|
{
|
||||||
int.TryParse(Request.Query["platform"], out platform);
|
int.TryParse(Request.Query["platform"], out platform);
|
||||||
@@ -77,50 +295,13 @@ public class AuthController(
|
|||||||
return Ok(results);
|
return Ok(results);
|
||||||
}
|
}
|
||||||
|
|
||||||
[HttpGet("/cachedlogin/current")]
|
private static CachedLogin ToCachedLogin(PlayerEntity p, int platform, string platformId) => new()
|
||||||
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,
|
Platform = platform,
|
||||||
PlatformId = platformId ?? string.Empty,
|
PlatformId = platformId,
|
||||||
AccountId = (int)p.Id,
|
AccountId = (int)p.Id,
|
||||||
LastLoginTime = p.LastSeenAt,
|
LastLoginTime = p.LastSeenAt,
|
||||||
RequirePassword = false,
|
RequirePassword = false,
|
||||||
};
|
};
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -59,18 +59,6 @@ public class PublicSiteController(
|
|||||||
|
|
||||||
var photoCount = await db.Photos
|
var photoCount = await db.Photos
|
||||||
.CountAsync(ph => ph.UploaderPlayerId == id && ph.IsPublic && ph.DeletedAt == null);
|
.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
|
return Ok(new
|
||||||
{
|
{
|
||||||
@@ -87,10 +75,6 @@ public class PublicSiteController(
|
|||||||
createdAt = p.CreatedAt,
|
createdAt = p.CreatedAt,
|
||||||
profileImageName = p.ProfileImageName,
|
profileImageName = p.ProfileImageName,
|
||||||
photoCount,
|
photoCount,
|
||||||
photosTakenCount = photoCount,
|
|
||||||
photosOfPlayerCount,
|
|
||||||
friendCount,
|
|
||||||
publicRoomCount,
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -110,54 +94,6 @@ public class PublicSiteController(
|
|||||||
return Ok(await EnrichPhotosAsync(rows));
|
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 ───────────────────────────────────────────────────────
|
// ── Photos ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
/// <summary>GET /api/site/v1/feed — global public feed, newest first.</summary>
|
/// <summary>GET /api/site/v1/feed — global public feed, newest first.</summary>
|
||||||
@@ -259,12 +195,6 @@ public class PublicSiteController(
|
|||||||
profileImageName = p.ProfileImageName,
|
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
|
/// <summary>Materialise photo rows into wire DTOs with display names
|
||||||
/// + room names + an absolute image URL. The URL apex switches
|
/// + room names + an absolute image URL. The URL apex switches
|
||||||
/// based on the request host so a localhost visitor gets localhost
|
/// based on the request host so a localhost visitor gets localhost
|
||||||
@@ -275,15 +205,10 @@ public class PublicSiteController(
|
|||||||
if (list.Count == 0) return new();
|
if (list.Count == 0) return new();
|
||||||
|
|
||||||
var uploaderIds = list.Select(p => p.UploaderPlayerId).Distinct().ToList();
|
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 roomIds = list.Select(p => p.RoomId).Where(id => id > 0).Distinct().ToList();
|
||||||
|
|
||||||
var playersById = await db.Players
|
var uploaders = await db.Players
|
||||||
.Where(p => playerIds.Contains(p.Id))
|
.Where(p => uploaderIds.Contains(p.Id))
|
||||||
.Select(p => new { p.Id, p.Username, p.DisplayName, p.ProfileImageName })
|
.Select(p => new { p.Id, p.Username, p.DisplayName, p.ProfileImageName })
|
||||||
.ToListAsync();
|
.ToListAsync();
|
||||||
var rooms = await db.Rooms
|
var rooms = await db.Rooms
|
||||||
@@ -291,14 +216,14 @@ public class PublicSiteController(
|
|||||||
.Select(r => new { r.Id, r.Name })
|
.Select(r => new { r.Id, r.Name })
|
||||||
.ToListAsync();
|
.ToListAsync();
|
||||||
|
|
||||||
var pMap = playersById.ToDictionary(u => u.Id);
|
var uMap = uploaders.ToDictionary(u => u.Id);
|
||||||
var rMap = rooms.ToDictionary(r => r.Id);
|
var rMap = rooms.ToDictionary(r => r.Id);
|
||||||
|
|
||||||
var cdnHost = domain.Sub("cdn");
|
var cdnHost = domain.Sub("cdn");
|
||||||
|
|
||||||
return list.Select(p =>
|
return list.Select(p =>
|
||||||
{
|
{
|
||||||
pMap.TryGetValue(p.UploaderPlayerId, out var u);
|
uMap.TryGetValue(p.UploaderPlayerId, out var u);
|
||||||
rMap.TryGetValue(p.RoomId, out var r);
|
rMap.TryGetValue(p.RoomId, out var r);
|
||||||
return (object)new
|
return (object)new
|
||||||
{
|
{
|
||||||
@@ -312,17 +237,6 @@ public class PublicSiteController(
|
|||||||
caption = p.Caption,
|
caption = p.Caption,
|
||||||
roomId = p.RoomId,
|
roomId = p.RoomId,
|
||||||
roomName = r?.Name ?? string.Empty,
|
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,
|
isPublic = p.IsPublic,
|
||||||
cheerCount = p.CheerCount,
|
cheerCount = p.CheerCount,
|
||||||
viewCount = p.ViewCount,
|
viewCount = p.ViewCount,
|
||||||
@@ -331,16 +245,6 @@ public class PublicSiteController(
|
|||||||
}).ToList();
|
}).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) ─────────────────────────────────────
|
// ── Signup (code redemption) ─────────────────────────────────────
|
||||||
|
|
||||||
/// <summary>GET /api/site/v1/join/pending-devices — device ids that
|
/// <summary>GET /api/site/v1/join/pending-devices — device ids that
|
||||||
|
|||||||
@@ -1,22 +1,20 @@
|
|||||||
<Project Sdk="Microsoft.NET.Sdk.Web">
|
<Project Sdk="Microsoft.NET.Sdk.Web">
|
||||||
|
|
||||||
<PropertyGroup>
|
<PropertyGroup>
|
||||||
<TargetFramework>net10.0</TargetFramework>
|
<TargetFramework>net9.0</TargetFramework>
|
||||||
<Nullable>enable</Nullable>
|
<Nullable>enable</Nullable>
|
||||||
<ImplicitUsings>enable</ImplicitUsings>
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<PackageReference Include="Open.IdentityServer" Version="1.0.0" />
|
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="9.0.4" />
|
||||||
<PackageReference Include="MessagePack" Version="3.1.7" />
|
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="9.0.15" />
|
||||||
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="10.0.9" />
|
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="9.0.4">
|
||||||
<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>
|
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||||
<PrivateAssets>all</PrivateAssets>
|
<PrivateAssets>all</PrivateAssets>
|
||||||
</PackageReference>
|
</PackageReference>
|
||||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="10.0.9" />
|
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="9.0.4" />
|
||||||
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="10.0.2" />
|
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="9.0.4" />
|
||||||
<!-- RenderedCompactJsonFormatter for stdout JSON logs in production
|
<!-- RenderedCompactJsonFormatter for stdout JSON logs in production
|
||||||
(Coolify-friendly). The host pipeline reads stdout line-by-line,
|
(Coolify-friendly). The host pipeline reads stdout line-by-line,
|
||||||
and structured JSON lets a future Loki/Datadog ingest parse fields
|
and structured JSON lets a future Loki/Datadog ingest parse fields
|
||||||
@@ -27,8 +25,8 @@
|
|||||||
scale: SignalR group fanout, OrphanAccountTracker (sticky-session
|
scale: SignalR group fanout, OrphanAccountTracker (sticky-session
|
||||||
account-creation flow), and PlayerPresenceService heartbeats all
|
account-creation flow), and PlayerPresenceService heartbeats all
|
||||||
need shared state across replicas. -->
|
need shared state across replicas. -->
|
||||||
<PackageReference Include="StackExchange.Redis" Version="2.13.17" />
|
<PackageReference Include="StackExchange.Redis" Version="2.8.16" />
|
||||||
<PackageReference Include="Microsoft.AspNetCore.SignalR.StackExchangeRedis" Version="10.0.9" />
|
<PackageReference Include="Microsoft.AspNetCore.SignalR.StackExchangeRedis" Version="9.0.4" />
|
||||||
<!-- S3-compatible object storage. Production target: Garage in
|
<!-- S3-compatible object storage. Production target: Garage in
|
||||||
Coolify (one-click). Dev fallback: local MinIO for parity
|
Coolify (one-click). Dev fallback: local MinIO for parity
|
||||||
testing. The SDK speaks plain S3, so swapping endpoints
|
testing. The SDK speaks plain S3, so swapping endpoints
|
||||||
@@ -40,7 +38,7 @@
|
|||||||
cost+salt in the output string so we only need one column. -->
|
cost+salt in the output string so we only need one column. -->
|
||||||
<PackageReference Include="BCrypt.Net-Next" Version="4.0.3" />
|
<PackageReference Include="BCrypt.Net-Next" Version="4.0.3" />
|
||||||
<PackageReference Include="Serilog.AspNetCore" Version="9.0.0" />
|
<PackageReference Include="Serilog.AspNetCore" Version="9.0.0" />
|
||||||
<PackageReference Include="Swashbuckle.AspNetCore" Version="10.2.3" />
|
<PackageReference Include="Swashbuckle.AspNetCore" Version="7.3.1" />
|
||||||
<!-- Server-side crop/resize for img.* CDN transforms (?cropSquare=1&width=N
|
<!-- Server-side crop/resize for img.* CDN transforms (?cropSquare=1&width=N
|
||||||
on profile thumbnails). The 2020 client treats this as part of the
|
on profile thumbnails). The 2020 client treats this as part of the
|
||||||
image CDN contract — it uploads a full-frame capture and expects the
|
image CDN contract — it uploads a full-frame capture and expects the
|
||||||
|
|||||||
@@ -3,16 +3,12 @@ using Microsoft.IdentityModel.Tokens;
|
|||||||
using System.IdentityModel.Tokens.Jwt;
|
using System.IdentityModel.Tokens.Jwt;
|
||||||
using System.Security.Claims;
|
using System.Security.Claims;
|
||||||
using System.Text;
|
using System.Text;
|
||||||
using DorkNet.Server.Auth;
|
|
||||||
using DorkNet.Server.Data;
|
using DorkNet.Server.Data;
|
||||||
|
|
||||||
namespace DorkNet.Server.Services;
|
namespace DorkNet.Server.Services;
|
||||||
|
|
||||||
public class AuthService(IConfiguration config, DorkNetDbContext db, DomainConfig domain, IdentityServerSigningKeyProvider signingKeyProvider)
|
public class AuthService(IConfiguration config, DorkNetDbContext db)
|
||||||
{
|
{
|
||||||
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 —
|
// Resolution order MUST match Program.cs's JwtBearer validator setup —
|
||||||
// env var first, config key second — otherwise tokens are signed with
|
// env var first, config key second — otherwise tokens are signed with
|
||||||
// one secret and validated against another and every authenticated
|
// one secret and validated against another and every authenticated
|
||||||
@@ -42,17 +38,38 @@ public class AuthService(IConfiguration config, DorkNetDbContext db, DomainConfi
|
|||||||
private string CreateJwt(long playerId, TimeSpan lifetime, string tokenType)
|
private string CreateJwt(long playerId, TimeSpan lifetime, string tokenType)
|
||||||
{
|
{
|
||||||
var handler = new JwtSecurityTokenHandler();
|
var handler = new JwtSecurityTokenHandler();
|
||||||
var claims = RecRoomIdentityClaims.CreateAsync(db, playerId)
|
var key = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(SecretKey));
|
||||||
.GetAwaiter()
|
|
||||||
.GetResult();
|
// Look up dev/admin flags so the JWT carries a "developer" role
|
||||||
claims.Add(new Claim("token_type", tokenType));
|
// 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 descriptor = new SecurityTokenDescriptor
|
var descriptor = new SecurityTokenDescriptor
|
||||||
{
|
{
|
||||||
Subject = new ClaimsIdentity(claims),
|
Subject = new ClaimsIdentity(claims),
|
||||||
Expires = DateTime.UtcNow.Add(lifetime),
|
Expires = DateTime.UtcNow.Add(lifetime),
|
||||||
Issuer = domain.AuthIssuer,
|
Issuer = "https://api.rec.net/",
|
||||||
SigningCredentials = new SigningCredentials(signingKeyProvider.SigningKey, SecurityAlgorithms.RsaSha256),
|
SigningCredentials = new SigningCredentials(key, SecurityAlgorithms.HmacSha256),
|
||||||
};
|
};
|
||||||
|
|
||||||
return handler.WriteToken(handler.CreateJwtSecurityToken(descriptor));
|
return handler.WriteToken(handler.CreateJwtSecurityToken(descriptor));
|
||||||
@@ -63,22 +80,19 @@ public class AuthService(IConfiguration config, DorkNetDbContext db, DomainConfi
|
|||||||
try
|
try
|
||||||
{
|
{
|
||||||
var handler = new JwtSecurityTokenHandler();
|
var handler = new JwtSecurityTokenHandler();
|
||||||
|
var key = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(SecretKey));
|
||||||
|
|
||||||
var principal = handler.ValidateToken(token, new TokenValidationParameters
|
var principal = handler.ValidateToken(token, new TokenValidationParameters
|
||||||
{
|
{
|
||||||
ValidateIssuerSigningKey = true,
|
ValidateIssuerSigningKey = true,
|
||||||
IssuerSigningKeys = signingKeyProvider.ValidationKeys,
|
IssuerSigningKey = key,
|
||||||
ValidateIssuer = true,
|
ValidateIssuer = true,
|
||||||
ValidIssuers = new[] { domain.AuthIssuer, LegacyIssuer },
|
ValidIssuer = "https://api.rec.net/",
|
||||||
ValidateAudience = false,
|
ValidateAudience = false,
|
||||||
ClockSkew = TimeSpan.Zero,
|
ClockSkew = TimeSpan.Zero,
|
||||||
}, out _);
|
}, 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;
|
return idClaim is not null ? long.Parse(idClaim) : null;
|
||||||
}
|
}
|
||||||
catch
|
catch
|
||||||
|
|||||||
@@ -27,7 +27,6 @@ public sealed class DomainConfig
|
|||||||
}
|
}
|
||||||
|
|
||||||
public string Sub(string prefix) => $"{prefix}.{Apex}";
|
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 Url(string host) => Port is null ? $"{Scheme}://{host}" : $"{Scheme}://{host}:{Port}";
|
||||||
public string SubUrl(string prefix) => Url(Sub(prefix));
|
public string SubUrl(string prefix) => Url(Sub(prefix));
|
||||||
public static bool MatchesSubdomain(string host, string prefix)
|
public static bool MatchesSubdomain(string host, string prefix)
|
||||||
|
|||||||
@@ -681,21 +681,8 @@ public class PlayerService(DorkNetDbContext db, RoomService rooms, ILogger<Playe
|
|||||||
// request `take=1000000` and exhaust the connection. 50 is enough
|
// request `take=1000000` and exhaust the connection. 50 is enough
|
||||||
// for the watch's typeahead UI (it shows ~10 rows at a time).
|
// for the watch's typeahead UI (it shows ~10 rows at a time).
|
||||||
var clamped = Math.Clamp(take, 1, 50);
|
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
|
return await db.Players
|
||||||
.Where(p => p.Id == idQuery ||
|
.Where(p => p.Username.Contains(query) || p.DisplayName.Contains(query))
|
||||||
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)
|
.Take(clamped)
|
||||||
.ToListAsync();
|
.ToListAsync();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -62,9 +62,6 @@ public static class MiddlewarePipelineExtensions
|
|||||||
|
|
||||||
app.UseMiddleware<IpBanCheckMiddleware>();
|
app.UseMiddleware<IpBanCheckMiddleware>();
|
||||||
|
|
||||||
app.UseMiddleware<IdentityServerGameTokenRequestMiddleware>();
|
|
||||||
app.UseIdentityServer();
|
|
||||||
|
|
||||||
app.UseAuthentication();
|
app.UseAuthentication();
|
||||||
app.UseAuthorization();
|
app.UseAuthorization();
|
||||||
app.UseMiddleware<BanCheckMiddleware>();
|
app.UseMiddleware<BanCheckMiddleware>();
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
using DorkNet.Server.Auth;
|
using System.Text;
|
||||||
using DorkNet.Server.Data;
|
using DorkNet.Server.Data;
|
||||||
using DorkNet.Server.Services;
|
using DorkNet.Server.Services;
|
||||||
using Microsoft.AspNetCore.Authentication.JwtBearer;
|
using Microsoft.AspNetCore.Authentication.JwtBearer;
|
||||||
@@ -17,10 +17,10 @@ public static class ServiceCollectionExtensions
|
|||||||
{
|
{
|
||||||
ConfigureLogging(builder);
|
ConfigureLogging(builder);
|
||||||
AddDatabase(builder);
|
AddDatabase(builder);
|
||||||
var domain = AddDomain(builder);
|
AddDomain(builder);
|
||||||
AddCoreServices(builder.Services);
|
AddCoreServices(builder.Services);
|
||||||
var redisConn = AddRedis(builder);
|
var redisConn = AddRedis(builder);
|
||||||
AddAuthentication(builder, domain);
|
AddAuthentication(builder);
|
||||||
AddKestrelAndSignalR(builder, redisConn);
|
AddKestrelAndSignalR(builder, redisConn);
|
||||||
builder.Services.AddEndpointsApiExplorer();
|
builder.Services.AddEndpointsApiExplorer();
|
||||||
builder.Services.AddSwaggerGen();
|
builder.Services.AddSwaggerGen();
|
||||||
@@ -70,7 +70,7 @@ public static class ServiceCollectionExtensions
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
private static DomainConfig AddDomain(WebApplicationBuilder builder)
|
private static void AddDomain(WebApplicationBuilder builder)
|
||||||
{
|
{
|
||||||
var apex =
|
var apex =
|
||||||
builder.Configuration["Domain:Apex"]
|
builder.Configuration["Domain:Apex"]
|
||||||
@@ -87,8 +87,7 @@ public static class ServiceCollectionExtensions
|
|||||||
builder.Configuration["Domain:SingleOriginBaseUrl"]
|
builder.Configuration["Domain:SingleOriginBaseUrl"]
|
||||||
?? Environment.GetEnvironmentVariable("DORKNET_SINGLE_ORIGIN_BASE_URL");
|
?? Environment.GetEnvironmentVariable("DORKNET_SINGLE_ORIGIN_BASE_URL");
|
||||||
|
|
||||||
var domain = new DomainConfig(apex, domainScheme, domainPort, singleOriginBaseUrl);
|
builder.Services.AddSingleton(new DomainConfig(apex, domainScheme, domainPort, singleOriginBaseUrl));
|
||||||
builder.Services.AddSingleton(domain);
|
|
||||||
builder.Services.Configure<Microsoft.AspNetCore.HostFiltering.HostFilteringOptions>(opt =>
|
builder.Services.Configure<Microsoft.AspNetCore.HostFiltering.HostFilteringOptions>(opt =>
|
||||||
{
|
{
|
||||||
opt.AllowedHosts = new[] { apex, $"*.{apex}", "localhost", "127.0.0.1" };
|
opt.AllowedHosts = new[] { apex, $"*.{apex}", "localhost", "127.0.0.1" };
|
||||||
@@ -96,7 +95,6 @@ public static class ServiceCollectionExtensions
|
|||||||
opt.IncludeFailureMessage = true;
|
opt.IncludeFailureMessage = true;
|
||||||
});
|
});
|
||||||
Console.WriteLine($"[domain] apex={apex}, scheme={domainScheme}, port={domainPort}, singleOrigin={singleOriginBaseUrl}, allowedHosts=[{apex}, *.{apex}, localhost]");
|
Console.WriteLine($"[domain] apex={apex}, scheme={domainScheme}, port={domainPort}, singleOrigin={singleOriginBaseUrl}, allowedHosts=[{apex}, *.{apex}, localhost]");
|
||||||
return domain;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private static void AddCoreServices(IServiceCollection services)
|
private static void AddCoreServices(IServiceCollection services)
|
||||||
@@ -163,11 +161,14 @@ public static class ServiceCollectionExtensions
|
|||||||
return opts.ToString();
|
return opts.ToString();
|
||||||
}
|
}
|
||||||
|
|
||||||
private static void AddAuthentication(WebApplicationBuilder builder, DomainConfig domain)
|
private static void AddAuthentication(WebApplicationBuilder builder)
|
||||||
{
|
{
|
||||||
var signingKeyProvider = new IdentityServerSigningKeyProvider(builder.Configuration);
|
var jwtSecret = Environment.GetEnvironmentVariable("DORKNET_JWT_SECRET")
|
||||||
builder.Services.AddSingleton(signingKeyProvider);
|
?? Environment.GetEnvironmentVariable("RECNET_JWT_SECRET")
|
||||||
builder.Services.AddRecRoomIdentityServer(builder.Configuration, domain, signingKeyProvider);
|
?? 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)
|
builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
|
||||||
.AddJwtBearer(opt =>
|
.AddJwtBearer(opt =>
|
||||||
@@ -175,9 +176,9 @@ public static class ServiceCollectionExtensions
|
|||||||
opt.TokenValidationParameters = new TokenValidationParameters
|
opt.TokenValidationParameters = new TokenValidationParameters
|
||||||
{
|
{
|
||||||
ValidateIssuerSigningKey = true,
|
ValidateIssuerSigningKey = true,
|
||||||
IssuerSigningKeys = signingKeyProvider.ValidationKeys,
|
IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(jwtSecret)),
|
||||||
ValidateIssuer = true,
|
ValidateIssuer = true,
|
||||||
ValidIssuers = new[] { domain.AuthIssuer, AuthService.LegacyIssuer },
|
ValidIssuer = "https://api.rec.net/",
|
||||||
ValidateAudience = false,
|
ValidateAudience = false,
|
||||||
ClockSkew = TimeSpan.Zero,
|
ClockSkew = TimeSpan.Zero,
|
||||||
};
|
};
|
||||||
@@ -189,12 +190,6 @@ public static class ServiceCollectionExtensions
|
|||||||
var path = ctx.HttpContext.Request.Path;
|
var path = ctx.HttpContext.Request.Path;
|
||||||
if (!string.IsNullOrEmpty(accessToken) && path.StartsWithSegments("/hub"))
|
if (!string.IsNullOrEmpty(accessToken) && path.StartsWithSegments("/hub"))
|
||||||
ctx.Token = accessToken;
|
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;
|
return Task.CompletedTask;
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,20 +1,6 @@
|
|||||||
import { clearSession, getToken, notifyAuthChange } from './auth';
|
import { clearSession, getToken, notifyAuthChange } from './auth';
|
||||||
|
|
||||||
const API_BASE = adminApiBase();
|
const API_BASE = '/api/admin/v1';
|
||||||
|
|
||||||
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 {
|
export class ApiError extends Error {
|
||||||
constructor(public status: number, message: string, public body?: unknown) {
|
constructor(public status: number, message: string, public body?: unknown) {
|
||||||
|
|||||||
@@ -1,209 +0,0 @@
|
|||||||
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,5 +1,4 @@
|
|||||||
import { Settings } from './Settings';
|
import { Settings } from './Settings';
|
||||||
import { IdentityServer } from './IdentityServer';
|
|
||||||
import { SignupCodes } from './SignupCodes';
|
import { SignupCodes } from './SignupCodes';
|
||||||
import { PageHeader } from '../components/PageHeader';
|
import { PageHeader } from '../components/PageHeader';
|
||||||
import { Tabs, useTabParam } from '../components/Tabs';
|
import { Tabs, useTabParam } from '../components/Tabs';
|
||||||
@@ -8,21 +7,19 @@ import { Tabs, useTabParam } from '../components/Tabs';
|
|||||||
// invite codes (an access-control setting in its own right). Old
|
// invite codes (an access-control setting in its own right). Old
|
||||||
// /signup-codes route redirects here with ?tab=signup.
|
// /signup-codes route redirects here with ?tab=signup.
|
||||||
export function SettingsHome() {
|
export function SettingsHome() {
|
||||||
const [tab, setTab] = useTabParam<'server' | 'identity' | 'signup'>('server', ['server', 'identity', 'signup']);
|
const [tab, setTab] = useTabParam<'server' | 'signup'>('server', ['server', 'signup']);
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<PageHeader title="Settings" blurb="Runtime server toggles, authentication, and single-use signup invite codes." />
|
<PageHeader title="Settings" blurb="Runtime server toggles and single-use signup invite codes." />
|
||||||
<Tabs
|
<Tabs
|
||||||
tabs={[
|
tabs={[
|
||||||
{ key: 'server', label: 'Server' },
|
{ key: 'server', label: 'Server' },
|
||||||
{ key: 'identity', label: 'IdentityServer' },
|
|
||||||
{ key: 'signup', label: 'Signup codes' },
|
{ key: 'signup', label: 'Signup codes' },
|
||||||
]}
|
]}
|
||||||
active={tab}
|
active={tab}
|
||||||
onChange={setTab}
|
onChange={setTab}
|
||||||
/>
|
/>
|
||||||
{tab === 'server' && <Settings embedded />}
|
{tab === 'server' && <Settings embedded />}
|
||||||
{tab === 'identity' && <IdentityServer />}
|
|
||||||
{tab === 'signup' && <SignupCodes embedded />}
|
{tab === 'signup' && <SignupCodes embedded />}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -10,8 +10,6 @@ import { num, relativeTime } from '../lib/format';
|
|||||||
// inner anchor preserves that even on touch devices where iOS
|
// inner anchor preserves that even on touch devices where iOS
|
||||||
// occasionally fires both handlers.
|
// occasionally fires both handlers.
|
||||||
export function PhotoCard({ photo }: { photo: SitePhoto }) {
|
export function PhotoCard({ photo }: { photo: SitePhoto }) {
|
||||||
const tagged = photo.taggedPlayers ?? [];
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Link
|
<Link
|
||||||
to={`/photo/${photo.id}`}
|
to={`/photo/${photo.id}`}
|
||||||
@@ -44,34 +42,12 @@ export function PhotoCard({ photo }: { photo: SitePhoto }) {
|
|||||||
{photo.caption && (
|
{photo.caption && (
|
||||||
<p className="mt-2 text-xs text-ink-300 line-clamp-2">{photo.caption}</p>
|
<p className="mt-2 text-xs text-ink-300 line-clamp-2">{photo.caption}</p>
|
||||||
)}
|
)}
|
||||||
{tagged.length > 0 && (
|
<div className="mt-2 flex items-center justify-between text-[11px] text-ink-400">
|
||||||
<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>
|
<span title="cheers">♥ {num(photo.cheerCount)}</span>
|
||||||
<span className="min-w-0 truncate text-right">
|
{photo.roomName
|
||||||
{photo.roomName && (
|
? <span className="font-mono truncate ml-2" title={`Room: ${photo.roomName}`}>^{photo.roomName}</span>
|
||||||
<span className="font-mono text-ink-300" title={`Room: ${photo.roomName}`}>^{photo.roomName}</span>
|
: <span>{relativeTime(photo.createdAt)}</span>
|
||||||
)}
|
}
|
||||||
{photo.roomName && <span className="text-ink-600"> · </span>}
|
|
||||||
<span>{relativeTime(photo.createdAt)}</span>
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Link>
|
</Link>
|
||||||
|
|||||||
@@ -20,17 +20,6 @@ export interface SitePlayerDetail extends SitePlayerCard {
|
|||||||
xp: number;
|
xp: number;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
photoCount: number;
|
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 {
|
export interface SitePhoto {
|
||||||
@@ -44,7 +33,6 @@ export interface SitePhoto {
|
|||||||
caption: string;
|
caption: string;
|
||||||
roomId: number;
|
roomId: number;
|
||||||
roomName: string;
|
roomName: string;
|
||||||
taggedPlayers: SitePhotoPlayer[];
|
|
||||||
isPublic: boolean;
|
isPublic: boolean;
|
||||||
cheerCount: number;
|
cheerCount: number;
|
||||||
viewCount: number;
|
viewCount: number;
|
||||||
|
|||||||
@@ -72,17 +72,13 @@ cd DorkNet
|
|||||||
| `ConnectionStrings:Redis` | `ConnectionStrings__Redis` | no | enables SignalR backplane |
|
| `ConnectionStrings:Redis` | `ConnectionStrings__Redis` | no | enables SignalR backplane |
|
||||||
| `Domain:Apex` | `DORKNET_DOMAIN` | no | defaults to `localhost`; set for production |
|
| `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:**
|
**4. Boot:**
|
||||||
|
|
||||||
```pwsh
|
```pwsh
|
||||||
dotnet run --project DorkNet.Server
|
dotnet run --project DorkNet.Server
|
||||||
```
|
```
|
||||||
|
|
||||||
First boot creates `bin/Debug/net10.0/data/dorknet.db`, applies all EF
|
First boot creates `bin/Debug/net9.0/data/dorknet.db`, applies all EF
|
||||||
migrations under `DorkNet.Server/Migrations/`, seeds canonical rooms +
|
migrations under `DorkNet.Server/Migrations/`, seeds canonical rooms +
|
||||||
store catalog, and starts listening. You should see `Application
|
store catalog, and starts listening. You should see `Application
|
||||||
started.` in the log; `curl http://localhost:8080/healthz` returns 200.
|
started.` in the log; `curl http://localhost:8080/healthz` returns 200.
|
||||||
@@ -108,38 +104,6 @@ AppIds, and bypasses TLS verification on the client's HTTPS calls so
|
|||||||
self-signed certs work. (First run prints "launch the game once"; do
|
self-signed certs work. (First run prints "launch the game once"; do
|
||||||
that, then re-run with `-ResumeBuild`.)
|
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)
|
### Debug console (opt-in)
|
||||||
|
|
||||||
The 2020 client ships a built-in dev console
|
The 2020 client ships a built-in dev console
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
<PropertyGroup>
|
<PropertyGroup>
|
||||||
<OutputType>Exe</OutputType>
|
<OutputType>Exe</OutputType>
|
||||||
<TargetFramework>net10.0</TargetFramework>
|
<TargetFramework>net9.0</TargetFramework>
|
||||||
<Nullable>enable</Nullable>
|
<Nullable>enable</Nullable>
|
||||||
<ImplicitUsings>enable</ImplicitUsings>
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
|||||||
Reference in New Issue
Block a user