mirror of
https://github.com/DorkSquadRR/DorkNet.git
synced 2026-10-26 07:28:45 -07:00
Modernize March server startup
This commit is contained in:
@@ -0,0 +1,349 @@
|
||||
using System.Data;
|
||||
using System.Data.Common;
|
||||
using DorkNet.Server.Controllers.Health;
|
||||
using DorkNet.Server.Data;
|
||||
using DorkNet.Server.Services;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Serilog;
|
||||
|
||||
namespace DorkNet.Server.Startup;
|
||||
|
||||
/// <summary>Boot-time schema repair, seed data, and health readiness.</summary>
|
||||
public static class DatabaseBootstrap
|
||||
{
|
||||
public static async Task RunDatabaseBootstrapAsync(this WebApplication app)
|
||||
{
|
||||
using var scope = app.Services.CreateScope();
|
||||
var db = scope.ServiceProvider.GetRequiredService<DorkNetDbContext>();
|
||||
|
||||
if (db.Database.IsSqlite())
|
||||
{
|
||||
db.Database.EnsureCreated();
|
||||
ApplySqliteCompatibilityPatches(db);
|
||||
await EnsureSqliteInviteTablesAsync(db);
|
||||
}
|
||||
else
|
||||
{
|
||||
await BootstrapPostgresAsync(app, db);
|
||||
}
|
||||
|
||||
var playerService = scope.ServiceProvider.GetRequiredService<PlayerService>();
|
||||
await playerService.EnsureSystemAccountAsync();
|
||||
await playerService.EnsureAvatarSeedAsync();
|
||||
await playerService.BackfillStarterWalletsAsync();
|
||||
|
||||
var roomService = scope.ServiceProvider.GetRequiredService<RoomService>();
|
||||
await roomService.EnsureDormsForAllPlayersAsync();
|
||||
await roomService.SeedAsync();
|
||||
|
||||
using (var http = new HttpClient { Timeout = TimeSpan.FromSeconds(10) })
|
||||
{
|
||||
var imagesDir = Path.Combine(AppContext.BaseDirectory, "data", "images");
|
||||
await roomService.ApplyCanonicalOverridesAsync(http, imagesDir);
|
||||
}
|
||||
|
||||
var storeService = scope.ServiceProvider.GetRequiredService<StoreService>();
|
||||
await storeService.SeedAsync();
|
||||
|
||||
HealthController.MigrationsComplete = true;
|
||||
}
|
||||
|
||||
private static async Task EnsureSqliteInviteTablesAsync(DorkNetDbContext db)
|
||||
{
|
||||
await db.Database.ExecuteSqlRawAsync(
|
||||
@"CREATE TABLE IF NOT EXISTS ""SignupCodes"" (
|
||||
""Id"" INTEGER NOT NULL CONSTRAINT ""PK_SignupCodes"" PRIMARY KEY AUTOINCREMENT,
|
||||
""Code"" TEXT NOT NULL,
|
||||
""Descriptor"" TEXT NOT NULL DEFAULT '',
|
||||
""CreatedByPlayerId"" INTEGER NOT NULL DEFAULT 0,
|
||||
""CreatedAt"" TEXT NOT NULL,
|
||||
""ExpiresAt"" TEXT NULL,
|
||||
""RedeemedByPlayerId"" INTEGER NULL,
|
||||
""RedeemedAt"" TEXT NULL,
|
||||
""Revoked"" INTEGER NOT NULL DEFAULT 0
|
||||
);");
|
||||
await db.Database.ExecuteSqlRawAsync(
|
||||
@"CREATE UNIQUE INDEX IF NOT EXISTS ""IX_SignupCodes_Code"" ON ""SignupCodes"" (""Code"");");
|
||||
await db.Database.ExecuteSqlRawAsync(
|
||||
@"CREATE TABLE IF NOT EXISTS ""PendingDevices"" (
|
||||
""Id"" INTEGER NOT NULL CONSTRAINT ""PK_PendingDevices"" PRIMARY KEY AUTOINCREMENT,
|
||||
""DeviceId"" TEXT NOT NULL,
|
||||
""Platform"" INTEGER NOT NULL DEFAULT 0,
|
||||
""PlatformId"" TEXT NOT NULL DEFAULT '',
|
||||
""LastIp"" TEXT NULL,
|
||||
""FirstSeenAt"" TEXT NOT NULL,
|
||||
""LastSeenAt"" TEXT NOT NULL
|
||||
);");
|
||||
await db.Database.ExecuteSqlRawAsync(
|
||||
@"CREATE UNIQUE INDEX IF NOT EXISTS ""IX_PendingDevices_DeviceId"" ON ""PendingDevices"" (""DeviceId"");");
|
||||
}
|
||||
|
||||
private static async Task BootstrapPostgresAsync(WebApplication app, DorkNetDbContext db)
|
||||
{
|
||||
var conn = db.Database.GetDbConnection();
|
||||
var connectLogger = app.Services.GetRequiredService<ILoggerFactory>()
|
||||
.CreateLogger("DorkNet.Server.Bootstrap");
|
||||
|
||||
for (var attempt = 1; ; attempt++)
|
||||
{
|
||||
try { await conn.OpenAsync(); break; }
|
||||
catch (Exception ex) when (attempt < 15 &&
|
||||
(ex is System.Net.Sockets.SocketException
|
||||
|| ex is Npgsql.NpgsqlException
|
||||
|| ex is DbException))
|
||||
{
|
||||
var delay = TimeSpan.FromSeconds(Math.Min(attempt, 5));
|
||||
connectLogger.LogWarning(
|
||||
"[bootstrap] Postgres connect attempt {Attempt}/15 failed ({Type}: {Message}); retrying in {Delay}s",
|
||||
attempt, ex.GetType().Name, ex.Message, delay.TotalSeconds);
|
||||
await Task.Delay(delay);
|
||||
}
|
||||
}
|
||||
|
||||
await using var tx = await conn.BeginTransactionAsync();
|
||||
await using (var lockCmd = conn.CreateCommand())
|
||||
{
|
||||
lockCmd.Transaction = tx;
|
||||
lockCmd.CommandText = "SELECT pg_advisory_xact_lock(1146246987);";
|
||||
await lockCmd.ExecuteNonQueryAsync();
|
||||
}
|
||||
db.Database.EnsureCreated();
|
||||
await tx.CommitAsync();
|
||||
|
||||
var bootstrapLogger = app.Services.GetRequiredService<ILoggerFactory>()
|
||||
.CreateLogger("DorkNet.Server.Bootstrap");
|
||||
|
||||
await RunPostgresPatchAsync(conn, bootstrapLogger, "RoomDataBlobs.Bytes nullable",
|
||||
@"ALTER TABLE ""RoomDataBlobs"" ALTER COLUMN ""Bytes"" DROP NOT NULL;");
|
||||
|
||||
await RunPostgresPatchAsync(conn, bootstrapLogger, "ChatThreads table",
|
||||
@"CREATE TABLE IF NOT EXISTS ""ChatThreads"" (
|
||||
""Id"" bigserial PRIMARY KEY,
|
||||
""ThreadKey"" varchar(96) NOT NULL,
|
||||
""Name"" varchar(128) NOT NULL DEFAULT '',
|
||||
""CreatorPlayerId"" bigint NOT NULL,
|
||||
""CreatedAt"" timestamp with time zone NOT NULL DEFAULT now(),
|
||||
""UpdatedAt"" timestamp with time zone NOT NULL DEFAULT now()
|
||||
);");
|
||||
await RunPostgresPatchAsync(conn, bootstrapLogger, "ChatThreads ThreadKey unique index",
|
||||
@"CREATE UNIQUE INDEX IF NOT EXISTS ""IX_ChatThreads_ThreadKey""
|
||||
ON ""ChatThreads"" (""ThreadKey"");");
|
||||
await RunPostgresPatchAsync(conn, bootstrapLogger, "ChatThreadMembers table",
|
||||
@"CREATE TABLE IF NOT EXISTS ""ChatThreadMembers"" (
|
||||
""Id"" bigserial PRIMARY KEY,
|
||||
""ThreadKey"" text NOT NULL,
|
||||
""PlayerId"" bigint NOT NULL,
|
||||
""JoinedAt"" timestamp with time zone NOT NULL DEFAULT now(),
|
||||
""SnoozeUntil"" timestamp with time zone NULL,
|
||||
""LastReadMessageId"" bigint NULL
|
||||
);");
|
||||
await RunPostgresPatchAsync(conn, bootstrapLogger, "ChatThreadMembers (ThreadKey, PlayerId) unique index",
|
||||
@"CREATE UNIQUE INDEX IF NOT EXISTS ""IX_ChatThreadMembers_ThreadKey_PlayerId""
|
||||
ON ""ChatThreadMembers"" (""ThreadKey"", ""PlayerId"");");
|
||||
await RunPostgresPatchAsync(conn, bootstrapLogger, "ChatThreadMembers PlayerId index",
|
||||
@"CREATE INDEX IF NOT EXISTS ""IX_ChatThreadMembers_PlayerId""
|
||||
ON ""ChatThreadMembers"" (""PlayerId"");");
|
||||
|
||||
await RunPostgresPatchAsync(conn, bootstrapLogger, "Relationships.Favorited column",
|
||||
@"ALTER TABLE ""Relationships""
|
||||
ADD COLUMN IF NOT EXISTS ""Favorited"" boolean NOT NULL DEFAULT false;");
|
||||
await RunPostgresPatchAsync(conn, bootstrapLogger, "Relationships.Muted column",
|
||||
@"ALTER TABLE ""Relationships""
|
||||
ADD COLUMN IF NOT EXISTS ""Muted"" boolean NOT NULL DEFAULT false;");
|
||||
await RunPostgresPatchAsync(conn, bootstrapLogger, "Relationships.Ignored column",
|
||||
@"ALTER TABLE ""Relationships""
|
||||
ADD COLUMN IF NOT EXISTS ""Ignored"" boolean NOT NULL DEFAULT false;");
|
||||
|
||||
await RunPostgresPatchAsync(conn, bootstrapLogger, "StoreItems.Slug widen to 128",
|
||||
@"ALTER TABLE ""StoreItems"" ALTER COLUMN ""Slug"" TYPE varchar(128);");
|
||||
|
||||
await RunPostgresPatchAsync(conn, bootstrapLogger, "Messages.Type column",
|
||||
@"ALTER TABLE ""Messages""
|
||||
ADD COLUMN IF NOT EXISTS ""Type"" integer NOT NULL DEFAULT 30;");
|
||||
await RunPostgresPatchAsync(conn, bootstrapLogger, "Messages.RoomId column",
|
||||
@"ALTER TABLE ""Messages""
|
||||
ADD COLUMN IF NOT EXISTS ""RoomId"" bigint NULL;");
|
||||
|
||||
await RunPostgresPatchAsync(conn, bootstrapLogger, "PrivateInstanceInvitees.LatestInviteMessageId column",
|
||||
@"ALTER TABLE ""PrivateInstanceInvitees""
|
||||
ADD COLUMN IF NOT EXISTS ""LatestInviteMessageId"" bigint NULL;");
|
||||
|
||||
var currentRegion = (app.Configuration["Photon:CloudRegion"] ?? "us").ToLowerInvariant().Replace("'", "''");
|
||||
await RunPostgresPatchAsync(conn, bootstrapLogger, $"PrivateInstances.PhotonRegion -> {currentRegion}",
|
||||
$@"UPDATE ""PrivateInstances""
|
||||
SET ""PhotonRegion"" = '{currentRegion}'
|
||||
WHERE ""PhotonRegion"" IS DISTINCT FROM '{currentRegion}';");
|
||||
|
||||
await RunPostgresPatchAsync(conn, bootstrapLogger, "CommunityBoardRows table",
|
||||
@"CREATE TABLE IF NOT EXISTS ""CommunityBoardRows"" (
|
||||
""Id"" integer PRIMARY KEY,
|
||||
""Json"" text NOT NULL DEFAULT '{}',
|
||||
""UpdatedAt"" timestamp with time zone NOT NULL DEFAULT now()
|
||||
);");
|
||||
|
||||
await RunPostgresPatchAsync(conn, bootstrapLogger, "ServerSettings table",
|
||||
@"CREATE TABLE IF NOT EXISTS ""ServerSettings"" (
|
||||
""Id"" integer PRIMARY KEY,
|
||||
""SignupsDisabled"" boolean NOT NULL DEFAULT false,
|
||||
""UpdatedAt"" timestamp with time zone NOT NULL DEFAULT now()
|
||||
);");
|
||||
|
||||
await RunPostgresPatchAsync(conn, bootstrapLogger, "ServerSettings.WeeklyChallengesCompletedRequired column",
|
||||
@"ALTER TABLE ""ServerSettings""
|
||||
ADD COLUMN IF NOT EXISTS ""WeeklyChallengesCompletedRequired"" boolean NOT NULL DEFAULT true;");
|
||||
await RunPostgresPatchAsync(conn, bootstrapLogger, "ServerSettings.WeeklyChallengesJson column",
|
||||
@"ALTER TABLE ""ServerSettings""
|
||||
ADD COLUMN IF NOT EXISTS ""WeeklyChallengesJson"" text NOT NULL DEFAULT '';");
|
||||
await RunPostgresPatchAsync(conn, bootstrapLogger, "ServerSettings.WeeklyChallengeRewardJson column",
|
||||
@"ALTER TABLE ""ServerSettings""
|
||||
ADD COLUMN IF NOT EXISTS ""WeeklyChallengeRewardJson"" text NOT NULL DEFAULT '';");
|
||||
await RunPostgresPatchAsync(conn, bootstrapLogger, "ServerSettings.GlobalFriendsEnabled column",
|
||||
@"ALTER TABLE ""ServerSettings""
|
||||
ADD COLUMN IF NOT EXISTS ""GlobalFriendsEnabled"" boolean NOT NULL DEFAULT false;");
|
||||
|
||||
await RunPostgresPatchAsync(conn, bootstrapLogger, "Players.IsCommunityTeam column",
|
||||
@"ALTER TABLE ""Players""
|
||||
ADD COLUMN IF NOT EXISTS ""IsCommunityTeam"" boolean NOT NULL DEFAULT false;");
|
||||
|
||||
await RunPostgresPatchAsync(conn, bootstrapLogger, "Rooms.HiddenFromBrowse column",
|
||||
@"ALTER TABLE ""Rooms""
|
||||
ADD COLUMN IF NOT EXISTS ""HiddenFromBrowse"" boolean NOT NULL DEFAULT false;");
|
||||
|
||||
await RunPostgresPatchAsync(conn, bootstrapLogger, "RoomRoles table",
|
||||
@"CREATE TABLE IF NOT EXISTS ""RoomRoles"" (
|
||||
""Id"" bigserial PRIMARY KEY,
|
||||
""RoomId"" bigint NOT NULL,
|
||||
""PlayerId"" bigint NOT NULL,
|
||||
""Role"" integer NOT NULL,
|
||||
""Accepted"" boolean NOT NULL DEFAULT true,
|
||||
""GrantedByPlayerId"" bigint NULL,
|
||||
""GrantedAt"" timestamp with time zone NOT NULL DEFAULT now()
|
||||
);");
|
||||
await RunPostgresPatchAsync(conn, bootstrapLogger, "RoomRoles (RoomId, PlayerId, Role) unique index",
|
||||
@"CREATE UNIQUE INDEX IF NOT EXISTS ""IX_RoomRoles_RoomId_PlayerId_Role""
|
||||
ON ""RoomRoles"" (""RoomId"", ""PlayerId"", ""Role"");");
|
||||
await RunPostgresPatchAsync(conn, bootstrapLogger, "RoomRoles RoomId index",
|
||||
@"CREATE INDEX IF NOT EXISTS ""IX_RoomRoles_RoomId"" ON ""RoomRoles"" (""RoomId"");");
|
||||
|
||||
await RunPostgresPatchAsync(conn, bootstrapLogger, "LeaderboardChannelMeta table",
|
||||
@"CREATE TABLE IF NOT EXISTS ""LeaderboardChannelMeta"" (
|
||||
""Channel"" integer PRIMARY KEY,
|
||||
""RoomId"" bigint NOT NULL DEFAULT 0,
|
||||
""Name"" varchar(128) NOT NULL DEFAULT '',
|
||||
""LowerIsBetter"" boolean NOT NULL DEFAULT false,
|
||||
""ValueFormat"" varchar(32) NOT NULL DEFAULT 'count',
|
||||
""CreatedAt"" timestamp with time zone NOT NULL DEFAULT now(),
|
||||
""UpdatedAt"" timestamp with time zone NOT NULL DEFAULT now()
|
||||
);");
|
||||
await RunPostgresPatchAsync(conn, bootstrapLogger, "LeaderboardChannelMeta RoomId index",
|
||||
@"CREATE INDEX IF NOT EXISTS ""IX_LeaderboardChannelMeta_RoomId""
|
||||
ON ""LeaderboardChannelMeta"" (""RoomId"");");
|
||||
|
||||
await RunPostgresPatchAsync(conn, bootstrapLogger, "LoadingScreenTips table",
|
||||
@"CREATE TABLE IF NOT EXISTS ""LoadingScreenTips"" (
|
||||
""Id"" bigserial PRIMARY KEY,
|
||||
""Title"" varchar(128) NOT NULL DEFAULT '',
|
||||
""Message"" varchar(512) NOT NULL DEFAULT '',
|
||||
""ImageName"" varchar(128) NOT NULL DEFAULT '',
|
||||
""Context"" int NOT NULL DEFAULT 0,
|
||||
""PlatformMask"" int NOT NULL DEFAULT -1,
|
||||
""RoomNamesCsv"" varchar(512) NOT NULL DEFAULT '',
|
||||
""SortOrder"" int NOT NULL DEFAULT 0,
|
||||
""IsActive"" boolean NOT NULL DEFAULT true,
|
||||
""CreatedAt"" timestamp with time zone NOT NULL DEFAULT now(),
|
||||
""UpdatedAt"" timestamp with time zone NOT NULL DEFAULT now()
|
||||
);");
|
||||
|
||||
await RunPostgresPatchAsync(conn, bootstrapLogger, "SignupCodes table",
|
||||
@"CREATE TABLE IF NOT EXISTS ""SignupCodes"" (
|
||||
""Id"" bigserial PRIMARY KEY,
|
||||
""Code"" text NOT NULL,
|
||||
""Descriptor"" text NOT NULL DEFAULT '',
|
||||
""CreatedByPlayerId"" bigint NOT NULL DEFAULT 0,
|
||||
""CreatedAt"" timestamp with time zone NOT NULL DEFAULT now(),
|
||||
""ExpiresAt"" timestamp with time zone NULL,
|
||||
""RedeemedByPlayerId"" bigint NULL,
|
||||
""RedeemedAt"" timestamp with time zone NULL,
|
||||
""Revoked"" boolean NOT NULL DEFAULT false
|
||||
);");
|
||||
await RunPostgresPatchAsync(conn, bootstrapLogger, "SignupCodes Code unique index",
|
||||
@"CREATE UNIQUE INDEX IF NOT EXISTS ""IX_SignupCodes_Code"" ON ""SignupCodes"" (""Code"");");
|
||||
await RunPostgresPatchAsync(conn, bootstrapLogger, "PendingDevices table",
|
||||
@"CREATE TABLE IF NOT EXISTS ""PendingDevices"" (
|
||||
""Id"" bigserial PRIMARY KEY,
|
||||
""DeviceId"" text NOT NULL,
|
||||
""Platform"" integer NOT NULL DEFAULT 0,
|
||||
""PlatformId"" text NOT NULL DEFAULT '',
|
||||
""LastIp"" text NULL,
|
||||
""FirstSeenAt"" timestamp with time zone NOT NULL DEFAULT now(),
|
||||
""LastSeenAt"" timestamp with time zone NOT NULL DEFAULT now()
|
||||
);");
|
||||
await RunPostgresPatchAsync(conn, bootstrapLogger, "PendingDevices DeviceId unique index",
|
||||
@"CREATE UNIQUE INDEX IF NOT EXISTS ""IX_PendingDevices_DeviceId"" ON ""PendingDevices"" (""DeviceId"");");
|
||||
|
||||
await conn.CloseAsync();
|
||||
}
|
||||
|
||||
private static async Task RunPostgresPatchAsync(DbConnection conn, Microsoft.Extensions.Logging.ILogger logger, string label, string sql)
|
||||
{
|
||||
await using var cmd = conn.CreateCommand();
|
||||
cmd.CommandText = sql;
|
||||
try { await cmd.ExecuteNonQueryAsync(); }
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogWarning(ex, "[schema-patch] {Label} failed", label);
|
||||
}
|
||||
}
|
||||
|
||||
private static void ApplySqliteCompatibilityPatches(DorkNetDbContext db)
|
||||
{
|
||||
if (!db.Database.IsSqlite()) return;
|
||||
AddSqliteColumnIfMissing(db, "Rooms", "HiddenFromBrowse",
|
||||
@"""HiddenFromBrowse"" INTEGER NOT NULL DEFAULT 0");
|
||||
AddSqliteColumnIfMissing(db, "ServerSettings", "WeeklyChallengesCompletedRequired",
|
||||
@"""WeeklyChallengesCompletedRequired"" INTEGER NOT NULL DEFAULT 1");
|
||||
AddSqliteColumnIfMissing(db, "ServerSettings", "WeeklyChallengesJson",
|
||||
@"""WeeklyChallengesJson"" TEXT NOT NULL DEFAULT ''");
|
||||
AddSqliteColumnIfMissing(db, "ServerSettings", "WeeklyChallengeRewardJson",
|
||||
@"""WeeklyChallengeRewardJson"" TEXT NOT NULL DEFAULT ''");
|
||||
AddSqliteColumnIfMissing(db, "ServerSettings", "GlobalFriendsEnabled",
|
||||
@"""GlobalFriendsEnabled"" INTEGER NOT NULL DEFAULT 0");
|
||||
}
|
||||
|
||||
private static void AddSqliteColumnIfMissing(DorkNetDbContext db, string table, string column, string definition)
|
||||
{
|
||||
var conn = db.Database.GetDbConnection();
|
||||
var shouldClose = conn.State == ConnectionState.Closed;
|
||||
if (shouldClose) conn.Open();
|
||||
try
|
||||
{
|
||||
var exists = false;
|
||||
using var check = conn.CreateCommand();
|
||||
check.CommandText = $"PRAGMA table_info(\"{table.Replace("\"", "\"\"")}\");";
|
||||
using (var reader = check.ExecuteReader())
|
||||
{
|
||||
while (reader.Read())
|
||||
{
|
||||
if (!string.Equals(reader.GetString(1), column, StringComparison.OrdinalIgnoreCase)) continue;
|
||||
exists = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (exists)
|
||||
{
|
||||
Log.Information("[sqlite-compat] {Table}.{Column} already exists", table, column);
|
||||
return;
|
||||
}
|
||||
|
||||
using var alter = conn.CreateCommand();
|
||||
alter.CommandText = $"ALTER TABLE \"{table.Replace("\"", "\"\"")}\" ADD COLUMN {definition};";
|
||||
alter.ExecuteNonQuery();
|
||||
Log.Information("[sqlite-compat] Added {Table}.{Column}", table, column);
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (shouldClose) conn.Close();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
using DorkNet.Server.Auth;
|
||||
using DorkNet.Server.Hubs;
|
||||
using DorkNet.Server.Services;
|
||||
using Microsoft.AspNetCore.Http.Connections;
|
||||
|
||||
namespace DorkNet.Server.Startup;
|
||||
|
||||
/// <summary>Assembles the DorkNet middleware pipeline in runtime order.</summary>
|
||||
public static class MiddlewarePipelineExtensions
|
||||
{
|
||||
public static void UseDorkNetPipeline(this WebApplication app)
|
||||
{
|
||||
var domainCfg = app.Services.GetRequiredService<DomainConfig>();
|
||||
|
||||
// Easy Launcher single-origin mode. localtunnel gives us one public
|
||||
// hostname, so /__dn/{service}/... is mapped to the internal subdomain
|
||||
// host shape before endpoint selection runs.
|
||||
app.Use(async (ctx, next) =>
|
||||
{
|
||||
if (domainCfg.SingleOriginEnabled &&
|
||||
TryMapSingleOriginPath(ctx.Request.Path, out var service, out var rest))
|
||||
{
|
||||
ctx.Request.Host = new HostString(service == "www" ? domainCfg.Apex : domainCfg.Sub(service));
|
||||
ctx.Request.Path = string.IsNullOrEmpty(rest) ? "/" : rest;
|
||||
}
|
||||
|
||||
await next();
|
||||
});
|
||||
|
||||
app.UseRouting();
|
||||
|
||||
if (app.Environment.IsDevelopment())
|
||||
{
|
||||
app.UseSwagger();
|
||||
app.UseSwaggerUI();
|
||||
}
|
||||
|
||||
app.UseHttpsRedirection();
|
||||
|
||||
app.UseWebSockets(new Microsoft.AspNetCore.Builder.WebSocketOptions
|
||||
{
|
||||
KeepAliveInterval = TimeSpan.FromSeconds(30),
|
||||
});
|
||||
|
||||
app.MountStaticHost(domainCfg.Sub("admin"), "admin", spaFallback: true);
|
||||
app.MountStaticHost(domainCfg.Apex, "site", spaFallback: true);
|
||||
app.MountStaticHost($"www.{domainCfg.Apex}", "site", spaFallback: true);
|
||||
app.MountStaticHost(domainCfg.Sub("feed"), "feed");
|
||||
|
||||
app.UseRequestTracing();
|
||||
|
||||
app.Use(async (ctx, next) =>
|
||||
{
|
||||
if (ctx.Request.Host.Host.Equals(domainCfg.Sub("api"), StringComparison.OrdinalIgnoreCase) &&
|
||||
(ctx.Request.Path == "/admin" || ctx.Request.Path.StartsWithSegments("/admin")))
|
||||
{
|
||||
ctx.Response.Redirect($"https://{domainCfg.Sub("admin")}/", permanent: false);
|
||||
return;
|
||||
}
|
||||
await next();
|
||||
});
|
||||
|
||||
app.UseMiddleware<IpBanCheckMiddleware>();
|
||||
|
||||
app.UseAuthentication();
|
||||
app.UseAuthorization();
|
||||
app.UseMiddleware<BanCheckMiddleware>();
|
||||
app.MapControllers();
|
||||
|
||||
app.MapHub<NotifyHub>("/hub/v1", opts =>
|
||||
{
|
||||
opts.Transports = HttpTransportType.WebSockets;
|
||||
opts.TransportSendTimeout = TimeSpan.FromSeconds(30);
|
||||
}).RequireHost(domainCfg.Sub("notify"));
|
||||
}
|
||||
|
||||
private static bool TryMapSingleOriginPath(PathString path, out string service, out PathString rest)
|
||||
{
|
||||
service = "";
|
||||
rest = PathString.Empty;
|
||||
if (!path.StartsWithSegments("/__dn", out var tail)) return false;
|
||||
|
||||
var value = tail.Value ?? "";
|
||||
if (value.Length <= 1) return false;
|
||||
var slash = value.IndexOf('/', 1);
|
||||
service = slash < 0 ? value[1..] : value[1..slash];
|
||||
if (string.IsNullOrWhiteSpace(service)) return false;
|
||||
|
||||
rest = slash < 0 ? "/" : value[slash..];
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,193 @@
|
||||
using System.Text.RegularExpressions;
|
||||
using DorkNet.Server.Auth;
|
||||
using DorkNet.Server.Services;
|
||||
|
||||
namespace DorkNet.Server.Startup;
|
||||
|
||||
/// <summary>Per-request observability middleware for HTTP traces and player logs.</summary>
|
||||
public static class RequestTracingExtensions
|
||||
{
|
||||
public static void UseRequestTracing(this WebApplication app)
|
||||
{
|
||||
var reqLogger = app.Services.GetRequiredService<ILoggerFactory>()
|
||||
.CreateLogger("DorkNet.Server.HttpTrace");
|
||||
var playerLog = app.Services.GetRequiredService<PlayerLogService>();
|
||||
|
||||
app.Use(async (ctx, next) =>
|
||||
{
|
||||
if (IsHealthProbe(ctx.Request))
|
||||
{
|
||||
await next();
|
||||
return;
|
||||
}
|
||||
|
||||
if (ctx.WebSockets.IsWebSocketRequest)
|
||||
{
|
||||
reqLogger.LogInformation(
|
||||
"[ws] {Method} {Host}{Path}{Query}",
|
||||
ctx.Request.Method, ctx.Request.Host, ctx.Request.Path,
|
||||
ctx.Request.QueryString.Value ?? "");
|
||||
await next();
|
||||
return;
|
||||
}
|
||||
|
||||
var host = ctx.Request.Host.Host;
|
||||
var path = ctx.Request.Path.Value ?? "";
|
||||
var isStorageLike = host.StartsWith("storage.", StringComparison.OrdinalIgnoreCase)
|
||||
|| host.StartsWith("cdn.", StringComparison.OrdinalIgnoreCase)
|
||||
|| path.Contains("upload", StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
if (isStorageLike)
|
||||
{
|
||||
reqLogger.LogInformation(
|
||||
"[req:start] {Method} {Host}{Path}{Query} contentType={ContentType} contentLength={ContentLength}",
|
||||
ctx.Request.Method, ctx.Request.Host, ctx.Request.Path,
|
||||
ctx.Request.QueryString.Value ?? "", ctx.Request.ContentType ?? "",
|
||||
ctx.Request.ContentLength);
|
||||
}
|
||||
|
||||
var sw = System.Diagnostics.Stopwatch.StartNew();
|
||||
var originalBody = ctx.Response.Body;
|
||||
var captureResponse = ShouldCaptureResponse(ctx.Request);
|
||||
MemoryStream? capture = null;
|
||||
if (captureResponse)
|
||||
{
|
||||
capture = new MemoryStream();
|
||||
ctx.Response.Body = capture;
|
||||
}
|
||||
|
||||
string? requestBody = null;
|
||||
if (ctx.Request.ContentLength is > 0 and < 4096 && IsTextLikeRequest(ctx.Request.ContentType))
|
||||
{
|
||||
ctx.Request.EnableBuffering();
|
||||
using var reader = new StreamReader(ctx.Request.Body, leaveOpen: true);
|
||||
requestBody = await reader.ReadToEndAsync();
|
||||
ctx.Request.Body.Position = 0;
|
||||
}
|
||||
|
||||
Exception? thrown = null;
|
||||
try
|
||||
{
|
||||
await next();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
thrown = ex;
|
||||
throw;
|
||||
}
|
||||
finally
|
||||
{
|
||||
sw.Stop();
|
||||
var status = thrown is null ? ctx.Response.StatusCode : 500;
|
||||
var responseBody = "";
|
||||
|
||||
if (capture is not null)
|
||||
{
|
||||
try
|
||||
{
|
||||
capture.Seek(0, SeekOrigin.Begin);
|
||||
if (ShouldReadResponseBody(ctx.Response.ContentType, status))
|
||||
responseBody = await new StreamReader(capture, leaveOpen: true).ReadToEndAsync();
|
||||
capture.Seek(0, SeekOrigin.Begin);
|
||||
await capture.CopyToAsync(originalBody);
|
||||
}
|
||||
finally
|
||||
{
|
||||
ctx.Response.Body = originalBody;
|
||||
await capture.DisposeAsync();
|
||||
}
|
||||
}
|
||||
|
||||
var safeRequest = RedactSecrets(requestBody);
|
||||
var safeResponse = RedactSecrets(responseBody) ?? "";
|
||||
var trimmedRequest = safeRequest is null ? null
|
||||
: (safeRequest.Length > 200 ? safeRequest[..200] + "..." : safeRequest);
|
||||
var trimmedResponse = safeResponse.Length > 300 ? safeResponse[..300] + "..." : safeResponse;
|
||||
var level = thrown is not null || status >= 500 ? LogLevel.Error
|
||||
: status >= 400 ? LogLevel.Warning
|
||||
: LogLevel.Information;
|
||||
|
||||
reqLogger.Log(level, thrown,
|
||||
"[req] {Status} {Method} {Host}{Path}{Query} {ElapsedMs}ms contentType={ContentType} contentLength={ContentLength} req={ReqBody} resp={RespBody}",
|
||||
status, ctx.Request.Method, ctx.Request.Host, ctx.Request.Path,
|
||||
ctx.Request.QueryString.Value ?? "", sw.ElapsedMilliseconds,
|
||||
ctx.Request.ContentType ?? "", ctx.Request.ContentLength,
|
||||
trimmedRequest ?? "", trimmedResponse);
|
||||
|
||||
var pid = ControllerBaseExtensions.CurrentPlayerId(ctx.User);
|
||||
if (pid is long playerId)
|
||||
{
|
||||
playerLog.Record(new PlayerLogEntry
|
||||
{
|
||||
Timestamp = DateTime.UtcNow,
|
||||
PlayerId = playerId,
|
||||
Method = ctx.Request.Method,
|
||||
Host = ctx.Request.Host.Host,
|
||||
Path = ctx.Request.Path.Value ?? string.Empty,
|
||||
Query = ctx.Request.QueryString.Value ?? string.Empty,
|
||||
Status = status,
|
||||
ElapsedMs = sw.ElapsedMilliseconds,
|
||||
ReqBody = trimmedRequest,
|
||||
RespBody = trimmedResponse,
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private const string SecretKeys =
|
||||
"password|passwd|pwd|passwordhash|token|accesstoken|refreshtoken|idtoken|" +
|
||||
"secret|clientsecret|apikey|api_key|authorization|deviceauth|sessionkey";
|
||||
|
||||
private static readonly Regex JsonSecretRegex = new(
|
||||
"(\"(?:" + SecretKeys + ")\"\\s*:\\s*)\"(?:[^\"\\\\]|\\\\.)*\"",
|
||||
RegexOptions.IgnoreCase | RegexOptions.Compiled);
|
||||
|
||||
private static readonly Regex FormSecretRegex = new(
|
||||
"(?<=^|&)(?<k>" + SecretKeys + ")=[^&]*",
|
||||
RegexOptions.IgnoreCase | RegexOptions.Compiled);
|
||||
|
||||
private static string? RedactSecrets(string? body)
|
||||
{
|
||||
if (string.IsNullOrEmpty(body)) return body;
|
||||
if (body.IndexOf('"') < 0 && body.IndexOf('=') < 0) return body;
|
||||
var redacted = JsonSecretRegex.Replace(body, "$1\"***\"");
|
||||
redacted = FormSecretRegex.Replace(redacted, "${k}=***");
|
||||
return redacted;
|
||||
}
|
||||
|
||||
private static bool IsHealthProbe(HttpRequest request)
|
||||
=> request.Path.Equals("/healthz", StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
private static bool IsTextLikeRequest(string? contentType)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(contentType)) return false;
|
||||
return contentType.StartsWith("application/json", StringComparison.OrdinalIgnoreCase)
|
||||
|| contentType.StartsWith("application/x-www-form-urlencoded", StringComparison.OrdinalIgnoreCase)
|
||||
|| contentType.StartsWith("text/", StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
private static bool ShouldCaptureResponse(HttpRequest request)
|
||||
{
|
||||
var host = request.Host.Host;
|
||||
if (host.StartsWith("cdn.", StringComparison.OrdinalIgnoreCase))
|
||||
return false;
|
||||
|
||||
var path = request.Path.Value ?? "";
|
||||
var ext = Path.GetExtension(path);
|
||||
if (string.IsNullOrEmpty(ext))
|
||||
return true;
|
||||
|
||||
return ext.Equals(".json", StringComparison.OrdinalIgnoreCase)
|
||||
|| ext.Equals(".txt", StringComparison.OrdinalIgnoreCase)
|
||||
|| ext.Equals(".log", StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
private static bool ShouldReadResponseBody(string? contentType, int status)
|
||||
{
|
||||
if (status < 400) return false;
|
||||
if (string.IsNullOrWhiteSpace(contentType)) return true;
|
||||
return contentType.StartsWith("application/json", StringComparison.OrdinalIgnoreCase)
|
||||
|| contentType.StartsWith("text/", StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,227 @@
|
||||
using System.Text;
|
||||
using DorkNet.Server.Data;
|
||||
using DorkNet.Server.Services;
|
||||
using Microsoft.AspNetCore.Authentication.JwtBearer;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.IdentityModel.Tokens;
|
||||
using Serilog;
|
||||
using Serilog.Formatting.Compact;
|
||||
using StackExchange.Redis;
|
||||
|
||||
namespace DorkNet.Server.Startup;
|
||||
|
||||
/// <summary>Central DI and host configuration for the DorkNet server.</summary>
|
||||
public static class ServiceCollectionExtensions
|
||||
{
|
||||
public static void AddDorkNetServices(this WebApplicationBuilder builder)
|
||||
{
|
||||
ConfigureLogging(builder);
|
||||
AddDatabase(builder);
|
||||
AddDomain(builder);
|
||||
AddCoreServices(builder.Services);
|
||||
var redisConn = AddRedis(builder);
|
||||
AddAuthentication(builder);
|
||||
AddKestrelAndSignalR(builder, redisConn);
|
||||
builder.Services.AddEndpointsApiExplorer();
|
||||
builder.Services.AddSwaggerGen();
|
||||
}
|
||||
|
||||
private static void ConfigureLogging(WebApplicationBuilder builder)
|
||||
{
|
||||
builder.Host.UseSerilog((ctx, services, cfg) =>
|
||||
{
|
||||
cfg.ReadFrom.Configuration(ctx.Configuration)
|
||||
.ReadFrom.Services(services)
|
||||
.Enrich.FromLogContext();
|
||||
|
||||
if (ctx.HostingEnvironment.IsDevelopment())
|
||||
cfg.WriteTo.Console();
|
||||
else
|
||||
cfg.WriteTo.Console(new RenderedCompactJsonFormatter());
|
||||
});
|
||||
}
|
||||
|
||||
private static void AddDatabase(WebApplicationBuilder builder)
|
||||
{
|
||||
var dbProvider = (builder.Configuration["Database:Provider"] ?? "sqlite").ToLowerInvariant();
|
||||
|
||||
builder.Services.AddDbContext<DorkNetDbContext>(opt =>
|
||||
{
|
||||
opt.ConfigureWarnings(w => w.Ignore(
|
||||
Microsoft.EntityFrameworkCore.Diagnostics.RelationalEventId.PendingModelChangesWarning));
|
||||
|
||||
if (dbProvider == "postgres")
|
||||
{
|
||||
var conn = builder.Configuration.GetConnectionString("Default")
|
||||
?? throw new InvalidOperationException(
|
||||
"Database:Provider=postgres but ConnectionStrings:Default is not set. " +
|
||||
"Set the ConnectionStrings__Default env var or `ConnectionStrings:Default` " +
|
||||
"in appsettings.Local.json.");
|
||||
opt.UseNpgsql(conn, npg => npg.MigrationsAssembly("DorkNet.Server"));
|
||||
}
|
||||
else
|
||||
{
|
||||
var dbPath = builder.Configuration["Database:SqlitePath"];
|
||||
if (string.IsNullOrWhiteSpace(dbPath))
|
||||
dbPath = Path.Combine(AppContext.BaseDirectory, "data", "dorknet.db");
|
||||
Directory.CreateDirectory(Path.GetDirectoryName(dbPath)!);
|
||||
opt.UseSqlite($"Data Source={dbPath}", lite => lite.MigrationsAssembly("DorkNet.Server"));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private static void AddDomain(WebApplicationBuilder builder)
|
||||
{
|
||||
var apex =
|
||||
builder.Configuration["Domain:Apex"]
|
||||
?? Environment.GetEnvironmentVariable("DORKNET_DOMAIN")
|
||||
?? "localhost";
|
||||
var domainScheme =
|
||||
builder.Configuration["Domain:Scheme"]
|
||||
?? Environment.GetEnvironmentVariable("DORKNET_DOMAIN_SCHEME")
|
||||
?? "https";
|
||||
var domainPort =
|
||||
builder.Configuration["Domain:Port"]
|
||||
?? Environment.GetEnvironmentVariable("DORKNET_DOMAIN_PORT");
|
||||
var singleOriginBaseUrl =
|
||||
builder.Configuration["Domain:SingleOriginBaseUrl"]
|
||||
?? Environment.GetEnvironmentVariable("DORKNET_SINGLE_ORIGIN_BASE_URL");
|
||||
|
||||
builder.Services.AddSingleton(new DomainConfig(apex, domainScheme, domainPort, singleOriginBaseUrl));
|
||||
builder.Services.Configure<Microsoft.AspNetCore.HostFiltering.HostFilteringOptions>(opt =>
|
||||
{
|
||||
opt.AllowedHosts = new[] { apex, $"*.{apex}", "localhost", "127.0.0.1" };
|
||||
opt.AllowEmptyHosts = true;
|
||||
opt.IncludeFailureMessage = true;
|
||||
});
|
||||
Console.WriteLine($"[domain] apex={apex}, scheme={domainScheme}, port={domainPort}, singleOrigin={singleOriginBaseUrl}, allowedHosts=[{apex}, *.{apex}, localhost]");
|
||||
}
|
||||
|
||||
private static void AddCoreServices(IServiceCollection services)
|
||||
{
|
||||
services.AddScoped<PlayerService>();
|
||||
services.AddScoped<ConfigService>();
|
||||
services.AddScoped<AuthService>();
|
||||
services.AddScoped<RoomService>();
|
||||
services.AddScoped<LevelService>();
|
||||
services.AddScoped<StoreService>();
|
||||
services.AddSingleton<RoomDataBlobService>();
|
||||
services.AddSingleton<RoomBlobNormalizerService>();
|
||||
services.AddHttpClient();
|
||||
services.AddSingleton<HtrAssetMirrorService>();
|
||||
services.AddScoped<GameSessionService>();
|
||||
services.AddScoped<PrivateInstanceService>();
|
||||
services.AddScoped<CommunityBoardService>();
|
||||
services.AddScoped<ServerSettingsService>();
|
||||
services.AddScoped<SignupCodeService>();
|
||||
services.AddSingleton<NotificationService>();
|
||||
services.AddSingleton<OrphanAccountTracker>();
|
||||
services.AddSingleton<PlayerPresenceService>();
|
||||
services.AddSingleton<OnlinePresenceService>();
|
||||
services.AddSingleton<PlayerLogService>();
|
||||
services.AddSingleton<IObjectStorage, ObjectStorageService>();
|
||||
services.AddSingleton<ImageSignatureService>();
|
||||
}
|
||||
|
||||
private static string? AddRedis(WebApplicationBuilder builder)
|
||||
{
|
||||
var redisConn = builder.Configuration.GetConnectionString("Redis");
|
||||
if (string.IsNullOrWhiteSpace(redisConn)) return null;
|
||||
|
||||
var seRedisConfig = NormalizeRedisConn(redisConn);
|
||||
var mux = ConnectionMultiplexer.Connect(seRedisConfig);
|
||||
builder.Services.AddSingleton<IConnectionMultiplexer>(mux);
|
||||
return redisConn;
|
||||
}
|
||||
|
||||
internal static string NormalizeRedisConn(string raw)
|
||||
{
|
||||
if (!raw.StartsWith("redis://", StringComparison.OrdinalIgnoreCase) &&
|
||||
!raw.StartsWith("rediss://", StringComparison.OrdinalIgnoreCase))
|
||||
return raw;
|
||||
|
||||
var uri = new Uri(raw);
|
||||
var host = uri.Host;
|
||||
var port = uri.Port > 0 ? uri.Port : 6379;
|
||||
string? user = null, pass = null;
|
||||
if (!string.IsNullOrEmpty(uri.UserInfo))
|
||||
{
|
||||
var parts = uri.UserInfo.Split(':', 2);
|
||||
user = Uri.UnescapeDataString(parts[0]);
|
||||
if (parts.Length == 2) pass = Uri.UnescapeDataString(parts[1]);
|
||||
}
|
||||
var opts = new ConfigurationOptions
|
||||
{
|
||||
EndPoints = { { host, port } },
|
||||
AbortOnConnectFail = false,
|
||||
Ssl = raw.StartsWith("rediss://", StringComparison.OrdinalIgnoreCase),
|
||||
};
|
||||
if (!string.IsNullOrEmpty(user)) opts.User = user;
|
||||
if (!string.IsNullOrEmpty(pass)) opts.Password = pass;
|
||||
return opts.ToString();
|
||||
}
|
||||
|
||||
private static void AddAuthentication(WebApplicationBuilder builder)
|
||||
{
|
||||
var jwtSecret = Environment.GetEnvironmentVariable("DORKNET_JWT_SECRET")
|
||||
?? Environment.GetEnvironmentVariable("RECNET_JWT_SECRET")
|
||||
?? builder.Configuration["Jwt:Secret"]
|
||||
?? throw new InvalidOperationException(
|
||||
"JWT secret not configured. Set the DORKNET_JWT_SECRET env var or " +
|
||||
"add `Jwt:Secret` to appsettings.Local.json.");
|
||||
|
||||
builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
|
||||
.AddJwtBearer(opt =>
|
||||
{
|
||||
opt.TokenValidationParameters = new TokenValidationParameters
|
||||
{
|
||||
ValidateIssuerSigningKey = true,
|
||||
IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(jwtSecret)),
|
||||
ValidateIssuer = true,
|
||||
ValidIssuer = "https://api.rec.net/",
|
||||
ValidateAudience = false,
|
||||
ClockSkew = TimeSpan.Zero,
|
||||
};
|
||||
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();
|
||||
builder.Services.AddControllers().AddJsonOptions(opt =>
|
||||
opt.JsonSerializerOptions.PropertyNamingPolicy = null);
|
||||
}
|
||||
|
||||
private static void AddKestrelAndSignalR(WebApplicationBuilder builder, string? redisConn)
|
||||
{
|
||||
builder.WebHost.ConfigureKestrel(kopts =>
|
||||
{
|
||||
kopts.Limits.KeepAliveTimeout = TimeSpan.FromMinutes(10);
|
||||
kopts.Limits.MinRequestBodyDataRate = null;
|
||||
kopts.Limits.MinResponseDataRate = null;
|
||||
});
|
||||
|
||||
var signalR = builder.Services.AddSignalR(opts =>
|
||||
{
|
||||
opts.KeepAliveInterval = TimeSpan.FromSeconds(10);
|
||||
opts.ClientTimeoutInterval = TimeSpan.FromSeconds(60);
|
||||
opts.HandshakeTimeout = TimeSpan.FromSeconds(15);
|
||||
opts.MaximumReceiveMessageSize = 1024 * 1024;
|
||||
opts.EnableDetailedErrors = true;
|
||||
});
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(redisConn))
|
||||
signalR.AddStackExchangeRedis(NormalizeRedisConn(redisConn), opts =>
|
||||
{
|
||||
opts.Configuration.ChannelPrefix = RedisChannel.Literal("dorknet-signalr");
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
using Microsoft.AspNetCore.Builder;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.Extensions.FileProviders;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace DorkNet.Server.Startup;
|
||||
|
||||
/// <summary>Per-host static-files branching for admin, public site, and feed hosts.</summary>
|
||||
public static class StaticHostExtensions
|
||||
{
|
||||
public static void MountStaticHost(this WebApplication app, string host, string subdir, bool spaFallback = false)
|
||||
{
|
||||
var staticHostLogger = app.Services.GetRequiredService<ILoggerFactory>()
|
||||
.CreateLogger("DorkNet.Server.StaticHost");
|
||||
var root = Path.Combine(app.Environment.ContentRootPath, "wwwroot", subdir);
|
||||
staticHostLogger.LogInformation(
|
||||
"[static-host] {Host} -> {Root} (exists: {Exists}, spaFallback: {Spa})",
|
||||
host, root, Directory.Exists(root), spaFallback);
|
||||
if (!Directory.Exists(root)) return;
|
||||
var files = new PhysicalFileProvider(root);
|
||||
|
||||
var defaultFilesOpts = new DefaultFilesOptions { FileProvider = files };
|
||||
var staticFilesOpts = new StaticFileOptions
|
||||
{
|
||||
FileProvider = files,
|
||||
OnPrepareResponse = ctx =>
|
||||
{
|
||||
var headers = ctx.Context.Response.Headers;
|
||||
headers.CacheControl = "no-cache, no-store, must-revalidate";
|
||||
headers.Pragma = "no-cache";
|
||||
headers.Expires = "0";
|
||||
},
|
||||
};
|
||||
|
||||
app.UseWhen(ctx => ctx.Request.Host.Host.Equals(host, StringComparison.OrdinalIgnoreCase),
|
||||
branch =>
|
||||
{
|
||||
branch.Use(async (ctx, next) =>
|
||||
{
|
||||
var p = ctx.Request.Path.Value ?? "/";
|
||||
var probe = files.GetFileInfo(p == "/" ? "/index.html" : p);
|
||||
staticHostLogger.LogInformation(
|
||||
"[static-host:{Host}] path={Path} probe={Probe} exists={Exists}",
|
||||
host, p, probe.PhysicalPath ?? "<null>", probe.Exists);
|
||||
if (!p.StartsWith("/api/", StringComparison.OrdinalIgnoreCase))
|
||||
ctx.SetEndpoint(null);
|
||||
await next();
|
||||
});
|
||||
branch.UseDefaultFiles(defaultFilesOpts);
|
||||
branch.UseStaticFiles(staticFilesOpts);
|
||||
|
||||
branch.Use(async (ctx, next) =>
|
||||
{
|
||||
if (ctx.Response.HasStarted) return;
|
||||
|
||||
var path = ctx.Request.Path.Value ?? "/";
|
||||
|
||||
if (path.StartsWith("/api/", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
await next();
|
||||
return;
|
||||
}
|
||||
|
||||
if (ctx.Request.Method != "GET" && ctx.Request.Method != "HEAD")
|
||||
{
|
||||
await next();
|
||||
return;
|
||||
}
|
||||
|
||||
if (Path.HasExtension(path))
|
||||
{
|
||||
ctx.Response.StatusCode = StatusCodes.Status404NotFound;
|
||||
return;
|
||||
}
|
||||
|
||||
if (!spaFallback)
|
||||
{
|
||||
ctx.Response.StatusCode = StatusCodes.Status404NotFound;
|
||||
return;
|
||||
}
|
||||
|
||||
var index = files.GetFileInfo("/index.html");
|
||||
if (!index.Exists)
|
||||
{
|
||||
ctx.Response.StatusCode = StatusCodes.Status404NotFound;
|
||||
return;
|
||||
}
|
||||
|
||||
ctx.Response.ContentType = "text/html; charset=utf-8";
|
||||
var hdrs = ctx.Response.Headers;
|
||||
hdrs.CacheControl = "no-cache, no-store, must-revalidate";
|
||||
hdrs.Pragma = "no-cache";
|
||||
hdrs.Expires = "0";
|
||||
await using var stream = index.CreateReadStream();
|
||||
await stream.CopyToAsync(ctx.Response.Body);
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user