mirror of
https://github.com/DorkSquadRR/DorkNet.git
synced 2026-10-25 23:18:44 -07:00
Import december-2020-12-18 from private repo (scrubbed 2026-05-27)
Targets Rec Room 2020.12.18 client.
Re-import after adding:
- DorkNet.ClientMod/dorknet-clientmod.json.template — config schema
the launcher's manifest-driven patcher renders into UserData/.
- tools/build-patcher-zip.ps1 — produces the dorknet-clientpatch-{key}.zip
Release asset the launcher fetches.
- .github/workflows/release.yml — auto-publishes both Release assets
on v1-december-* tag push.
Scrubbed via maintainer-side scrub-for-public.ps1: same drop rules as
the prior import, plus build-patcher-zip.ps1 added to the tools/
allow-list so it survives the scrub.
This commit is contained in:
@@ -0,0 +1,257 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using DorkNet.Server.Data;
|
||||
using DorkNet.Server.Services;
|
||||
using Serilog;
|
||||
|
||||
namespace DorkNet.Server.Startup;
|
||||
|
||||
/// <summary>Boot-time database orchestration: schema migration,
|
||||
/// idempotent data upgrades, and canonical seed application. Replaces
|
||||
/// the inline block previously in Program.cs.
|
||||
///
|
||||
/// <para>Provider-specific paths:
|
||||
/// <list type="bullet">
|
||||
/// <item>SQLite — <c>Database.Migrate()</c> after
|
||||
/// <see cref="BaselineExistingSchemaIfNeeded"/> reconciles any legacy
|
||||
/// migration-history state.</item>
|
||||
/// <item>Postgres — <c>EnsureCreated</c> behind a transaction-scoped
|
||||
/// advisory lock so concurrent replica boots don't race on
|
||||
/// <c>CREATE TABLE</c>. Connect retries cover the
|
||||
/// container-orchestrated boot window where Postgres DNS isn't
|
||||
/// resolvable yet.</item>
|
||||
/// </list>
|
||||
/// After the schema step both branches run the same idempotent
|
||||
/// post-init sequence: system-account seed, avatar-seed self-heal,
|
||||
/// starter-wallet backfill, dorm backfill, <see cref="LegacyUpgrades"/>
|
||||
/// data transforms, canonical room seeding + overrides, and per-feature
|
||||
/// seeders. The <c>/healthz</c> probe flips to 200 only after all of
|
||||
/// this completes — Coolify's rolling deploy holds traffic at the LB
|
||||
/// until then.</para></summary>
|
||||
public static class DatabaseBootstrap
|
||||
{
|
||||
public static async Task RunDatabaseBootstrapAsync(this WebApplication app)
|
||||
{
|
||||
using var scope = app.Services.CreateScope();
|
||||
var db = scope.ServiceProvider.GetRequiredService<DorkNetDbContext>();
|
||||
|
||||
if (db.Database.IsSqlite())
|
||||
{
|
||||
BaselineExistingSchemaIfNeeded(db);
|
||||
db.Database.Migrate();
|
||||
}
|
||||
else
|
||||
{
|
||||
// Postgres path: schema generated from the EF Core model via
|
||||
// EnsureCreated. Wrapped in a transaction-scoped advisory lock
|
||||
// so concurrent replicas booting against the same database
|
||||
// don't race on CREATE TABLE — only the first replica to take
|
||||
// the lock runs EnsureCreated, the rest see "schema already
|
||||
// exists" and proceed.
|
||||
//
|
||||
// Lock key 0x444F524B (ascii "DORK") is hard-coded; it just
|
||||
// needs to be the same int64 across replicas and not collide
|
||||
// with anything else in the database. pg_advisory_xact_lock
|
||||
// releases automatically on COMMIT, so a crashed replica can't
|
||||
// wedge the next boot.
|
||||
var conn = db.Database.GetDbConnection();
|
||||
|
||||
// Retry initial Postgres connect — on container-orchestrated boots
|
||||
// (Coolify, docker-compose) the Postgres service's DNS often isn't
|
||||
// resolvable yet when this app starts, surfacing as SocketException
|
||||
// errno=11 EAGAIN from Npgsql's Dns.GetHostEntryOrAddressesCore.
|
||||
// Postgres itself can also be up-but-not-accepting-connections for
|
||||
// a few seconds while it replays WAL. 15 attempts × ~2s backoff
|
||||
// covers the typical 5-30s warmup window without looping forever
|
||||
// on a permanently broken config.
|
||||
var connectLogger = app.Services.GetRequiredService<ILoggerFactory>()
|
||||
.CreateLogger("DorkNet.Server.Bootstrap");
|
||||
for (var attempt = 1; ; attempt++)
|
||||
{
|
||||
try { await conn.OpenAsync(); break; }
|
||||
catch (Exception ex) when (attempt < 15 &&
|
||||
(ex is System.Net.Sockets.SocketException
|
||||
|| ex is Npgsql.NpgsqlException
|
||||
|| ex is System.Data.Common.DbException))
|
||||
{
|
||||
var delay = TimeSpan.FromSeconds(Math.Min(attempt, 5));
|
||||
connectLogger.LogWarning(
|
||||
"[bootstrap] Postgres connect attempt {Attempt}/15 failed ({Type}: {Message}); retrying in {Delay}s",
|
||||
attempt, ex.GetType().Name, ex.Message, delay.TotalSeconds);
|
||||
await Task.Delay(delay);
|
||||
}
|
||||
}
|
||||
await using var tx = await conn.BeginTransactionAsync();
|
||||
await using (var lockCmd = conn.CreateCommand())
|
||||
{
|
||||
lockCmd.Transaction = tx;
|
||||
lockCmd.CommandText = "SELECT pg_advisory_xact_lock(1146246987);";
|
||||
await lockCmd.ExecuteNonQueryAsync();
|
||||
}
|
||||
db.Database.EnsureCreated();
|
||||
await tx.CommitAsync();
|
||||
|
||||
await conn.CloseAsync();
|
||||
}
|
||||
|
||||
// Coach system account at Player.Id=1. The RR-Original room seeder
|
||||
// below assigns CreatorPlayerId=1 to every canonical room
|
||||
// (RecCenter, Paintball, Dodgeball, etc.), so the FK target needs
|
||||
// to exist before we insert rooms. Idempotent: the call no-ops once
|
||||
// the row is there. Must run BEFORE roomService.SeedAsync().
|
||||
var playerService = scope.ServiceProvider.GetRequiredService<PlayerService>();
|
||||
await playerService.EnsureSystemAccountAsync();
|
||||
// Self-heal the AvatarEntity seed on every startup so accounts
|
||||
// created with old/broken starter values get the current safe
|
||||
// GUIDs without needing a one-shot EF migration.
|
||||
await playerService.EnsureAvatarSeedAsync();
|
||||
|
||||
// Grant the starter RecCenterTokens balance to any account that
|
||||
// doesn't yet have a CurrencyType=2 row. Pre-existing accounts
|
||||
// booted into a 0-token wallet and saw "Not enough tokens" on
|
||||
// every store tile — this brings them up to the same starting
|
||||
// state new accounts get. Idempotent.
|
||||
await playerService.BackfillStarterWalletsAsync();
|
||||
|
||||
// Backfill the per-player dorm room + DormStateEntity for any
|
||||
// legacy account that signed in via a path that didn't run
|
||||
// EnsurePersonalDormAsync. Without these rows the watch's
|
||||
// /api/rooms/v4/details/{dormId} → cdn.{apex}/room/{blobName}
|
||||
// chain hits an empty CurrentDataBlobName.
|
||||
var roomService = scope.ServiceProvider.GetRequiredService<RoomService>();
|
||||
await roomService.EnsureDormsForAllPlayersAsync();
|
||||
|
||||
// Idempotent data-transform pass for things that can't be expressed
|
||||
// as an EF migration against the entity model. MUST run before
|
||||
// SeedAsync() so the seed sees the post-rename state.
|
||||
var legacyLogger = app.Services.GetRequiredService<ILoggerFactory>()
|
||||
.CreateLogger("DorkNet.Server.LegacyUpgrades");
|
||||
await LegacyUpgrades.RunAsync(db, app.Configuration, legacyLogger);
|
||||
|
||||
// Seed canonical Rec Room Original rooms so the watch's "Trending"
|
||||
// tab has content on first launch. Idempotent — bails if any
|
||||
// rooms already exist.
|
||||
await roomService.SeedAsync();
|
||||
|
||||
// Canonical room overrides: rename BloodMoon → Crescendo, fold
|
||||
// the paintball-map standalone rooms into Paintball as sub-rooms,
|
||||
// fold LaserTagHangar into LaserTag, hide MakerRoom + EventRoom
|
||||
// from browse, and pull down the user-supplied thumbnails.
|
||||
using (var http = new HttpClient { Timeout = TimeSpan.FromSeconds(10) })
|
||||
{
|
||||
var imagesDir = Path.Combine(AppContext.BaseDirectory, "data", "images");
|
||||
await roomService.ApplyCanonicalOverridesAsync(http, imagesDir);
|
||||
}
|
||||
|
||||
// Seed default curated playlists, club category tags, store catalog.
|
||||
// Must run after SeedAsync so they can reference RR-Originals.
|
||||
var playlistService = scope.ServiceProvider.GetRequiredService<PlaylistService>();
|
||||
await playlistService.SeedCuratedAsync();
|
||||
var clubService = scope.ServiceProvider.GetRequiredService<ClubService>();
|
||||
await clubService.SeedDefaultsAsync();
|
||||
var storeService = scope.ServiceProvider.GetRequiredService<StoreService>();
|
||||
await storeService.SeedAsync();
|
||||
|
||||
// Tell the /healthz probe migrations are done. Coolify's rolling
|
||||
// deploy holds traffic at the LB until this flips and /healthz
|
||||
// returns 200.
|
||||
Controllers.Health.HealthController.MigrationsComplete = true;
|
||||
}
|
||||
|
||||
/// <summary>SQLite migration-history reconciliation. Handles two
|
||||
/// cutover scenarios so legacy DBs boot cleanly without manual SQL:
|
||||
/// <list type="number">
|
||||
/// <item><b>Legacy EnsureCreated</b> — schema exists, no
|
||||
/// <c>__EFMigrationsHistory</c>. (Pre-migrations DorkNet builds
|
||||
/// used EnsureCreated for SQLite too.)</item>
|
||||
/// <item><b>Stale history after consolidation</b> — history
|
||||
/// references migration IDs that no longer exist in the assembly.
|
||||
/// Happens once when the per-feature migrations are collapsed into
|
||||
/// a single Initial (see <c>Data/MIGRATIONS.md</c>).</item>
|
||||
/// </list>
|
||||
/// In both cases the on-disk schema is canonical; we just need history
|
||||
/// to reflect the assembly's current migration list so Migrate()
|
||||
/// no-ops. Fresh DBs short-circuit (no <c>Players</c> table yet) and
|
||||
/// Migrate() does the full CreateTable run normally.</summary>
|
||||
private static void BaselineExistingSchemaIfNeeded(DorkNetDbContext db)
|
||||
{
|
||||
using var conn = db.Database.GetDbConnection();
|
||||
conn.Open();
|
||||
|
||||
using (var probe = conn.CreateCommand())
|
||||
{
|
||||
probe.CommandText =
|
||||
"SELECT name FROM sqlite_master WHERE type='table' AND name='Players';";
|
||||
var hasPlayers = probe.ExecuteScalar() is not null;
|
||||
if (!hasPlayers) return; // Fresh DB — let Migrate() do its full thing.
|
||||
}
|
||||
|
||||
var assemblyMigrations = db.Database.GetMigrations().ToList();
|
||||
if (assemblyMigrations.Count == 0) return;
|
||||
|
||||
var historyExists = false;
|
||||
using (var historyProbe = conn.CreateCommand())
|
||||
{
|
||||
historyProbe.CommandText =
|
||||
"SELECT name FROM sqlite_master WHERE type='table' AND name='__EFMigrationsHistory';";
|
||||
historyExists = historyProbe.ExecuteScalar() is not null;
|
||||
}
|
||||
|
||||
var existingIds = new HashSet<string>(StringComparer.Ordinal);
|
||||
if (historyExists)
|
||||
{
|
||||
using var readCmd = conn.CreateCommand();
|
||||
readCmd.CommandText = @"SELECT ""MigrationId"" FROM ""__EFMigrationsHistory"";";
|
||||
using var reader = readCmd.ExecuteReader();
|
||||
while (reader.Read()) existingIds.Add(reader.GetString(0));
|
||||
|
||||
if (existingIds.SetEquals(assemblyMigrations))
|
||||
return;
|
||||
}
|
||||
else
|
||||
{
|
||||
using var createHistory = conn.CreateCommand();
|
||||
createHistory.CommandText = """
|
||||
CREATE TABLE "__EFMigrationsHistory" (
|
||||
"MigrationId" TEXT NOT NULL CONSTRAINT "PK___EFMigrationsHistory" PRIMARY KEY,
|
||||
"ProductVersion" TEXT NOT NULL
|
||||
);
|
||||
""";
|
||||
createHistory.ExecuteNonQuery();
|
||||
}
|
||||
|
||||
using (var wipe = conn.CreateCommand())
|
||||
{
|
||||
wipe.CommandText = @"DELETE FROM ""__EFMigrationsHistory"";";
|
||||
wipe.ExecuteNonQuery();
|
||||
}
|
||||
|
||||
var productVersion = typeof(DbContext).Assembly.GetName().Version?.ToString() ?? "10.0.0";
|
||||
foreach (var id in assemblyMigrations)
|
||||
{
|
||||
using var insert = conn.CreateCommand();
|
||||
insert.CommandText = """
|
||||
INSERT INTO "__EFMigrationsHistory" ("MigrationId", "ProductVersion")
|
||||
VALUES ($id, $ver);
|
||||
""";
|
||||
var pId = insert.CreateParameter(); pId.ParameterName = "$id"; pId.Value = id; insert.Parameters.Add(pId);
|
||||
var pVr = insert.CreateParameter(); pVr.ParameterName = "$ver"; pVr.Value = productVersion; insert.Parameters.Add(pVr);
|
||||
insert.ExecuteNonQuery();
|
||||
}
|
||||
|
||||
// A previous failed migrate may have left a stale row in
|
||||
// __EFMigrationsLock — drop the table so Migrate re-creates it
|
||||
// cleanly. Safe — the lock is per-application-instance and
|
||||
// we're single-instance here.
|
||||
using (var dropLock = conn.CreateCommand())
|
||||
{
|
||||
dropLock.CommandText = "DROP TABLE IF EXISTS __EFMigrationsLock;";
|
||||
dropLock.ExecuteNonQuery();
|
||||
}
|
||||
|
||||
var stale = existingIds.Except(assemblyMigrations).Count();
|
||||
var added = assemblyMigrations.Except(existingIds).Count();
|
||||
Log.Information(
|
||||
"[migrations] Reconciled SQLite history: dropped {Stale} stale ids, baselined {Added} assembly migrations ({First} … {Last}).",
|
||||
stale, added, assemblyMigrations.First(), assemblyMigrations.Last());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
using DorkNet.Server.Auth;
|
||||
using DorkNet.Server.Compat;
|
||||
using DorkNet.Server.Hubs;
|
||||
using DorkNet.Server.Services;
|
||||
using Microsoft.AspNetCore.Http.Connections;
|
||||
|
||||
namespace DorkNet.Server.Startup;
|
||||
|
||||
/// <summary>Assembles the full middleware pipeline in canonical order.
|
||||
/// Ordering rules (the comments at each Use call explain the why):
|
||||
/// <list type="number">
|
||||
/// <item>Swagger (dev only)</item>
|
||||
/// <item>HTTPS redirect (Kestrel-direct mode only)</item>
|
||||
/// <item>WebSocket keepalive override</item>
|
||||
/// <item>Static hosts (admin/site/feed) BEFORE request tracing because
|
||||
/// StaticFileMiddleware bypasses Response.Body wrapping</item>
|
||||
/// <item>Request tracing</item>
|
||||
/// <item>api.{apex}/admin → admin.{apex} redirect</item>
|
||||
/// <item>IP-ban (earliest filter, no DB beyond a small lookup)</item>
|
||||
/// <item>Version detection (gates anonymous calls too)</item>
|
||||
/// <item>Authentication → Authorization → player-ban</item>
|
||||
/// <item>MapControllers + SignalR hub</item>
|
||||
/// </list></summary>
|
||||
public static class MiddlewarePipelineExtensions
|
||||
{
|
||||
public static void UseDorkNetPipeline(this WebApplication app)
|
||||
{
|
||||
var domainCfg = app.Services.GetRequiredService<DomainConfig>();
|
||||
|
||||
if (app.Environment.IsDevelopment())
|
||||
{
|
||||
app.UseSwagger();
|
||||
app.UseSwaggerUI();
|
||||
}
|
||||
|
||||
// HTTP → HTTPS redirect — only kicks in when an HTTPS endpoint is
|
||||
// configured in appsettings (Kestrel-direct mode). When fronted
|
||||
// by nginx, both endpoints are effectively HTTP from Kestrel's
|
||||
// perspective and this is a no-op.
|
||||
app.UseHttpsRedirection();
|
||||
|
||||
// 2-minute WebSocket pings are too lazy for cloudflared — the
|
||||
// tunnel kills the underlying TCP stream after ~90-100s of silence
|
||||
// even though SignalR's app-layer pings would arrive on schedule.
|
||||
// Send a ping frame every 30s so the tunnel always sees fresh
|
||||
// traffic; SignalR's own KeepAliveInterval (15s) stays under this
|
||||
// floor.
|
||||
app.UseWebSockets(new Microsoft.AspNetCore.Builder.WebSocketOptions { KeepAliveInterval = TimeSpan.FromSeconds(30) });
|
||||
|
||||
// Per-host static branches — admin SPA, public site, legacy feed.* —
|
||||
// all keyed off the configured apex so a single DORKNET_DOMAIN env
|
||||
// var moves every static host together.
|
||||
app.MountStaticHost(domainCfg.Sub("admin"), "admin", spaFallback: true);
|
||||
// Public-facing site at the apex (e.g. localhost + www variant).
|
||||
// React-router routes are client-side so spaFallback must be true.
|
||||
app.MountStaticHost(domainCfg.Apex, "site", spaFallback: true);
|
||||
app.MountStaticHost($"www.{domainCfg.Apex}", "site", spaFallback: true);
|
||||
// Old feed.* subdomain kept as legacy; new visitors land on the apex.
|
||||
app.MountStaticHost(domainCfg.Sub("feed"), "feed");
|
||||
|
||||
// Request/response tracing — emits one structured log per non-health
|
||||
// request, with the response body included for any 4xx/5xx. Mounted
|
||||
// AFTER static hosts so SendFileAsync's underlying connection isn't
|
||||
// wrapped.
|
||||
app.UseRequestTracing();
|
||||
|
||||
// Friendly redirect from the old api.{apex}/admin path → admin.{apex}.
|
||||
app.Use(async (ctx, next) =>
|
||||
{
|
||||
if (ctx.Request.Host.Host.Equals(domainCfg.Sub("api"), StringComparison.OrdinalIgnoreCase) &&
|
||||
(ctx.Request.Path == "/admin" || ctx.Request.Path.StartsWithSegments("/admin")))
|
||||
{
|
||||
ctx.Response.Redirect($"https://{domainCfg.Sub("admin")}/", permanent: false);
|
||||
return;
|
||||
}
|
||||
await next();
|
||||
});
|
||||
|
||||
// IP-level bans run BEFORE authentication so banned-IP traffic gets
|
||||
// cut off at the earliest point possible — no JWT round-trip, no DB
|
||||
// hit beyond the (small) IpBans lookup.
|
||||
app.UseMiddleware<IpBanCheckMiddleware>();
|
||||
|
||||
// Client-version gate. Reads X-DorkNet-Version, validates against
|
||||
// the per-deployment allow-list, and 426s mismatches before they
|
||||
// reach a controller. Sits before auth on purpose so anonymous
|
||||
// calls (login, photon custom auth) are gated too.
|
||||
app.UseMiddleware<VersionDetectionMiddleware>();
|
||||
|
||||
app.UseAuthentication();
|
||||
app.UseAuthorization();
|
||||
// Sits between auth and the controllers — by this point ctx.User has
|
||||
// the validated principal (or is empty for anonymous calls). Bans are
|
||||
// enforced uniformly without each controller having to remember.
|
||||
app.UseMiddleware<BanCheckMiddleware>();
|
||||
app.MapControllers();
|
||||
|
||||
// SignalR hub on the notify.* subdomain — RequireHost prevents the
|
||||
// same path from being matched on api.* or other subdomains.
|
||||
// Pin transport to WebSockets-only: letting SignalR fall back to
|
||||
// ServerSentEvents or LongPolling makes "is the proxy idle?" a
|
||||
// per-poll question instead of a stream-level one. WebSockets keep
|
||||
// one socket hot with our 10s pings.
|
||||
app.MapHub<NotifyHub>("/hub/v1", opts =>
|
||||
{
|
||||
opts.Transports = HttpTransportType.WebSockets;
|
||||
// SignalR's per-connection idle disconnect — must be > the
|
||||
// longest keepalive interval in the stack.
|
||||
opts.TransportSendTimeout = TimeSpan.FromSeconds(30);
|
||||
}).RequireHost(domainCfg.Sub("notify"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,190 @@
|
||||
using DorkNet.Server.Services;
|
||||
|
||||
namespace DorkNet.Server.Startup;
|
||||
|
||||
/// <summary>Per-request observability middleware. Emits one structured
|
||||
/// log line per non-health request, capturing the response body for any
|
||||
/// 4xx/5xx so we can see exactly what the game is asking for. Also
|
||||
/// records each request against <see cref="PlayerLogService"/> for the
|
||||
/// admin SPA's "Player logs" tab when an authenticated player is
|
||||
/// attached.
|
||||
///
|
||||
/// <para>Skips:
|
||||
/// <list type="bullet">
|
||||
/// <item>Health probes (<c>/healthz</c>) — too noisy.</item>
|
||||
/// <item>WebSocket upgrades — wrapping the response body breaks the
|
||||
/// handshake and SignalR's WebSocket transport.</item>
|
||||
/// <item>CDN host responses — they're binary blobs we don't want to
|
||||
/// serialize into log lines.</item>
|
||||
/// </list></para>
|
||||
///
|
||||
/// <para>Must be registered AFTER <see cref="StaticHostExtensions.MountStaticHost"/>
|
||||
/// so StaticFileMiddleware's <c>SendFileAsync</c> path doesn't have its
|
||||
/// underlying connection wrapped by a MemoryStream.</para></summary>
|
||||
public static class RequestTracingExtensions
|
||||
{
|
||||
public static void UseRequestTracing(this WebApplication app)
|
||||
{
|
||||
var reqLogger = app.Services.GetRequiredService<ILoggerFactory>()
|
||||
.CreateLogger("DorkNet.Server.HttpTrace");
|
||||
var playerLog = app.Services.GetRequiredService<PlayerLogService>();
|
||||
|
||||
app.Use(async (ctx, next) =>
|
||||
{
|
||||
if (IsHealthProbe(ctx.Request))
|
||||
{
|
||||
await next();
|
||||
return;
|
||||
}
|
||||
|
||||
// WebSocket upgrades take over the underlying connection —
|
||||
// wrapping the response body in a MemoryStream confuses the
|
||||
// upgrade handshake and prevents SignalR's WebSocket transport
|
||||
// from working.
|
||||
if (ctx.WebSockets.IsWebSocketRequest)
|
||||
{
|
||||
reqLogger.LogInformation(
|
||||
"[ws] {Method} {Host}{Path}{Query}",
|
||||
ctx.Request.Method, ctx.Request.Host, ctx.Request.Path,
|
||||
ctx.Request.QueryString.Value ?? "");
|
||||
await next();
|
||||
return;
|
||||
}
|
||||
|
||||
var host = ctx.Request.Host.Host;
|
||||
var path = ctx.Request.Path.Value ?? "";
|
||||
var isStorageLike = host.StartsWith("storage.", StringComparison.OrdinalIgnoreCase)
|
||||
|| host.StartsWith("cdn.", StringComparison.OrdinalIgnoreCase)
|
||||
|| path.Contains("upload", StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
if (isStorageLike)
|
||||
{
|
||||
reqLogger.LogInformation(
|
||||
"[req:start] {Method} {Host}{Path}{Query} contentType={ContentType} contentLength={ContentLength}",
|
||||
ctx.Request.Method, ctx.Request.Host, ctx.Request.Path,
|
||||
ctx.Request.QueryString.Value ?? "", ctx.Request.ContentType ?? "",
|
||||
ctx.Request.ContentLength);
|
||||
}
|
||||
|
||||
var sw = System.Diagnostics.Stopwatch.StartNew();
|
||||
var originalBody = ctx.Response.Body;
|
||||
var captureResponse = ShouldCaptureResponse(ctx.Request);
|
||||
MemoryStream? capture = null;
|
||||
if (captureResponse)
|
||||
{
|
||||
capture = new MemoryStream();
|
||||
ctx.Response.Body = capture;
|
||||
}
|
||||
|
||||
string? requestBody = null;
|
||||
if (ctx.Request.ContentLength is > 0 and < 4096 && IsTextLikeRequest(ctx.Request.ContentType))
|
||||
{
|
||||
ctx.Request.EnableBuffering();
|
||||
using var reader = new StreamReader(ctx.Request.Body, leaveOpen: true);
|
||||
requestBody = await reader.ReadToEndAsync();
|
||||
ctx.Request.Body.Position = 0;
|
||||
}
|
||||
|
||||
Exception? thrown = null;
|
||||
try
|
||||
{
|
||||
await next();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
thrown = ex;
|
||||
throw;
|
||||
}
|
||||
finally
|
||||
{
|
||||
sw.Stop();
|
||||
var status = thrown is null ? ctx.Response.StatusCode : 500;
|
||||
var responseBody = "";
|
||||
|
||||
if (capture is not null)
|
||||
{
|
||||
try
|
||||
{
|
||||
capture.Seek(0, SeekOrigin.Begin);
|
||||
if (ShouldReadResponseBody(ctx.Response.ContentType, status))
|
||||
responseBody = await new StreamReader(capture, leaveOpen: true).ReadToEndAsync();
|
||||
capture.Seek(0, SeekOrigin.Begin);
|
||||
await capture.CopyToAsync(originalBody);
|
||||
}
|
||||
finally
|
||||
{
|
||||
ctx.Response.Body = originalBody;
|
||||
await capture.DisposeAsync();
|
||||
}
|
||||
}
|
||||
|
||||
var trimmedRequest = requestBody is null ? null
|
||||
: (requestBody.Length > 200 ? requestBody[..200] + "..." : requestBody);
|
||||
var trimmedResponse = responseBody.Length > 300 ? responseBody[..300] + "..." : responseBody;
|
||||
var level = thrown is not null || status >= 500 ? LogLevel.Error
|
||||
: status >= 400 ? LogLevel.Warning
|
||||
: LogLevel.Information;
|
||||
|
||||
reqLogger.Log(level, thrown,
|
||||
"[req] {Status} {Method} {Host}{Path}{Query} {ElapsedMs}ms contentType={ContentType} contentLength={ContentLength} req={ReqBody} resp={RespBody}",
|
||||
status, ctx.Request.Method, ctx.Request.Host, ctx.Request.Path,
|
||||
ctx.Request.QueryString.Value ?? "", sw.ElapsedMilliseconds,
|
||||
ctx.Request.ContentType ?? "", ctx.Request.ContentLength,
|
||||
trimmedRequest ?? "", trimmedResponse);
|
||||
|
||||
var pid = Auth.ControllerBaseExtensions.CurrentPlayerId(ctx.User);
|
||||
if (pid is long playerId)
|
||||
{
|
||||
playerLog.Record(new PlayerLogEntry
|
||||
{
|
||||
Timestamp = DateTime.UtcNow,
|
||||
PlayerId = playerId,
|
||||
Method = ctx.Request.Method,
|
||||
Host = ctx.Request.Host.Host,
|
||||
Path = ctx.Request.Path.Value ?? string.Empty,
|
||||
Query = ctx.Request.QueryString.Value ?? string.Empty,
|
||||
Status = status,
|
||||
ElapsedMs = sw.ElapsedMilliseconds,
|
||||
ReqBody = trimmedRequest,
|
||||
RespBody = trimmedResponse,
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private static bool IsHealthProbe(HttpRequest request)
|
||||
=> request.Path.Equals("/healthz", StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
private static bool IsTextLikeRequest(string? contentType)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(contentType)) return false;
|
||||
return contentType.StartsWith("application/json", StringComparison.OrdinalIgnoreCase)
|
||||
|| contentType.StartsWith("application/x-www-form-urlencoded", StringComparison.OrdinalIgnoreCase)
|
||||
|| contentType.StartsWith("text/", StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
private static bool ShouldCaptureResponse(HttpRequest request)
|
||||
{
|
||||
var host = request.Host.Host;
|
||||
if (host.StartsWith("cdn.", StringComparison.OrdinalIgnoreCase))
|
||||
return false;
|
||||
|
||||
var path = request.Path.Value ?? "";
|
||||
var ext = Path.GetExtension(path);
|
||||
if (string.IsNullOrEmpty(ext))
|
||||
return true;
|
||||
|
||||
return ext.Equals(".json", StringComparison.OrdinalIgnoreCase)
|
||||
|| ext.Equals(".txt", StringComparison.OrdinalIgnoreCase)
|
||||
|| ext.Equals(".log", StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
private static bool ShouldReadResponseBody(string? contentType, int status)
|
||||
{
|
||||
if (status < 400) return false;
|
||||
if (string.IsNullOrWhiteSpace(contentType)) return true;
|
||||
return contentType.StartsWith("application/json", StringComparison.OrdinalIgnoreCase)
|
||||
|| contentType.StartsWith("text/", StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,341 @@
|
||||
using Microsoft.AspNetCore.Authentication.JwtBearer;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.IdentityModel.Tokens;
|
||||
using DorkNet.Server.Compat;
|
||||
using DorkNet.Server.Data;
|
||||
using DorkNet.Server.Services;
|
||||
using DorkNet.Server.Versions.Late2020;
|
||||
using Serilog;
|
||||
using Serilog.Formatting.Compact;
|
||||
using StackExchange.Redis;
|
||||
using System.Text;
|
||||
|
||||
namespace DorkNet.Server.Startup;
|
||||
|
||||
/// <summary>One-stop DI registration for the DorkNet host. Each
|
||||
/// section is a self-contained private method so the top-level
|
||||
/// <see cref="AddDorkNetServices"/> reads as a checklist of what the
|
||||
/// server actually needs. Keep new registrations grouped by topic
|
||||
/// rather than appended chronologically.</summary>
|
||||
public static class ServiceCollectionExtensions
|
||||
{
|
||||
public static void AddDorkNetServices(this WebApplicationBuilder builder)
|
||||
{
|
||||
ConfigureLogging(builder);
|
||||
AddDatabase(builder);
|
||||
AddDomain(builder);
|
||||
AddCoreServices(builder.Services);
|
||||
AddVersionCompatibility(builder);
|
||||
var redisConn = AddRedis(builder);
|
||||
AddAuthentication(builder);
|
||||
AddKestrelAndSignalR(builder, redisConn);
|
||||
}
|
||||
|
||||
// ── Logging ───────────────────────────────────────────────────────────────
|
||||
// Production: structured JSON (one JSON object per line) so the log pipeline
|
||||
// can parse fields without regex. Development: human-readable text so the
|
||||
// local console stays scannable. JSON output is the same shape no matter the
|
||||
// environment — log keys like {Tag}, {Status}, {Method} stay queryable.
|
||||
private static void ConfigureLogging(WebApplicationBuilder builder)
|
||||
{
|
||||
builder.Host.UseSerilog((ctx, services, cfg) =>
|
||||
{
|
||||
cfg.ReadFrom.Configuration(ctx.Configuration)
|
||||
.ReadFrom.Services(services)
|
||||
.Enrich.FromLogContext();
|
||||
|
||||
if (ctx.HostingEnvironment.IsDevelopment())
|
||||
cfg.WriteTo.Console();
|
||||
else
|
||||
cfg.WriteTo.Console(new RenderedCompactJsonFormatter());
|
||||
});
|
||||
}
|
||||
|
||||
// ── Database ──────────────────────────────────────────────────────────────
|
||||
// Provider switch — `Database:Provider` config key (or DATABASE__PROVIDER env
|
||||
// var) chooses sqlite (default for local dev) or postgres (production).
|
||||
private static void AddDatabase(WebApplicationBuilder builder)
|
||||
{
|
||||
var dbProvider = (builder.Configuration["Database:Provider"] ?? "sqlite").ToLowerInvariant();
|
||||
|
||||
builder.Services.AddDbContext<DorkNetDbContext>(opt =>
|
||||
{
|
||||
// EF Core 9 throws on every connection if the model drifts from
|
||||
// the migrations snapshot. We track migrations + the snapshot
|
||||
// by hand for data-only fixes, so this warning is noise — Coolify
|
||||
// and local-dev databases still apply real migrations from disk.
|
||||
opt.ConfigureWarnings(w => w.Ignore(
|
||||
Microsoft.EntityFrameworkCore.Diagnostics.RelationalEventId.PendingModelChangesWarning));
|
||||
|
||||
if (dbProvider == "postgres")
|
||||
{
|
||||
var conn = builder.Configuration.GetConnectionString("Default")
|
||||
?? throw new InvalidOperationException(
|
||||
"Database:Provider=postgres but ConnectionStrings:Default is not set. " +
|
||||
"Set the ConnectionStrings__Default env var or `ConnectionStrings:Default` " +
|
||||
"in appsettings.Local.json.");
|
||||
opt.UseNpgsql(conn, npg => npg.MigrationsAssembly("DorkNet.Server"));
|
||||
}
|
||||
else
|
||||
{
|
||||
var dbPath = Path.Combine(AppContext.BaseDirectory, "data", "dorknet.db");
|
||||
Directory.CreateDirectory(Path.GetDirectoryName(dbPath)!);
|
||||
opt.UseSqlite($"Data Source={dbPath}", lite => lite.MigrationsAssembly("DorkNet.Server"));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// ── Domain ────────────────────────────────────────────────────────────────
|
||||
// Single source of truth for the deployment apex. Read once at startup
|
||||
// from DORKNET_DOMAIN (env-var) or Domain:Apex (config), defaulting to
|
||||
// localhost so existing deploys keep working without setting the env var.
|
||||
// Replaces per-controller [Host(...)] filters that hard-coded
|
||||
// rec.net/localhost pairs — the singleton DomainConfig is injected
|
||||
// anywhere code needs to build outbound URLs.
|
||||
private static void AddDomain(WebApplicationBuilder builder)
|
||||
{
|
||||
var apex =
|
||||
builder.Configuration["Domain:Apex"]
|
||||
?? Environment.GetEnvironmentVariable("DORKNET_DOMAIN")
|
||||
?? "localhost";
|
||||
builder.Services.AddSingleton(new DomainConfig(apex));
|
||||
builder.Services.Configure<Microsoft.AspNetCore.HostFiltering.HostFilteringOptions>(opt =>
|
||||
{
|
||||
opt.AllowedHosts = new[] { apex, $"*.{apex}", "localhost", "127.0.0.1" };
|
||||
opt.AllowEmptyHosts = true;
|
||||
opt.IncludeFailureMessage = true;
|
||||
});
|
||||
Console.WriteLine($"[domain] apex={apex}, allowedHosts=[{apex}, *.{apex}, localhost]");
|
||||
}
|
||||
|
||||
// ── Services ──────────────────────────────────────────────────────────────
|
||||
private static void AddCoreServices(IServiceCollection services)
|
||||
{
|
||||
services.AddScoped<PlayerService>();
|
||||
services.AddScoped<ConfigService>();
|
||||
services.AddScoped<AuthService>();
|
||||
services.AddScoped<RoomService>();
|
||||
services.AddScoped<PlaylistService>();
|
||||
services.AddScoped<ClubService>();
|
||||
services.AddScoped<LevelService>();
|
||||
services.AddScoped<StoreService>();
|
||||
// Singleton — the protobuf blob is identical for every room and built
|
||||
// once at startup; no need to rebuild per request.
|
||||
services.AddSingleton<RoomDataBlobService>();
|
||||
services.AddSingleton<RoomBlobNormalizerService>();
|
||||
services.AddHttpClient();
|
||||
services.AddSingleton<HtrAssetMirrorService>();
|
||||
// Scoped (per-request) — these services hold DbContext references and
|
||||
// must share the request's scope to participate in the request's
|
||||
// transaction lifecycle. Pre-PR-3 they were Singleton with in-process
|
||||
// ConcurrentDictionary state; PR 3 moved their state to Postgres and
|
||||
// they now resolve from DI per-request like any other DbContext consumer.
|
||||
services.AddScoped<GameSessionService>();
|
||||
services.AddScoped<PrivateInstanceService>();
|
||||
services.AddScoped<CommunityBoardService>();
|
||||
services.AddScoped<ServerSettingsService>();
|
||||
// Singletons — these own connectionless state (Redis-backed or
|
||||
// process-local) and don't need a per-request scope.
|
||||
services.AddSingleton<NotificationService>();
|
||||
services.AddSingleton<OrphanAccountTracker>();
|
||||
services.AddSingleton<PlayerPresenceService>();
|
||||
services.AddSingleton<OnlinePresenceService>();
|
||||
services.AddSingleton<PlayerLogService>();
|
||||
// S3-compatible object storage (Garage in production, MinIO/disk in dev)
|
||||
// — holds profile images + room-blob bytes. Stateless wrapper around
|
||||
// the AWS SDK; safe as a singleton.
|
||||
services.AddSingleton<IObjectStorage, ObjectStorageService>();
|
||||
services.AddSingleton<ImageSignatureService>();
|
||||
}
|
||||
|
||||
// ── Client-version compatibility ─────────────────────────────────────────
|
||||
// Each Rec Room build the server supports is one IVersionPlugin. Plugins
|
||||
// declare which version keys they handle (a single plugin can cover N keys
|
||||
// when several builds share the same wire format) and register any
|
||||
// generation-specific strategy services into DI.
|
||||
//
|
||||
// Add a new version family: drop a folder under Versions/, implement
|
||||
// IVersionPlugin, and register the singleton here. Add a new version to
|
||||
// an existing family (same wire shapes): just list the key in the
|
||||
// existing plugin's VersionKeys — no DI change needed.
|
||||
private static void AddVersionCompatibility(WebApplicationBuilder builder)
|
||||
{
|
||||
var pluginInstances = new IVersionPlugin[]
|
||||
{
|
||||
new Late2020VersionPlugin(),
|
||||
// Add March2020 / future generations here as their plugins land.
|
||||
};
|
||||
foreach (var p in pluginInstances) p.RegisterStrategies(builder.Services);
|
||||
foreach (var p in pluginInstances)
|
||||
builder.Services.AddSingleton<IVersionPlugin>(p);
|
||||
|
||||
var supported = (builder.Configuration.GetSection("DorkNet:SupportedVersions").Get<string[]>()
|
||||
?? new[] { "december_2020_12_18" })
|
||||
.ToHashSet(StringComparer.OrdinalIgnoreCase);
|
||||
var defaultKey = builder.Configuration["DorkNet:DefaultClientVersion"]
|
||||
?? "december_2020_12_18";
|
||||
builder.Services.AddSingleton(sp => new VersionRegistry(
|
||||
sp.GetServices<IVersionPlugin>(),
|
||||
supported,
|
||||
string.IsNullOrWhiteSpace(defaultKey) ? null : defaultKey));
|
||||
}
|
||||
|
||||
// ── Redis ─────────────────────────────────────────────────────────────────
|
||||
// IConnectionMultiplexer is registered ONLY when a connection string is
|
||||
// present. The Redis-backed services check for it via DI and fall back to
|
||||
// process-local state when null. Local dev can run without Redis; Coolify
|
||||
// production sets ConnectionStrings__Redis on the linked service.
|
||||
private static string? AddRedis(WebApplicationBuilder builder)
|
||||
{
|
||||
var redisConn = builder.Configuration.GetConnectionString("Redis");
|
||||
if (string.IsNullOrWhiteSpace(redisConn)) return null;
|
||||
var seRedisConfig = NormalizeRedisConn(redisConn);
|
||||
var mux = ConnectionMultiplexer.Connect(seRedisConfig);
|
||||
builder.Services.AddSingleton<IConnectionMultiplexer>(mux);
|
||||
return redisConn;
|
||||
}
|
||||
|
||||
// Coolify hands you a URI like
|
||||
// redis://default:PASSWORD@host:6379/0
|
||||
// but StackExchange.Redis.Connect(string) doesn't reliably handle the
|
||||
// user-info segment of a URI (the "default:PASSWORD@" part) — passing
|
||||
// the URI verbatim ends up with no password applied to the
|
||||
// ConfigurationOptions and Connect throws a misleading
|
||||
// "Error connecting right now". So we detect a URI prefix and translate
|
||||
// to SE.Redis's native config string before handing off.
|
||||
internal static string NormalizeRedisConn(string raw)
|
||||
{
|
||||
if (!raw.StartsWith("redis://", StringComparison.OrdinalIgnoreCase) &&
|
||||
!raw.StartsWith("rediss://", StringComparison.OrdinalIgnoreCase))
|
||||
return raw;
|
||||
var uri = new Uri(raw);
|
||||
var host = uri.Host;
|
||||
var port = uri.Port > 0 ? uri.Port : 6379;
|
||||
string? user = null, pass = null;
|
||||
if (!string.IsNullOrEmpty(uri.UserInfo))
|
||||
{
|
||||
var parts = uri.UserInfo.Split(':', 2);
|
||||
user = Uri.UnescapeDataString(parts[0]);
|
||||
if (parts.Length == 2) pass = Uri.UnescapeDataString(parts[1]);
|
||||
}
|
||||
var opts = new ConfigurationOptions
|
||||
{
|
||||
EndPoints = { { host, port } },
|
||||
AbortOnConnectFail = false,
|
||||
Ssl = raw.StartsWith("rediss://", StringComparison.OrdinalIgnoreCase),
|
||||
};
|
||||
if (!string.IsNullOrEmpty(user)) opts.User = user;
|
||||
if (!string.IsNullOrEmpty(pass)) opts.Password = pass;
|
||||
return opts.ToString();
|
||||
}
|
||||
|
||||
// ── JWT Auth ──────────────────────────────────────────────────────────────
|
||||
// Resolution order: env-var first (for production / containerised deploys),
|
||||
// then `Jwt:Secret` from any configuration source (appsettings.Local.json
|
||||
// is the same-machine convenience path the patcher script writes to).
|
||||
// Never commit a real secret to appsettings.json. DORKNET_JWT_SECRET is the
|
||||
// canonical env var name; RECNET_JWT_SECRET kept for backward compat with
|
||||
// older Coolify configs.
|
||||
private static void AddAuthentication(WebApplicationBuilder builder)
|
||||
{
|
||||
var jwtSecret = Environment.GetEnvironmentVariable("DORKNET_JWT_SECRET")
|
||||
?? Environment.GetEnvironmentVariable("RECNET_JWT_SECRET")
|
||||
?? builder.Configuration["Jwt:Secret"]
|
||||
?? throw new InvalidOperationException(
|
||||
"JWT secret not configured. Set the DORKNET_JWT_SECRET env var or " +
|
||||
"add `Jwt:Secret` to appsettings.Local.json (the install-plugin.ps1 " +
|
||||
"script generates one automatically on same-machine setup).");
|
||||
|
||||
builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
|
||||
.AddJwtBearer(opt =>
|
||||
{
|
||||
opt.TokenValidationParameters = new TokenValidationParameters
|
||||
{
|
||||
ValidateIssuerSigningKey = true,
|
||||
IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(jwtSecret)),
|
||||
ValidateIssuer = true,
|
||||
ValidIssuer = "https://api.rec.net/",
|
||||
ValidateAudience = false,
|
||||
ClockSkew = TimeSpan.Zero,
|
||||
};
|
||||
// SignalR over WebSocket: BestHTTP.SignalRCore (the 2020 watch's
|
||||
// hub client) negotiates with Authorization: Bearer, but the
|
||||
// WebSocket upgrade leg isn't guaranteed to keep the header
|
||||
// depending on the proxy in front of us. The hub client falls
|
||||
// back to ?access_token=<jwt> on the WS URL, which we extract
|
||||
// here for any /hub/* path. Without this, the watch's
|
||||
// RecNet.Notifications connect throws "Failed to connect to
|
||||
// RecNet Notifications" mid-login.
|
||||
opt.Events = new JwtBearerEvents
|
||||
{
|
||||
OnMessageReceived = ctx =>
|
||||
{
|
||||
var accessToken = ctx.Request.Query["access_token"];
|
||||
var path = ctx.HttpContext.Request.Path;
|
||||
if (!string.IsNullOrEmpty(accessToken) && path.StartsWithSegments("/hub"))
|
||||
ctx.Token = accessToken;
|
||||
return Task.CompletedTask;
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
builder.Services.AddAuthorization();
|
||||
// Use C# property names verbatim — [JsonPropertyName] attributes on each
|
||||
// model give the per-key casing the client's LitJson Util.GetKey calls
|
||||
// expect (a mix of lowercase, camelCase and snake_case; verified by
|
||||
// disassembling each Deserialize body). Don't apply a global naming policy
|
||||
// or it will override the explicit per-key attributes.
|
||||
builder.Services.AddControllers().AddJsonOptions(opt =>
|
||||
opt.JsonSerializerOptions.PropertyNamingPolicy = null);
|
||||
}
|
||||
|
||||
// ── Kestrel + SignalR ────────────────────────────────────────────────────
|
||||
// Kestrel-level limits: defaults trim long-lived sockets earlier than our
|
||||
// SignalR ping interval allows.
|
||||
// * KeepAliveTimeout (default 130s) applies to upgraded WebSocket
|
||||
// connections in some hosting paths; bump to 10m so it never beats
|
||||
// SignalR's own ping cadence.
|
||||
// * MinRequestBodyDataRate / MinResponseDataRate enforce a minimum
|
||||
// bytes/sec on streaming bodies; for chunked-upload room imports
|
||||
// that's been the cause of mysterious abort-mid-write errors on
|
||||
// slow links. Disabling them is safer than risking false-positive
|
||||
// "client too slow" closures.
|
||||
//
|
||||
// SignalR — drives notify.{apex}/hub/v1, consumed client-side by
|
||||
// BestHTTP.SignalRCore.HubConnection. The framework handles the
|
||||
// /negotiate POST automatically. When Redis is configured, attach
|
||||
// the StackExchange.Redis backplane so groups (player:N) fan out
|
||||
// across replicas.
|
||||
private static void AddKestrelAndSignalR(WebApplicationBuilder builder, string? redisConn)
|
||||
{
|
||||
builder.WebHost.ConfigureKestrel(kopts =>
|
||||
{
|
||||
kopts.Limits.KeepAliveTimeout = TimeSpan.FromMinutes(10);
|
||||
kopts.Limits.MinRequestBodyDataRate = null;
|
||||
kopts.Limits.MinResponseDataRate = null;
|
||||
});
|
||||
|
||||
// Keepalive cadence: server pings every 10s, gives the client 60s
|
||||
// to ack before declaring the connection dead. The 10s ping forces
|
||||
// frame traffic well below every proxy timeout we've seen in the
|
||||
// path (Cloudflare edge ~100s, cloudflared 600s, Coolify/Traefik
|
||||
// 180s, Kestrel 130s).
|
||||
var signalR = builder.Services.AddSignalR(opts =>
|
||||
{
|
||||
opts.KeepAliveInterval = TimeSpan.FromSeconds(10);
|
||||
opts.ClientTimeoutInterval = TimeSpan.FromSeconds(60);
|
||||
opts.HandshakeTimeout = TimeSpan.FromSeconds(15);
|
||||
// Allow large gift / community-board payloads (default 32 KB
|
||||
// truncates anything bigger and disconnects the client).
|
||||
opts.MaximumReceiveMessageSize = 1024 * 1024;
|
||||
opts.EnableDetailedErrors = true;
|
||||
});
|
||||
if (!string.IsNullOrWhiteSpace(redisConn))
|
||||
signalR.AddStackExchangeRedis(NormalizeRedisConn(redisConn), opts =>
|
||||
{
|
||||
opts.Configuration.ChannelPrefix = RedisChannel.Literal("dorknet-signalr");
|
||||
});
|
||||
builder.Services.AddEndpointsApiExplorer();
|
||||
builder.Services.AddSwaggerGen();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
using Microsoft.Extensions.FileProviders;
|
||||
|
||||
namespace DorkNet.Server.Startup;
|
||||
|
||||
/// <summary>Per-host static-files branching. Each subdomain
|
||||
/// (<c>admin.{apex}</c>, the apex, <c>www.{apex}</c>, <c>feed.{apex}</c>)
|
||||
/// gets its own <see cref="PhysicalFileProvider"/> rooted at
|
||||
/// <c>wwwroot/<subdir></c>, so requests like
|
||||
/// <c>admin.{apex}/style.css</c> map to <c>wwwroot/admin/style.css</c>
|
||||
/// with no path-prefix munging.
|
||||
///
|
||||
/// <para>Uses <see cref="UseWhenExtensions.UseWhen"/> (not MapWhen) so
|
||||
/// requests for paths the SPA doesn't serve fall through to controllers.
|
||||
/// MapWhen would fork into a parallel pipeline that terminates without
|
||||
/// invoking matched endpoints.</para>
|
||||
///
|
||||
/// <para>Mounted BEFORE the request-tracing wrapper because
|
||||
/// StaticFileMiddleware uses <c>IHttpResponseBodyFeature.SendFileAsync</c>,
|
||||
/// which writes directly to the underlying connection and ignores any
|
||||
/// <c>Response.Body</c> replacement done downstream.</para></summary>
|
||||
public static class StaticHostExtensions
|
||||
{
|
||||
public static void MountStaticHost(this WebApplication app, string host, string subdir, bool spaFallback = false)
|
||||
{
|
||||
var staticHostLogger = app.Services.GetRequiredService<ILoggerFactory>()
|
||||
.CreateLogger("DorkNet.Server.StaticHost");
|
||||
var root = Path.Combine(app.Environment.ContentRootPath, "wwwroot", subdir);
|
||||
staticHostLogger.LogInformation(
|
||||
"[static-host] {Host} → {Root} (exists: {Exists}, spaFallback: {Spa})",
|
||||
host, root, Directory.Exists(root), spaFallback);
|
||||
if (!Directory.Exists(root)) return;
|
||||
var files = new PhysicalFileProvider(root);
|
||||
|
||||
// Use UseDefaultFiles + UseStaticFiles directly rather than
|
||||
// UseFileServer because the latter's nested options initialization
|
||||
// doesn't always propagate the FileProvider correctly when wrapped
|
||||
// inside UseWhen — leading to the static files middleware looking
|
||||
// at the default WebRoot (wwwroot/) instead of our scoped folder.
|
||||
var defaultFilesOpts = new DefaultFilesOptions { FileProvider = files };
|
||||
var staticFilesOpts = new StaticFileOptions
|
||||
{
|
||||
FileProvider = files,
|
||||
// Don't let the browser cache admin assets — we iterate on
|
||||
// the HTML/JS/CSS often and a stale cached page silently
|
||||
// swallows every change.
|
||||
OnPrepareResponse = ctx =>
|
||||
{
|
||||
var headers = ctx.Context.Response.Headers;
|
||||
headers.CacheControl = "no-cache, no-store, must-revalidate";
|
||||
headers.Pragma = "no-cache";
|
||||
headers.Expires = "0";
|
||||
},
|
||||
};
|
||||
|
||||
app.UseWhen(ctx => ctx.Request.Host.Host.Equals(host, StringComparison.OrdinalIgnoreCase),
|
||||
branch =>
|
||||
{
|
||||
// Endpoint-clearing rule: the auto-added UseRouting (which
|
||||
// runs before our middleware in minimal hosting) may have
|
||||
// matched a non-admin endpoint for paths the static-host
|
||||
// should answer — typically NsController.Root for "/" on
|
||||
// admin.{apex} and the apex, which would serve the
|
||||
// service-URL-map JSON instead of the SPA's index.html.
|
||||
// StaticFileMiddleware's ValidateNoEndpoint guard skips
|
||||
// serving whenever an endpoint is already matched, so we
|
||||
// clear it for non-API paths to let UseStaticFiles run.
|
||||
//
|
||||
// BUT: /api/* paths MUST keep their matched endpoint —
|
||||
// those are admin/site/feed API calls that the matching
|
||||
// controller needs to handle.
|
||||
branch.Use(async (ctx, next) =>
|
||||
{
|
||||
var p = ctx.Request.Path.Value ?? "/";
|
||||
var probe = files.GetFileInfo(p == "/" ? "/index.html" : p);
|
||||
staticHostLogger.LogInformation(
|
||||
"[static-host:{Host}] path={Path} probe={Probe} exists={Exists}",
|
||||
host, p, probe.PhysicalPath ?? "<null>", probe.Exists);
|
||||
if (!p.StartsWith("/api/", StringComparison.OrdinalIgnoreCase))
|
||||
ctx.SetEndpoint(null);
|
||||
await next();
|
||||
});
|
||||
branch.UseDefaultFiles(defaultFilesOpts);
|
||||
branch.UseStaticFiles(staticFilesOpts);
|
||||
|
||||
// Terminal handler — runs AFTER UseStaticFiles. By this
|
||||
// point the request either:
|
||||
// (a) was a static file UseStaticFiles served — Response
|
||||
// already started, we just return.
|
||||
// (b) is an API call we want to fall through to
|
||||
// controllers — call next().
|
||||
// (c) is a SPA deep link (no extension) we want to answer
|
||||
// with index.html so React Router can re-resolve.
|
||||
// (d) is a missing static asset (has extension, file
|
||||
// gone — e.g. /assets/index-<stalehash>.js after a
|
||||
// redeploy). Return 404 with no body — DO NOT fall
|
||||
// through to controllers. Serving JSON {} causes the
|
||||
// browser <script> loader to reject with "disallowed
|
||||
// MIME type"; 404 makes the browser drop its stale
|
||||
// index.html cache and refetch on the next load.
|
||||
branch.Use(async (ctx, next) =>
|
||||
{
|
||||
if (ctx.Response.HasStarted) return;
|
||||
|
||||
var path = ctx.Request.Path.Value ?? "/";
|
||||
|
||||
if (path.StartsWith("/api/", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
await next();
|
||||
return;
|
||||
}
|
||||
|
||||
if (ctx.Request.Method != "GET" && ctx.Request.Method != "HEAD")
|
||||
{
|
||||
await next();
|
||||
return;
|
||||
}
|
||||
|
||||
if (Path.HasExtension(path))
|
||||
{
|
||||
ctx.Response.StatusCode = StatusCodes.Status404NotFound;
|
||||
return;
|
||||
}
|
||||
|
||||
if (!spaFallback)
|
||||
{
|
||||
ctx.Response.StatusCode = StatusCodes.Status404NotFound;
|
||||
return;
|
||||
}
|
||||
|
||||
var index = files.GetFileInfo("/index.html");
|
||||
if (!index.Exists)
|
||||
{
|
||||
ctx.Response.StatusCode = StatusCodes.Status404NotFound;
|
||||
return;
|
||||
}
|
||||
|
||||
ctx.Response.ContentType = "text/html; charset=utf-8";
|
||||
var hdrs = ctx.Response.Headers;
|
||||
hdrs.CacheControl = "no-cache, no-store, must-revalidate";
|
||||
hdrs.Pragma = "no-cache";
|
||||
hdrs.Expires = "0";
|
||||
await using var stream = index.CreateReadStream();
|
||||
await stream.CopyToAsync(ctx.Response.Body);
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user