Files
2026-10-14 06:59:51 +02:00

852 lines
48 KiB
C#

using LiteDB;
using Microsoft.Extensions.FileProviders;
using Campus_live_rewrite;
using Campus_live_rewrite.api.dbs;
using Campus_live_rewrite.Controllers;
using Campus_live_rewrite.Commands;
using Campus_live_rewrite.api.server;
using System.Security.Principal;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Server.Kestrel.Core;
using Campus_live_rewrite.api.server.Classes;
using Newtonsoft.Json;
internal class Program
{
private static async Task Main(string[] args)
{
// If a DB restore was staged from the admin panel, apply it NOW — before anything opens
// a LiteDB file (they open lazily on first use, and can't be overwritten while held open).
BackupService.RestorePendingIfAny();
var builder = WebApplication.CreateBuilder(args);
builder.WebHost.ConfigureKestrel(serverOptions =>
{
serverOptions.AllowSynchronousIO = true;
serverOptions.AddServerHeader = false; // do not announce "Kestrel"
// ---- DoS / resource-exhaustion caps -------------------------------------------
// Nothing bounded Kestrel before this, so a connection flood or a giant/slow body
// could grow the process to max memory (the reported crash). These are hard ceilings
// that never affect a normal client; they only stop abuse.
//
// * Body size: an explicit per-request cap (== the historical 30 MB default, so real
// room/image uploads are unchanged) instead of relying on the implicit default.
// * Concurrent connections: bound how many sockets can be held open at once, so a
// flood of connections cannot balloon memory - excess connections are refused, the
// server stays up. Applies separately to normal and upgraded (WebSocket) sockets.
// * Header timeout: a client must finish sending request headers within 15s, which
// kills slowloris-style "trickle the headers forever" connection hoarding.
// * Keep-alive: reclaim idle connections after 60s instead of the 130s default.
// (MinRequestBodyDataRate is left at Kestrel's default - 240 B/s over a 5s grace -
// which already drops a client that trickles the BODY too slowly.)
serverOptions.Limits.MaxRequestBodySize = ConfigCampus.MaxRequestBodyBytes;
serverOptions.Limits.MaxConcurrentConnections = ConfigCampus.MaxConcurrentConnections;
serverOptions.Limits.MaxConcurrentUpgradedConnections = ConfigCampus.MaxConcurrentUpgradedConnections;
serverOptions.Limits.RequestHeadersTimeout = TimeSpan.FromSeconds(15);
serverOptions.Limits.KeepAliveTimeout = TimeSpan.FromSeconds(60);
});
builder.Services.AddHttpLogging(configureOptions => { });
builder.Services
.AddControllers()
.AddJsonOptions(o =>
{
o.JsonSerializerOptions.PropertyNamingPolicy = null;
});
builder.Services.AddSignalR();
builder.Services.AddSingleton<DiscordBot>();
builder.Host.UseContentRoot(Directory.GetCurrentDirectory());
builder.Services.Configure<ApiBehaviorOptions>(options =>
{
options.SuppressModelStateInvalidFilter = false;
});
var app = builder.Build();
if (!Directory.Exists(Environment.CurrentDirectory + "/Data"))
{
Directory.CreateDirectory(Environment.CurrentDirectory + "/Data");
Directory.CreateDirectory(Environment.CurrentDirectory + "/Data/APIS");
Directory.CreateDirectory(Environment.CurrentDirectory + "/Data/DBs");
}
if (!Directory.Exists(Environment.CurrentDirectory + "/Web"))
{
Directory.CreateDirectory(Environment.CurrentDirectory + "/Web");
Directory.CreateDirectory(Environment.CurrentDirectory + "/Web/Admin");
}
// FIRST in the pipeline: a banned IP gets 502 for the whole domain and never
// touches logging, routing or any controller. Loopback is exempt so the operator
// cannot lock themselves out of the admin panel over the SSH tunnel.
// Before even that: a connection that did not come from the reverse proxy on this machine is dropped (OriginLockMiddleware).
app.UseMiddleware<Campus_live_rewrite.Middleware.OriginLockMiddleware>();
// Then the real player address from the proxy (Caddy behind Cloudflare), written into X-Forwarded-For for every check below (ClientIpMiddleware).
app.UseMiddleware<Campus_live_rewrite.Middleware.ClientIpMiddleware>();
Campus_live_rewrite.api.server.YoutubeFetch.StartSweeper(); // deletes fetched YouTube videos once their 3 days are up
app.UseMiddleware<BannedIpMiddleware>();
// Which host name shows what: the website only on camprec.net, the admin panel only on admin.camprec.net, ns. / cdn. just their
// own answers (HostRoutingMiddleware). Early, so a page on the wrong host is turned away before anything else runs.
app.UseMiddleware<Campus_live_rewrite.Middleware.HostRoutingMiddleware>();
// Per-IP flood + brute-force guard. Runs right after the ban check and BEFORE routing /
// logging / controllers, so a flood is 429'd at the cheapest possible point and never
// reaches LiteDB or the access log. General ceiling stops volumetric DoS; a tight ceiling
// on /Auth/connect/token + /api/site/login stops credential brute-force and token farming.
// Loopback exempt. Toggle with ConfigCampus.TrafficGuardEnabled.
app.UseMiddleware<TrafficGuardMiddleware>();
// Patch gate: refuse game requests from any IP that has not completed the patch handshake
// (POST /api/patch/verify with the correct X-Campus-Patch header). A wrong/old/no patch
// never validates, so it is blocked here. Toggle with PatchGateMiddleware.Enable; exempts
// /api/patch/verify + /admin + browser assets + loopback.
app.UseMiddleware<PatchGateMiddleware>();
// Admin hardening: security headers + per-IP rate limit on /admin/* and /api/web/admin/*
// (on top of the dev-token + admin-session auth). Runs after the gate so the login page is
// still reachable to be throttled, and after the ban check so banned IPs never reach it.
app.UseMiddleware<AdminProtectionMiddleware>();
// Lightweight per-request access log for the admin "Log" tab (/admin/api/apilog).
// Rewritten to an in-memory ring buffer + single shared file writer (see Log.cs), so it
// no longer opens/closes the file per request or drops lines under concurrency. Now cheap
// enough to run UNCONDITIONALLY - the admin log tool needs data whether or not the heavy
// VerboseRequestLogging body-logging (Log2, below) is on. This only records one line per
// request ("<time> <METHOD> <PATH> -> <status>"); it changes no response bytes.
app.UseMiddleware<Log>(Path.Combine(Environment.CurrentDirectory, "Data", "APILog.txt"));
// Registered unconditionally - it checks the flag itself and short-circuits,
// so the flag can be read in one place and the pipeline shape stays constant.
app.UseMiddleware<RequestResponseLoggingMiddleware>();
// Banned website sessions: signed in and shown the ban banner, but every write they try
// (profile picture, bio, photos, friends...) is refused - see SiteBanGateMiddleware.
app.UseMiddleware<SiteBanGateMiddleware>();
// Website writes are plain text only - no fancy-font letters, emoji or zalgo (SitePlainTextMiddleware).
app.UseMiddleware<SitePlainTextMiddleware>();
//app.UseMiddleware<VpnBlockMiddleware>();
app.Use(async (context, next) =>
{
var path = context.Request.Path.ToString();
if (path.Contains("//"))
{
path = path.Replace("//", "/");
context.Request.Path = path;
}
await next.Invoke();
});
// Security headers on the ADMIN panel only.
//
// The panel holds moderation controls, so harden its responses: no framing (anti-
// clickjacking), no MIME sniffing, no referrer leak of admin URLs, and never cache an
// authenticated admin page or its JSON. Scoped to /admin so game endpoints - which the
// client caches aggressively - are untouched. Auth itself is already enforced per
// request by AuthHelper.ValidateTokenDev (valid signed token AND a real dev flag);
// these headers are defence-in-depth around that, not a replacement for it.
app.Use(async (context, next) =>
{
if (context.Request.Path.StartsWithSegments("/admin"))
{
var h = context.Response.Headers;
h["X-Frame-Options"] = "DENY";
h["X-Content-Type-Options"] = "nosniff";
h["Referrer-Policy"] = "no-referrer";
h["Cache-Control"] = "no-store, no-cache, must-revalidate, private";
h["Pragma"] = "no-cache";
h["Content-Security-Policy"] = "frame-ancestors 'none'";
}
await next.Invoke();
});
app.UseWebSockets();
app.UseAuthorization();
app.UseMiddleware<IPAllowMiddleware>();
// Quest clients get the Android conversions of first-party clothing (quest/<name>).
app.UseMiddleware<Campus_live_rewrite.Middleware.QuestBundleMiddleware>();
// app.UseStatusCodePagesWithReExecute("/Web/404");
var webSocketOptions = new WebSocketOptions
{
KeepAliveInterval = TimeSpan.FromMinutes(2)
};
app.UseWebSockets(webSocketOptions);
app.UseStaticFiles(new StaticFileOptions
{
FileProvider = new PhysicalFileProvider(Path.Combine(builder.Environment.ContentRootPath, "Data", "Videos")),
RequestPath = "/cdn/video",
OnPrepareResponse = ctx =>
{
ctx.Context.Response.Headers.Remove("Accept-Ranges");
var allowedContentTypes = new[]
{
"video/mp4", // some webm video break recroom video screen and show artifact
"video/mkv"
};
if (!allowedContentTypes.Contains(ctx.Context.Response.ContentType))
{
ctx.Context.Response.StatusCode = StatusCodes.Status403Forbidden;
ctx.Context.Response.Body = Stream.Null;
}
}
});
app.UseStaticFiles(new StaticFileOptions
{
FileProvider = new PhysicalFileProvider(
Path.Combine(builder.Environment.ContentRootPath, "Data", "CDN", "Configs")
),
RequestPath = "/cdn/config",
OnPrepareResponse = ctx =>
{
ctx.Context.Response.Headers.Remove("Accept-Ranges");
},
ServeUnknownFileTypes = true // optional if some files have unknown extensions
});
// Third logging layer - dumps full request/response headers per request.
// Same gate; this is pure overhead during normal play.
if (ConfigCampus.VerboseRequestLogging)
{
app.UseHttpLogging();
}
// Campus admin panel: the LoginV4.html / HomeV4.html templates reference
// /admin-assets/Cutout.png, /admin-assets/Background.png, /admin-assets/AnimatedIcon.png
// for the Campus branding (purple/orange gradient + tile pattern + Cutout hero).
// Mount those files from ContentRoot/AdminPanel/Assets - PackageServer.ps1 ships that
// folder into the deploy tarball alongside Data/ and Web/.
{
string adminAssets = Path.Combine(builder.Environment.ContentRootPath, "AdminPanel", "Assets");
if (Directory.Exists(adminAssets))
{
app.UseStaticFiles(new StaticFileOptions
{
FileProvider = new PhysicalFileProvider(Path.GetFullPath(adminAssets)),
RequestPath = "/admin-assets",
});
Console.WriteLine($"[admin] assets from {Path.GetFullPath(adminAssets)}");
}
else
{
Console.WriteLine($"[admin] WARN: AdminPanel/Assets not found at {adminAssets} - Campus branding won't load");
}
}
// College portfolio - a standalone static page at /collegeportfolio, nothing to do with
// Campus. It is the owner's own coursework (ContentRoot/CollegePortfolio: Portfolio.html
// plus its audio), served verbatim so the folder can be handed in unchanged.
//
// Two details this needs and the other mounts don't:
// * a DEFAULT DOCUMENT, because the page is Portfolio.html rather than index.html;
// * a redirect from "/collegeportfolio" to "/collegeportfolio/", because the page
// references its audio relatively ("drum-beat.wav"). Without the trailing slash the
// browser resolves that against the site ROOT and the audio 404s.
{
// On the VPS the folder sits next to the binary (the deploy scripts put it there).
// On a dev box it lives at the REPO root, two levels up - the owner keeps their
// coursework outside the server project, so the fallback avoids a second copy that
// could drift from the one they actually hand in.
string portfolioRoot = Path.Combine(builder.Environment.ContentRootPath, "CollegePortfolio");
if (!Directory.Exists(portfolioRoot))
{
string repoRoot = Path.Combine(builder.Environment.ContentRootPath, "..", "..", "CollegePortfolio");
if (Directory.Exists(repoRoot)) portfolioRoot = Path.GetFullPath(repoRoot);
}
if (Directory.Exists(portfolioRoot))
{
app.Use(async (context, next) =>
{
if (context.Request.Path.Equals("/collegeportfolio", StringComparison.OrdinalIgnoreCase))
{
context.Response.Redirect("/collegeportfolio/" + context.Request.QueryString, permanent: false);
return;
}
await next.Invoke();
});
var portfolioFiles = new PhysicalFileProvider(Path.GetFullPath(portfolioRoot));
app.UseDefaultFiles(new DefaultFilesOptions
{
FileProvider = portfolioFiles,
RequestPath = "/collegeportfolio",
DefaultFileNames = new List<string> { "Portfolio.html", "index.html" }
});
app.UseStaticFiles(new StaticFileOptions
{
FileProvider = portfolioFiles,
RequestPath = "/collegeportfolio",
// Unknown extensions are served rather than 404'd so an asset added to the
// folder later (a stem, a project file) does not need a code change.
ServeUnknownFileTypes = true,
DefaultContentType = "application/octet-stream",
OnPrepareResponse = ctx =>
{
ctx.Context.Response.Headers["Cache-Control"] =
ctx.File.Name.EndsWith(".html", StringComparison.OrdinalIgnoreCase)
? "no-cache"
: "public, max-age=3600";
}
});
Console.WriteLine($"[portfolio] /collegeportfolio -> {Path.GetFullPath(portfolioRoot)}");
}
else
{
Console.WriteLine($"[portfolio] WARN: {portfolioRoot} not found - /collegeportfolio will 404");
}
}
// Campus public website, served straight off ContentRoot/Website - this is what used
// to be the separate Flask process in Website/server.py. Only the web assets were
// copied across: server.py / build.py / requirements.txt / StartFlask*.bat stayed
// behind on purpose, because mounting the Flask project folder at the web root would
// publish its source (and any token.json sitting next to it) as downloadable files.
//
// NO UseDefaultFiles here. It would serve index.html for "/" from inside this
// middleware, before routing runs, and "/" MUST reach WebController.NsRoot or the
// game client cannot connect. NsRoot serves index.html itself for browsers.
{
string siteRoot = Path.Combine(builder.Environment.ContentRootPath, "Website");
if (Directory.Exists(siteRoot))
{
app.UseStaticFiles(new StaticFileOptions
{
FileProvider = new PhysicalFileProvider(Path.GetFullPath(siteRoot)),
RequestPath = "", // mount at root: /app.js, /style.css, /assets/...
OnPrepareResponse = ctx =>
{
// Pages must not be cached (they change with every deploy); the
// fingerprinted css/js/images can be.
ctx.Context.Response.Headers["Cache-Control"] =
ctx.File.Name.EndsWith(".html", StringComparison.OrdinalIgnoreCase)
? "no-cache"
: "public, max-age=3600";
}
});
Console.WriteLine($"[site] serving website from {Path.GetFullPath(siteRoot)}");
}
else
{
Console.WriteLine($"[site] WARN: {siteRoot} not found - the landing page will 404");
}
}
app.MapControllers();
// CAMPUS_LOCAL_DEV only (a developer's own copy, started by the launcher's Dev channel): the launcher asks it to shut down cleanly
// when the game closes - a plain kill could cut a database write short. Loopback only, and not through Caddy.
if (ConfigCampus.LocalDev)
{
app.MapPost("/api/localdev/shutdown", (HttpContext c) =>
{
bool loop = c.Connection.RemoteIpAddress != null && System.Net.IPAddress.IsLoopback(c.Connection.RemoteIpAddress)
&& !c.Request.Headers.ContainsKey("X-Forwarded-For");
if (!loop) return Results.NotFound();
Console.WriteLine("[localdev] shutdown requested by the launcher");
_ = Task.Run(async () => { await Task.Delay(300); app.Lifetime.StopApplication(); });
return Results.Ok(new { ok = true });
});
}
//PlayerDB.EnsureDormsDontBeStupid(log: true);
//RoomDB.d(51, 138, "f94c6b386d2b45a0a582f688022aba0c.room");
//PlayerDB.ImportPlayers(Environment.CurrentDirectory + "/Data/exported_players.json");
//await RoomDB.ImportRooms(Environment.CurrentDirectory + "/Data/yay.json");
// Once everything is checked for prod release, uncomment this and import the players and rooms.
/*
await Task.Run(() =>
{
try
{
Webhook.Send(
1,
"prod",
"Server Info",
"Campus Live server just went online!",
5898927
);
}
catch (Exception ex)
{
Console.WriteLine($"{ex.Message}");
}
});
*/
// PlayerDB.SetIsInfluencer(282, true);
// PlayerDB.SetCreatorCode(282, "idk");
// RoomDB.FixDuplicateDorms();
// PlayerDB.EnsureDormsForAllPlayers();
// PlayerDB.SetPlayerDev(Campus_live_rewrite.api.server.Classes.db_class.DevFlag.none, 3);
// PlayerDB.ClearRecentRoomsForAllPlayers();
// Start Discord bot in background — never let a bot fault take the web server down.
_ = Task.Run(async () =>
{
try
{
Console.WriteLine("[DiscordBot] Starting...");
await StartBotAsync(app.Services);
}
catch (Exception ex)
{
Console.WriteLine($"[DiscordBot] CRASHED: {ex}");
}
});
// Only start the interactive command console when stdin is attached to a terminal.
// Under systemd, stdin is /dev/null → ReadLine() returns null immediately and the loop
// busy-spins, flooding the journal and starving other threads.
if (!Console.IsInputRedirected)
{
new Thread(new ThreadStart(debug_run)).Start();
}
else
{
Console.WriteLine("[Startup] stdin is redirected — skipping CommandConsole (headless mode).");
}
PlayerDB.Setup();
HeartbeatDB.Setup();
RoomDB.Setup();
RoomDB.RenameAllDormsToOwners();
// Load the in-game token kill-switch epoch (admin "Expire all game tokens") so a revoke
// survives restarts. See ClientSecurity / ConfigCampus.GameTokenValidFrom.
Campus_live_rewrite.api.server.ClientSecurity.LoadGameTokenRevocation();
// Index the UGC custom-avatar-item catalog (Data/APIS/items/UGC.json) into memory once, so
// the customAvatarItems endpoints don't re-parse ~87MB per request. See CustomItemsCache.
Campus_live_rewrite.api.server.CustomItemsCache.Load();
// Bulk UGC import on boot: any avatar packs staged in Data/AvatarImportStaging/ (rsync'd
// there - the no-browser path for a large library) are imported once, then the pack folder
// is moved to _imported/ so a restart doesn't re-scan it. Assets are copied into AvatarBlobs
// /Images and each item registered into the dev locker. See AvatarItemImporter.
try
{
string staging = Path.Combine(Environment.CurrentDirectory, "Data", "AvatarImportStaging");
if (Directory.Exists(staging))
{
string done = Path.Combine(staging, "_imported");
foreach (var pack in Directory.EnumerateDirectories(staging))
{
if (Path.GetFileName(pack).Equals("_imported", StringComparison.OrdinalIgnoreCase)) continue;
if (!Campus_live_rewrite.api.server.AvatarItemImporter.LooksLikeAvatarPack(pack)) continue;
var ar = Campus_live_rewrite.api.server.AvatarItemImporter.ImportDir(pack);
Console.WriteLine($"[avatar-staging] {Path.GetFileName(pack)}: {ar.Items} items, {ar.BundlesHosted} bundles, {ar.ThumbsHosted} images, {ar.MissingAssets.Count} missing");
try { Directory.CreateDirectory(done); Directory.Move(pack, Path.Combine(done, Path.GetFileName(pack) + "_" + DateTime.UtcNow.ToString("yyyyMMddHHmmss"))); } catch (Exception mv) { Console.WriteLine($"[avatar-staging] move-aside failed for {Path.GetFileName(pack)}: {mv.Message}"); }
}
}
}
catch (Exception stx) { Console.WriteLine($"[avatar-staging] scan failed: {stx.Message}"); }
// Bulk ROOM import on boot from Data/RoomImportStaging/ (the no-browser path for big batches -
// scp/rsync/sftp the files there, no fragile multi-GB browser upload). Handles, per entry:
// * a room-export FOLDER (RoomDetails.json + .binpb blobs) -> ImportRoomExport
// * a FOLDER of loose .room blobs -> wrap each loadable one
// * a .zip -> extract, then room-export OR loose-.room pack
// * a loose .room file -> wrap it
// Processed entries move to _imported/. See RoomDB.ImportRoomExport / RoomDB.WrapLooseRoom.
try
{
string rstaging = Path.Combine(Environment.CurrentDirectory, "Data", "RoomImportStaging");
if (Directory.Exists(rstaging))
{
ulong importerId = Campus_live_rewrite.api.dbs.PlayerDB.GetAccountIdByUsernameCI("Importer");
ulong? owner = importerId == 0 ? (ulong?)null : importerId;
string done = Path.Combine(rstaging, "_imported");
async Task ProcessRoomDir(string dir, string label)
{
if (Campus_live_rewrite.api.dbs.RoomDB.LooksLikeRoomExport(dir))
{
var rr = await Campus_live_rewrite.api.dbs.RoomDB.ImportRoomExport(dir, owner);
Console.WriteLine($"[room-staging] {label}: room-export -> {rr.Rooms} room(s) [{string.Join(", ", rr.Names.Take(8))}], {rr.Blobs} blob(s), {rr.Errors.Count} error(s)");
foreach (var e in rr.Errors.Take(10)) Console.WriteLine($"[room-staging] ! {e}");
}
else
{
var roomFiles = Directory.EnumerateFiles(dir, "*.room", SearchOption.AllDirectories).ToList();
int made = 0, tooNew = 0, other = 0;
foreach (var rf in roomFiles)
{
var (st, id, ver, nm, _) = Campus_live_rewrite.api.dbs.RoomDB.WrapLooseRoom(rf, importerId);
if (st == Campus_live_rewrite.api.dbs.RoomDB.LooseWrapStatus.Created) made++;
else if (st == Campus_live_rewrite.api.dbs.RoomDB.LooseWrapStatus.TooNew) tooNew++;
else other++;
}
Console.WriteLine($"[room-staging] {label}: {roomFiles.Count} loose .room -> {made} room(s) created, {tooNew} too-new, {other} skipped");
}
}
// Loose .room files sitting directly in staging.
foreach (var rf in Directory.EnumerateFiles(rstaging, "*.room", SearchOption.TopDirectoryOnly))
{
var (st, id, ver, nm, err) = Campus_live_rewrite.api.dbs.RoomDB.WrapLooseRoom(rf, importerId);
Console.WriteLine($"[room-staging] {Path.GetFileName(rf)}: {st}" + (st == Campus_live_rewrite.api.dbs.RoomDB.LooseWrapStatus.Created ? $" '{nm}' (id {id}, v{ver})" : st == Campus_live_rewrite.api.dbs.RoomDB.LooseWrapStatus.TooNew ? $" (v{ver})" : ""));
try { Directory.CreateDirectory(done); Directory.Move(rf, Path.Combine(done, Path.GetFileName(rf))); } catch { }
}
// .zip files in staging: extract then process.
foreach (var zip in Directory.EnumerateFiles(rstaging, "*.zip", SearchOption.TopDirectoryOnly))
{
string ex = Path.Combine(rstaging, Path.GetFileNameWithoutExtension(zip) + "_x" + DateTime.UtcNow.ToString("HHmmss"));
try
{
Directory.CreateDirectory(ex);
System.IO.Compression.ZipFile.ExtractToDirectory(zip, ex, overwriteFiles: true);
await ProcessRoomDir(ex, Path.GetFileName(zip));
try { Directory.CreateDirectory(done); Directory.Move(zip, Path.Combine(done, Path.GetFileName(zip))); } catch { }
try { Directory.Delete(ex, true); } catch { }
}
catch (Exception zx) { Console.WriteLine($"[room-staging] {Path.GetFileName(zip)}: extract/import failed: {zx.Message}"); }
}
// Folders (room-export or loose-.room packs).
foreach (var pack in Directory.EnumerateDirectories(rstaging))
{
if (Path.GetFileName(pack).Equals("_imported", StringComparison.OrdinalIgnoreCase)) continue;
if (Path.GetFileName(pack).Contains("_x", StringComparison.Ordinal) && !Directory.EnumerateFileSystemEntries(pack).Any()) continue;
await ProcessRoomDir(pack, Path.GetFileName(pack));
try { Directory.CreateDirectory(done); Directory.Move(pack, Path.Combine(done, Path.GetFileName(pack) + "_" + DateTime.UtcNow.ToString("yyyyMMddHHmmss"))); } catch (Exception mv) { Console.WriteLine($"[room-staging] move-aside failed for {Path.GetFileName(pack)}: {mv.Message}"); }
}
}
}
catch (Exception rex) { Console.WriteLine($"[room-staging] scan failed: {rex.Message}"); }
// Downgrade already-imported rooms' declared build version to the client's level, so rooms
// imported before this fix stop failing the client's "older version of Rec Room" gate.
try
{
ulong impId = Campus_live_rewrite.api.dbs.PlayerDB.GetAccountIdByUsernameCI("Importer");
int clamped = Campus_live_rewrite.api.dbs.RoomDB.ClampImportedRoomsToClient(impId);
if (clamped > 0) Console.WriteLine($"[room-version] downgraded {clamped} imported room(s) to the client build version");
int purged = Campus_live_rewrite.api.dbs.RoomDB.RemoveTooNewLooseRooms(impId);
if (purged > 0) Console.WriteLine($"[room-version] removed {purged} too-new loose-blob room(s) that would freeze the client");
}
catch (Exception cvx) { Console.WriteLine($"[room-version] clamp failed: {cvx.Message}"); }
// End-of-game / quest reward pools (Data/APIS/quest-rewards.json) - keyed by mode + gift
// context, rolled into a gift box on POST /api/gamerewards/v1/request. See QuestRewards.
Campus_live_rewrite.api.server.QuestRewards.Load();
// Weekly-challenge definition (Data/APIS/weekly-challenge.json) served by /api/challenge/v2.
Campus_live_rewrite.api.server.WeeklyChallenge.Load();
// ---- LiteDB write-ahead log handling ---------------------------------------------
// Every LiteDatabase here is a static that is never disposed, so LiteDB's checkpoint
// (which merges <name>-log.db back into <name>.db) never ran. Data accumulated in the
// WAL indefinitely - and because install.sh preserves Data/DBs/* across deploys, that
// WAL was carried into every subsequent build, which is why deploying a known-good
// server changed nothing. Verified fix: the boot report caught Settings.db at 8KB with
// a 7.7MB log, and after checkpointing the data files hold the data and the logs are 0.
DbMaintenance.ReportWalState();
// Merge the write-ahead logs into the data files every 5 minutes, not just at
// shutdown. Without this, a hard kill leaves every write since boot recoverable
// only by replaying the log.
DbMaintenance.StartAutoCheckpoint(5);
// Snapshot every LiteDB store 3x/day into Data/Backups, keep 3 days (admin "Database" tab).
BackupService.Start();
// Creator earnings: pay the sales whose Sunday has come (EarningsDB).
Campus_live_rewrite.api.dbs.EarningsDB.Start();
// The Coach is Level 99 and nobody else can be (Progress.PLAYER_MAX_LEVEL).
try
{
ulong coach = Campus_live_rewrite.api.dbs.PlayerDB.GetAccountIdByUsernameCI(Campus_live_rewrite.api.server.ConfigCampus.Level99Username);
if (coach != 0)
{
var cur = Campus_live_rewrite.api.server.Classes.Progress.GetPlayerLevel(coach);
if (cur.Level != Campus_live_rewrite.api.server.Classes.Progress.MAX_LEVEL)
{
Campus_live_rewrite.api.dbs.PlayerDB.SetProgression(coach, 0, Campus_live_rewrite.api.server.Classes.Progress.MAX_LEVEL);
Console.WriteLine($"[progression] {Campus_live_rewrite.api.server.ConfigCampus.Level99Username} ({coach}) set to Level {Campus_live_rewrite.api.server.Classes.Progress.MAX_LEVEL}");
}
}
}
catch (Exception ex) { Console.WriteLine($"[progression] level-99 seed skipped: {ex.Message}"); }
CampusLiveSource.Controllers.NotificationsController.Stopping = app.Lifetime.ApplicationStopping;
app.Lifetime.ApplicationStopping.Register(() =>
{
Console.WriteLine("[Startup] shutting down - checkpointing LiteDB stores ...");
DbMaintenance.StopAutoCheckpoint();
BackupService.Stop();
DbMaintenance.CheckpointAll();
});
// ---- Admin seed --------------------------------------------------------------------
// Idempotent. On every start, make sure there's a Kyle account with dev_Flag=dev and
// the operator password so the admin panel is always reachable, even after a fresh
// deploy that ships with an empty Players.db. If the account already exists we DON'T
// clobber it - operator may have changed the password from the panel.
try
{
var col = Campus_live_rewrite.api.dbs.PlayerDB.PlayerDBFile
.GetCollection<Campus_live_rewrite.api.server.Classes.db_class.player_data>("players");
var existing = col.FindOne(x => x.player != null && x.player.Username == "Kyle");
if (existing == null)
{
// Seed via CreateAccount - random name + starter reputation - then rename to Kyle.
if (Campus_live_rewrite.api.dbs.PlayerDB.CreateAccount(
Campus_live_rewrite.api.server.Classes.db_class.Platforms.All,
0UL, "", 0UL, out var seeded))
{
var fresh = col.FindOne(x => x.playerid == seeded.accountId);
if (fresh != null && fresh.player != null)
{
fresh.player.Username = "Kyle";
fresh.player.DisplayName = "Kyle";
fresh.password = Campus_live_rewrite.api.server.ClientSecurity.HashPassword("VanessaPlease16#");
fresh.dev_Flag = Campus_live_rewrite.api.server.Classes.db_class.DevFlag.dev;
col.Update(fresh);
Console.WriteLine($"[admin-seed] created Kyle (id={fresh.playerid}) with dev flag - login: Kyle / VanessaPlease16#");
}
}
else
{
Console.WriteLine("[admin-seed] CreateAccount returned false, Kyle NOT seeded");
}
}
else
{
bool changed = false;
if (string.IsNullOrEmpty(existing.password))
{
existing.password = Campus_live_rewrite.api.server.ClientSecurity.HashPassword("VanessaPlease16#");
changed = true;
}
if ((existing.dev_Flag & Campus_live_rewrite.api.server.Classes.db_class.DevFlag.dev) !=
Campus_live_rewrite.api.server.Classes.db_class.DevFlag.dev)
{
existing.dev_Flag |= Campus_live_rewrite.api.server.Classes.db_class.DevFlag.dev;
changed = true;
}
// Stamp Kyle's owner Discord + Steam IDs.
existing.player_Extra ??= new Campus_live_rewrite.api.server.Classes.db_class.player_extra();
if (existing.player_Extra.DiscordId != 1450391092984807445UL)
{
existing.player_Extra.DiscordId = 1450391092984807445UL;
changed = true;
}
existing.platform = Campus_live_rewrite.api.server.Classes.db_class.Platforms.Steam;
existing.player.PlatformIds ??= new List<Campus_live_rewrite.api.server.Classes.db_class.mPlatformID>();
var steamEntry = existing.player.PlatformIds.FirstOrDefault(p => p != null && p.Platform == Campus_live_rewrite.api.server.Classes.db_class.Platforms.Steam);
if (steamEntry == null)
{
existing.player.PlatformIds.Add(new Campus_live_rewrite.api.server.Classes.db_class.mPlatformID
{
Platform = Campus_live_rewrite.api.server.Classes.db_class.Platforms.Steam,
PlatformId = 76561199059115358UL
});
changed = true;
}
else if (steamEntry.PlatformId != 76561199059115358UL)
{
steamEntry.PlatformId = 76561199059115358UL;
changed = true;
}
if (changed) { col.Update(existing); Console.WriteLine($"[admin-seed] refreshed Kyle (id={existing.playerid}) - dev flag / discord / steam stamped"); }
else { Console.WriteLine($"[admin-seed] Kyle (id={existing.playerid}) already fine - no changes"); }
// Force skip-orientation flags on Kyle so first login on this build doesn't
// loop through the broken SocialOrientation load path.
Campus_live_rewrite.api.dbs.SettingsDB.SetPlayerSetting("TUTORIAL_COMPLETE_MASK", "4294967295", existing.playerid);
Campus_live_rewrite.api.dbs.SettingsDB.SetPlayerSetting("Recroom.AccountCreation.HasFinished", "true", existing.playerid);
Campus_live_rewrite.api.dbs.SettingsDB.SetPlayerSetting("Recroom.AccountCreation.HasChosenUsername", "true", existing.playerid);
Campus_live_rewrite.api.dbs.SettingsDB.SetPlayerSetting("Recroom.AccountCreation.HasCreatedPassword", "true", existing.playerid);
}
}
catch (Exception seedEx)
{
Console.WriteLine($"[admin-seed] FAILED: {seedEx.Message}");
}
// ---- @Importer seed ----------------------------------------------------------------
// A second owner-controlled account that HOLDS the imported rooms. It shares Kyle's Steam
// id and is account-lock exempt (ConfigCampus.LockExemptUsernames), so it can be signed
// into from the same Steam/device as Kyle (log in with username "Importer"). Idempotent.
try
{
var col = Campus_live_rewrite.api.dbs.PlayerDB.PlayerDBFile
.GetCollection<Campus_live_rewrite.api.server.Classes.db_class.player_data>("players");
var imp = col.FindOne(x => x.player != null && x.player.Username == "Importer");
if (imp == null &&
Campus_live_rewrite.api.dbs.PlayerDB.CreateAccount(
Campus_live_rewrite.api.server.Classes.db_class.Platforms.All, 0UL, "", 0UL, out var impSeeded))
{
imp = col.FindOne(x => x.playerid == impSeeded.accountId);
}
if (imp != null && imp.player != null)
{
bool changed = false;
if (imp.player.Username != "Importer") { imp.player.Username = "Importer"; imp.player.DisplayName = "Importer"; changed = true; }
if (string.IsNullOrEmpty(imp.password)) { imp.password = Campus_live_rewrite.api.server.ClientSecurity.HashPassword("ImporterPlease16#"); changed = true; }
imp.platform = Campus_live_rewrite.api.server.Classes.db_class.Platforms.Steam;
imp.player.PlatformIds ??= new List<Campus_live_rewrite.api.server.Classes.db_class.mPlatformID>();
var s = imp.player.PlatformIds.FirstOrDefault(p => p != null && p.Platform == Campus_live_rewrite.api.server.Classes.db_class.Platforms.Steam);
if (s == null) { imp.player.PlatformIds.Add(new Campus_live_rewrite.api.server.Classes.db_class.mPlatformID { Platform = Campus_live_rewrite.api.server.Classes.db_class.Platforms.Steam, PlatformId = 76561199059115358UL }); changed = true; }
else if (s.PlatformId != 76561199059115358UL) { s.PlatformId = 76561199059115358UL; changed = true; }
if (changed) col.Update(imp);
Campus_live_rewrite.api.dbs.SettingsDB.SetPlayerSetting("TUTORIAL_COMPLETE_MASK", "4294967295", imp.playerid);
Campus_live_rewrite.api.dbs.SettingsDB.SetPlayerSetting("Recroom.AccountCreation.HasFinished", "true", imp.playerid);
Campus_live_rewrite.api.dbs.SettingsDB.SetPlayerSetting("Recroom.AccountCreation.HasChosenUsername", "true", imp.playerid);
Campus_live_rewrite.api.dbs.SettingsDB.SetPlayerSetting("Recroom.AccountCreation.HasCreatedPassword", "true", imp.playerid);
Console.WriteLine($"[admin-seed] Importer ready (id={imp.playerid}) - login: Importer / ImporterPlease16#");
}
}
catch (Exception impEx) { Console.WriteLine($"[admin-seed] Importer seed FAILED: {impEx.Message}"); }
// Cheer badges that should never have been accepted: a number that is not a real badge, or the Rec Room Developer badge on an
// account that is not a developer (SetSelectedCheer used to store whatever number a client posted). Reset those to "no badge".
try
{
var repCol = Campus_live_rewrite.api.dbs.PlayerDB.PlayerDBFile
.GetCollection<Campus_live_rewrite.api.server.Classes.db_class.player_data>("players");
int cleaned = 0;
foreach (var pl in repCol.FindAll().ToList())
{
var rep = pl.player?.PlayerReputation;
if (rep == null) continue;
if (!Campus_live_rewrite.api.dbs.PlayerDB.MaySelectCheer(rep.SelectedCheer, pl.playerid))
{
Console.WriteLine($"[cheer] account {pl.playerid} had selected cheer {(int)rep.SelectedCheer} - reset to none");
rep.SelectedCheer = Campus_live_rewrite.api.server.Classes.db_class.CheerCategoryEnum.None;
repCol.Update(pl);
cleaned++;
}
}
if (cleaned > 0) Console.WriteLine($"[cheer] {cleaned} invalid selected cheer badge(s) reset");
}
catch (Exception cheerEx) { Console.WriteLine($"[cheer] badge cleanup FAILED: {cheerEx.Message}"); }
// Mirror each Rooms 2.0 room's Accessibility onto its primary subroom's, so existing
// published rooms appear per their real visibility (they set it on the subroom, but
// Search/hot read the room-level field).
try { Campus_live_rewrite.api.dbs.RoomDB.FixPlayerRoomFlags(); }
catch (Exception _fEx) { Console.WriteLine("[rooms] player-room flag fix skipped: " + _fEx.Message); }
try
{
int _synced = Campus_live_rewrite.api.dbs.RoomDB.SyncAllRoomAccessibility();
if (_synced > 0) Console.WriteLine($"[rooms] synced room-level accessibility from subrooms for {_synced} room(s)");
}
catch (Exception _rEx) { Console.WriteLine("[rooms] accessibility sync skipped: " + _rEx.Message); }
try
{
int _pres = Campus_live_rewrite.api.dbs.HeartbeatDB.FixOutOfRangePresence();
if (_pres > 0) Console.WriteLine($"[presence] repaired {_pres} presence row(s) with out-of-range values");
}
catch (Exception _pEx) { Console.WriteLine("[presence] repair skipped: " + _pEx.Message); }
// Room kicks were stored as permanent room bans ("Kicked by X"); those are lifted once.
try
{
int _kicks = Campus_live_rewrite.api.dbs.RoomDB.LiftKickBans();
Campus_live_rewrite.api.server.BundleHashes.WarmUp(); // item definitions carry the hash of the bundle we actually serve
if (_kicks > 0) Console.WriteLine($"[rooms] lifted {_kicks} room ban(s) that were really kicks");
}
catch (Exception _kEx) { Console.WriteLine("[rooms] kick-ban lift skipped: " + _kEx.Message); }
try
{
int _orig = Campus_live_rewrite.api.dbs.RoomDB.LiftOriginalRoomBans();
if (_orig > 0) Console.WriteLine($"[rooms] lifted {_orig} room ban(s) on Rec Room Originals");
}
catch (Exception _oEx2) { Console.WriteLine("[rooms] original-room ban lift skipped: " + _oEx2.Message); }
// "Random bans": a Role_data.Banned entry with no active RoomBans record would still deny
// entry (the join gates read the role cache). Sweep those orphans so they don't stick.
try
{
int _orphans = Campus_live_rewrite.api.dbs.RoomDB.SweepOrphanBannedRoles();
if (_orphans > 0) Console.WriteLine($"[rooms] cleared {_orphans} orphan Banned role row(s) (no active ban record)");
}
catch (Exception _oEx) { Console.WriteLine("[rooms] orphan ban sweep skipped: " + _oEx.Message); }
// Resolve the account-lock exemptions (Coach/Kyle -> their current account ids) now that
// the admin-seed has run, so the owner's test accounts can share one Steam/device.
// LiteDB 5's BsonMapper builds an entity's member map lazily and registers the map
// BEFORE filling it; two requests hitting a not-yet-mapped type at once made the second
// see an empty map and throw "Member X not found on BsonMapper for type Y" (seen on
// ImageMetadataDB+Cheer, ClubDB+Club, ClubDB+ClubMember - random 500s on first use).
// Build every mapper here, single-threaded, before Kestrel accepts a request.
try
{
int warmed = 0;
foreach (var t in typeof(Program).Assembly.GetTypes())
{
if (!t.IsClass || t.IsAbstract || t.IsGenericTypeDefinition || t.IsNested && !t.IsNestedPublic && !t.IsNestedPrivate) continue;
// The global-namespace stores (ClubDB, ImageMetadataDB, InventionDB, InventionStore...)
// were skipped here, which is exactly where the race kept firing.
if (t.Namespace != null && !(t.Namespace.StartsWith("Campus_live_rewrite.api.dbs") || t.Namespace.StartsWith("Campus_live_rewrite.api.server"))) continue;
if (t.Name.EndsWith("Controller") || t.Name.EndsWith("Middleware")) continue;
if (t.GetConstructor(Type.EmptyTypes) == null) continue;
try { LiteDB.BsonMapper.Global.ToDocument(t, Activator.CreateInstance(t)!); warmed++; } catch { }
}
Console.WriteLine($"[litedb] pre-built {warmed} entity mappers");
}
catch (Exception ex) { Console.WriteLine($"[litedb] mapper warm-up failed: {ex.Message}"); }
// Nobody runs Orientation (ConfigCampus.SkipOrientation). Existing accounts that were
// parked mid-flow - tutorial mask 0, AccountCreation.* false - are settled here, once, so
// they stop being dropped back into it on login.
if (Campus_live_rewrite.api.server.ConfigCampus.SkipOrientation)
{
try
{
int _skipped = Campus_live_rewrite.api.dbs.SettingsDB.SkipOrientationForAllPlayers();
if (_skipped > 0) Console.WriteLine($"[orientation] marked complete for {_skipped} existing account(s)");
}
catch (Exception _oEx) { Console.WriteLine("[orientation] backfill skipped: " + _oEx.Message); }
}
try { Campus_live_rewrite.api.server.UploadGuard.LoadPenalties(); }
catch (Exception ex) { Console.WriteLine($"[upload-guard] load failed: {ex.Message}"); }
if (Campus_live_rewrite.api.server.ConfigCampus.UploadSweepOnStartup)
{
// A minute after boot so it never competes with startup; quarantines, never deletes.
_ = Task.Run(async () =>
{
await Task.Delay(TimeSpan.FromSeconds(60));
try { Campus_live_rewrite.api.server.UploadGuard.SweepStorage(dryRun: Campus_live_rewrite.api.server.ConfigCampus.UploadSweepDryRun); }
catch (Exception ex) { Console.WriteLine($"[upload-guard] sweep failed: {ex.Message}"); }
});
}
Campus_live_rewrite.api.server.CdnHost.Start();
try { Campus_live_rewrite.api.server.AccountLock.ResolveExemptions(); }
catch (Exception _lEx) { Console.WriteLine("[account-lock] exemption resolve skipped: " + _lEx.Message); }
app.Run();
//await botTask;
}
public static async Task StartBotAsync(IServiceProvider services)
{
var bot = services.GetRequiredService<DiscordBot>();
await bot.RunAsync();
}
public static void debug_run()
{
new CommandConsole();
}
}