using Microsoft.AspNetCore.Mvc; using Newtonsoft.Json; using System.IO.Compression; using Campus_live_rewrite.api; using Campus_live_rewrite.api.dbs; using Campus_live_rewrite.api.server; using Campus_live_rewrite.api.server.Classes.db_class; using CampusLiveSource.Controllers; using static Campus_live_rewrite.api.server.Moderation_Detail; namespace Campus_live_rewrite.Controllers { public class AdminController : Controller { private const string AuthCookieName = "Authorization"; /// /// Finds an admin HTML file whether the process runs from the deploy dir or not. /// /// The real routes are /admin/v4/login and /admin/home/v4; the file is served with /// Environment.CurrentDirectory, which is the systemd WorkingDirectory (/opt/campus). /// After a reinstall that directory can differ, so also try the assembly's own base /// directory - otherwise every admin page 404s even though the files shipped. /// private static string? FindAdminFile(params string[] relative) { foreach (var root in new[] { Environment.CurrentDirectory, AppContext.BaseDirectory }) { var p = Path.Combine(new[] { root }.Concat(relative).ToArray()); if (System.IO.File.Exists(p)) return p; } return null; } // Bare /admin and its common variants. There was no route for any of these, so the // most natural URL a person types - camprec.net/admin - 404'd, which reads as // "the admin panel is down". Send them to the panel: to the home page when they are // already an authenticated dev, otherwise to the login page. // "/admin" already matches "/admin/" (the matcher ignores the trailing slash), so a // separate "/admin/" template made both endpoints match "/admin/" -> AmbiguousMatchException // -> 500 on the bare entry point. One template covers both. [HttpGet("/admin")] [HttpGet("/admin/home")] [HttpGet("/admin/login")] public IActionResult AdminRoot() { var (_, error) = AuthHelper.ValidateTokenDev(HttpContext); return Redirect(error == null ? "/admin/home/v4" : "/admin/v4/login"); } /// /// The Discord sign-in callback (gameauth.ddns.net) cannot set a cookie for this host, so it hands the token over under a one-time /// ticket (AdminHandoff); this takes it and signs the browser in on admin.camprec.net. /// [HttpGet("/admin/discord-finish")] public IActionResult DiscordFinish([FromQuery] string? t) { string? jwt = Campus_live_rewrite.Middleware.AdminHandoff.Take(t); if (jwt == null) return Redirect("/admin/v4/login"); Response.Cookies.Append("Authorization", jwt, new Microsoft.AspNetCore.Http.CookieOptions { Expires = DateTime.Now.AddHours(24), HttpOnly = true, Secure = true, SameSite = Microsoft.AspNetCore.Http.SameSiteMode.Lax, Path = "/" }); return Redirect("/admin/home/v4"); } // NOTE: there is no separate visible gate page. The web token is just an extra field on the // admin login form (LoginV*.html) and is checked inside AdminLoginAPI below, so a person // only ever sees the normal login - with a token box on it. The login PAGE is reachable // without the gate cookie (that's where you enter the token); everything else on the admin // surface requires the gate cookie that a correct-token login stamps. [HttpGet("/admin/v{version}/login")] public IActionResult Login(int version) { var filePath = FindAdminFile("Web", "Admin", $"LoginV{version}.html"); if (filePath == null) return NotFound(); return PhysicalFile(filePath, "text/html"); } // Issues the two admin cookies - the ONE place either login path (plain password, or password + 2FA code) ends up, // so there is exactly one way an admin session is ever actually minted. No server-side Response.Redirect here: the // login page calls this through fetch(), which (like a browser) follows a 302 itself before JS ever sees the body - // the JSON response is what the page reads to decide where to navigate, so IT does the navigating once it sees success. private ContentResult IssueAdminSession(ulong accountId, object body) { string jwt = ClientSecurity.RefreshToken_Web(accountId, TimeSpan.FromHours(1), "admin"); Response.Cookies.Append(AuthCookieName, jwt, new Microsoft.AspNetCore.Http.CookieOptions { Expires = DateTime.UtcNow.AddHours(1), HttpOnly = true, Secure = true, SameSite = Microsoft.AspNetCore.Http.SameSiteMode.Strict }); Response.Cookies.Append(AdminGate.CookieName, AdminGate.Issue(), new Microsoft.AspNetCore.Http.CookieOptions { Expires = DateTime.UtcNow.Add(AdminGate.Ttl), HttpOnly = true, Secure = true, SameSite = Microsoft.AspNetCore.Http.SameSiteMode.Strict }); return new ContentResult { Content = JsonConvert.SerializeObject(body), ContentType = "text/html" }; } [HttpPost("/api/web/admin/login")] public ContentResult AdminLoginAPI([FromForm] string username, [FromForm] string password) { try { var loginResult = PlayerDB.GetAccountPassword(username, password, isAdminPanel: true); if (!loginResult.success) { return new ContentResult { Content = JsonConvert.SerializeObject(new { success = loginResult.success, error = loginResult.error }), ContentType = "text/html" }; } // Correct password. If this account turned 2FA on, that's only HALF the login - hold off on the admin // cookies and send back a ticket naming the account instead of a session. The browser then prompts for a // code and posts it to /api/web/admin/login/2fa, which is the only place the cookies actually get issued // for a 2FA account (see IssueAdminSession above). if (TwoFactorDB.IsEnabled(loginResult.accid)) { Console.WriteLine($"[admin-2fa] {username} ({loginResult.accid}): password ok, code required"); return new ContentResult { Content = JsonConvert.SerializeObject(new { success = false, need2fa = true, pending = PendingTwoFactorLogin.Issue(loginResult.accid, "admin") }), ContentType = "text/html" }; } // Admin panel is 2FA-only: a correct password with NO 2FA enrolled does not get a session either - it // gets funneled into forced enrollment instead (AdminSetup2FABegin/Confirm below), off the SAME kind of // short-lived ticket, just on a different `surface` ("admin-setup") so it can't be replayed against the // real /login/2fa endpoint. No session cookie exists until ConfirmEnroll succeeds, so there's no window // where a password-only login reaches the dashboard. Console.WriteLine($"[admin-2fa] {username} ({loginResult.accid}): password ok, 2FA not set up - forcing enrollment"); return new ContentResult { Content = JsonConvert.SerializeObject(new { success = false, needSetup = true, pending = PendingTwoFactorLogin.Issue(loginResult.accid, "admin-setup") }), ContentType = "text/html" }; } catch (Exception ex) { Console.WriteLine($"[AdminLoginAPI] Exception: {ex}"); return new ContentResult { Content = "Invalid username or password. Please try again.", ContentType = "text/html" }; } } /// Step 2 of a 2FA account's login: the ticket from AdminLoginAPI, plus a code from their authenticator /// app (or one of their recovery codes). Wrong-code attempts count toward the same IP lockout a wrong password /// does, so this can't be brute-forced any more easily than the password itself. [HttpPost("/api/web/admin/login/2fa")] public ContentResult AdminLogin2FA([FromForm] string pending, [FromForm] string code) { string clientIp = LoginGuard.ClientIp(HttpContext); if (LoginGuard.IsLockedOut(clientIp, out int retry)) return new ContentResult { Content = JsonConvert.SerializeObject(new { success = false, error = $"Too many attempts - try again in {retry}s." }), ContentType = "text/html" }; ulong accountId = PendingTwoFactorLogin.Validate(pending, "admin"); if (accountId == 0) return new ContentResult { Content = JsonConvert.SerializeObject(new { success = false, error = "That login attempt expired - log in again." }), ContentType = "text/html" }; if (!TwoFactorDB.VerifyLogin(accountId, code)) { LoginGuard.RecordFailure(clientIp); Console.WriteLine($"[admin-2fa] account {accountId}: wrong code from {clientIp}"); return new ContentResult { Content = JsonConvert.SerializeObject(new { success = false, need2fa = true, pending, error = "Wrong code." }), ContentType = "text/html" }; } LoginGuard.RecordSuccess(clientIp); Console.WriteLine($"[admin-2fa] account {accountId}: code accepted"); return IssueAdminSession(accountId, new { success = true, error = (string?)null, accid = accountId }); } /// Step 2 for an admin account that does NOT have 2FA set up yet (see the `needSetup` branch of /// AdminLoginAPI): the "admin-setup" ticket already proves the password was correct, so this starts enrollment /// off the ticket alone - no session exists yet, matching the "no 2FA, no admin panel" rule. Mirrors /// TwoFactorSetup() below but takes the account id from the ticket instead of a logged-in session. [HttpPost("/api/web/admin/login/setup2fa/begin")] public ContentResult AdminSetup2FABegin([FromForm] string pending) { ulong accountId = PendingTwoFactorLogin.Validate(pending, "admin-setup"); if (accountId == 0) return new ContentResult { Content = JsonConvert.SerializeObject(new { success = false, error = "That login attempt expired - log in again." }), ContentType = "text/html" }; string username = PlayerDB.GetFullAccount(accountId)?.player?.Username ?? ("admin" + accountId); string secret = TwoFactorDB.BeginEnroll(accountId); return new ContentResult { Content = JsonConvert.SerializeObject(new { success = true, secret, otpauth = Totp.OtpAuthUri(secret, username) }), ContentType = "application/json" }; } /// Confirms the code from AdminSetup2FABegin and, on success, mints the real admin session - the /// first and only cookie issuance for an account that just enrolled through forced setup. [HttpPost("/api/web/admin/login/setup2fa/confirm")] public ContentResult AdminSetup2FAConfirm([FromForm] string pending, [FromForm] string code) { ulong accountId = PendingTwoFactorLogin.Validate(pending, "admin-setup"); if (accountId == 0) return new ContentResult { Content = JsonConvert.SerializeObject(new { success = false, error = "That login attempt expired - log in again." }), ContentType = "text/html" }; var codes = TwoFactorDB.ConfirmEnroll(accountId, code ?? ""); if (codes == null) return new ContentResult { Content = JsonConvert.SerializeObject(new { success = false, error = "Wrong code - try the current 6 digits from your app." }), ContentType = "text/html" }; Console.WriteLine($"[admin-2fa] account {accountId}: 2FA set up during forced enrollment, session issued"); return IssueAdminSession(accountId, new { success = true, error = (string?)null, accid = accountId, recoveryCodes = codes }); } // ---- 2FA enrollment (the signed-in admin managing THEIR OWN account, from the Settings tab) -------------------- [HttpGet("/admin/api/2fa/status")] public ContentResult TwoFactorStatus() { var (account, error) = AuthHelper.ValidateTokenDev(HttpContext); if (error != null) return error; return new ContentResult { Content = JsonConvert.SerializeObject(new { success = true, enabled = TwoFactorDB.IsEnabled(account!.Id) }), ContentType = "application/json" }; } /// Starts enrollment: a fresh secret, not yet active. The admin panel shows it as text (and an /// otpauth:// URI) for the admin to add to their authenticator app, then calls /confirm with a real code. [HttpPost("/admin/api/2fa/setup")] public ContentResult TwoFactorSetup() { var (account, error) = AuthHelper.ValidateTokenDev(HttpContext); if (error != null) return error; string secret = TwoFactorDB.BeginEnroll(account!.Id); return new ContentResult { Content = JsonConvert.SerializeObject(new { success = true, secret, otpauth = Totp.OtpAuthUri(secret, account.Username) }), ContentType = "application/json" }; } [HttpPost("/admin/api/2fa/confirm")] public ContentResult TwoFactorConfirm([FromBody] TwoFactorCodeBody body) { var (account, error) = AuthHelper.ValidateTokenDev(HttpContext); if (error != null) return error; var codes = TwoFactorDB.ConfirmEnroll(account!.Id, body?.Code ?? ""); if (codes == null) return new ContentResult { Content = JsonConvert.SerializeObject(new { success = false, message = "Wrong code - try the current 6 digits from your app." }), ContentType = "application/json" }; Console.WriteLine($"[admin-2fa] {account.Username} ({account.Id}) turned on 2FA"); return new ContentResult { Content = JsonConvert.SerializeObject(new { success = true, recoveryCodes = codes }), ContentType = "application/json" }; } [HttpPost("/admin/api/2fa/disable")] public ContentResult TwoFactorDisable() { var (account, error) = AuthHelper.ValidateTokenDev(HttpContext); if (error != null) return error; TwoFactorDB.Disable(account!.Id); Console.WriteLine($"[admin-2fa] {account.Username} ({account.Id}) turned 2FA off"); return new ContentResult { Content = JsonConvert.SerializeObject(new { success = true }), ContentType = "application/json" }; } public class TwoFactorCodeBody { public string? Code { get; set; } } [HttpGet("/admin/home/v{version}")] public IActionResult AdminHomeVVersion(int version) { // Dev OR mod: the page shell itself has to load for either role - which tabs/data a mod // actually gets is enforced per-endpoint below and hidden client-side (HomeV4.html whoami). var (account, error) = AuthHelper.ValidateTokenDevOrMod(HttpContext); if (error != null) { return Redirect("/admin/v4/login"); } var filePath = FindAdminFile("Web", "Admin", "Home", $"HomeV{version}.html"); if (filePath == null) return NotFound(); return PhysicalFile(filePath, "text/html"); } /// The page's own self-role check - HomeV4.html calls this on load to decide which sidebar /// tabs to show. Mods get a fixed, server-decided slice (Dashboard, Players, Reports, Ban Logs, Chat); /// this endpoint just tells the client which one it's looking at, it doesn't grant anything by itself - /// every actual data/action endpoint enforces its own role independently. [HttpGet("/admin/api/whoami")] public ContentResult AdminWhoAmI() { var (account, error) = AuthHelper.ValidateTokenDevOrMod(HttpContext); if (error != null) return error; bool isDev = PlayerDB.CheckDevFlag(account!.Id); return Json(new { success = true, username = account.Username, isDev, isMod = !isDev }); } [Route("/admin/api/dashboard_data")] public ContentResult DashboardData() { var (account, error) = AuthHelper.ValidateTokenDevOrMod(HttpContext); if (error != null) return error; // Each count is guarded on its own. A single DB hiccup (a checkpoint mid-read, a // locked collection) used to throw here and 500 the WHOLE dashboard, leaving every // card stuck - one bad count should cost one number, not the page. -1 marks a count // that could not be read. int Safe(Func f) { try { return f(); } catch (Exception ex) { Console.WriteLine($"[dashboard] count failed: {ex.Message}"); return -1; } } int OnlinePlayers = Safe(() => NotificationsController.WebSockets.Count); string AdminMOTD = "Go wild like Campus!"; long bytesUsed = GC.GetTotalMemory(false); return new ContentResult { Content = JsonConvert.SerializeObject(new { total_players = Safe(() => (int)PlayerDB.GetPlayerCount()), online_players = OnlinePlayers, banned_players = 0, total_rooms = Safe(() => (int)RoomDB.GetAllRoomsCount()), total_inventions = Safe(() => InventionDB.Collection().Count()), mem_usage = FormatBytes(bytesUsed), total_clubs = Safe(() => ClubDB.CountAll()), MOTD = AdminMOTD, diskspace = 0, quick_link_sidebar = new List { new { Title = "Reports", Button_Title = "View Reports", Button_url = "/admin/reports/all", Button_icon = "", } }, // Quick links open panel TABS ("#Reports" -> the Reports tab, see the page's // GoToPage). The old ones pointed at pages that don't exist (/admin/reports/all, // /campus_net/home) or at placeholder text. // Gifts is dev-only (mods never get that tab), so its tile is left out entirely for a // mod account rather than shown as a dead link - filtered server-side here so the // client doesn't need its own copy of this role rule. quick_link = (PlayerDB.CheckDevFlag(account!.Id) ? new List { new { Title = "Reports", disc = "Open player reports and bug reports.", Button_Title = "Open Reports", Button_url = "#Reports" }, new { Title = "Ban Logs", disc = "Every ban, unban, kick and warning.", Button_Title = "Open Ban Logs", Button_url = "#BanLogs" }, new { Title = "Gifts", disc = "Give items or skins to a player.", Button_Title = "Open Gifts", Button_url = "#Gifts" }, new { Title = "Website", disc = "The Campus site players see.", Button_Title = "Open site", Button_url = "/" } } : new List { new { Title = "Reports", disc = "Open player reports and bug reports.", Button_Title = "Open Reports", Button_url = "#Reports" }, new { Title = "Ban Logs", disc = "Every ban, unban, kick and warning.", Button_Title = "Open Ban Logs", Button_url = "#BanLogs" }, new { Title = "Website", disc = "The Campus site players see.", Button_Title = "Open site", Button_url = "/" } }), }), ContentType = "application/json" }; } [HttpGet("/admin/api/players")] public ContentResult AdminGetPlayersAPI() { var (account, error) = AuthHelper.ValidateTokenDevOrMod(HttpContext); if (error != null) return error; var query = HttpContext.Request.Query; string? id = query["id"]; string? displayName = query["displayName"]; string? minLevel = query["minLevel"]; string? jrState = query["jrState"]; string? platformId = query["platformId"]; string? deviceId = query["deviceId"].FirstOrDefault(); bool onlyBannedPlayers = bool.TryParse(query["banned"], out var b) && b; var players = PlayerDB.Find_all_player_filtered(id, displayName, minLevel, jrState, platformId, deviceId, onlyBannedPlayers); // Enrich each row with live presence so the panel can show WHERE a player is - the room // name, its instance id and the room id - and whether they're online. Read-only peek so // enumerating everyone doesn't mutate heartbeats. A player who is nowhere gets nulls. var arr = Newtonsoft.Json.Linq.JArray.FromObject(players); foreach (var row in arr.OfType()) { ulong pid = (ulong?)row["AccountId"] ?? 0; var (online, room) = HeartbeatDB.PeekPresence(pid); row["IsOnline"] = online; row["RoomName"] = room?.name; row["RoomInstanceId"] = room != null ? room.roomInstanceId : (long?)null; row["RoomId"] = room != null ? room.roomId : (long?)null; row["SubRoomId"] = room != null ? room.subRoomId : (long?)null; row["RoomIsPrivate"] = room?.isPrivate ?? false; } return new ContentResult { Content = arr.ToString(Newtonsoft.Json.Formatting.None), ContentType = "application/json" }; } // Force join: drop the JOINER into the TARGET's current room instance. Same mechanism as the // !goto teleport - the joiner's heartbeat is set to the target's exact instance, so on its // next heartbeat the joiner's client walks into that room (same instance = same Photon room = // they end up together). // joinerId = the person being moved (the one who "joins") // targetId = the person whose room they land in (the one "being joined") [HttpPost("/admin/api/force_join")] public ContentResult AdminForceJoin([FromForm] ulong joinerId, [FromForm] ulong targetId) { var (account, error) = AuthHelper.ValidateTokenDev(HttpContext); if (error != null) return new ContentResult { Content = JsonConvert.SerializeObject(new { success = false, error = "unauthorized" }), ContentType = "application/json", StatusCode = 401 }; ContentResult Fail(string msg) => new ContentResult { Content = JsonConvert.SerializeObject(new { success = false, error = msg }), ContentType = "application/json", StatusCode = 200 }; if (joinerId == 0 || targetId == 0) return Fail("Pick both players."); if (joinerId == targetId) return Fail("Joiner and target are the same player."); var (targetOnline, room) = HeartbeatDB.PeekPresence(targetId); if (room == null) return Fail("Target isn't in a room right now - nowhere to send the joiner."); // Land the joiner in the target's EXACT instance only when it's a joinable PUBLIC one - // then they end up together on the same Photon room. A PRIVATE instance (a Dorm) is // single-occupant: the client would try, fail to load, and bounce home (the "takes ages // then fails" symptom). Say so up front instead. For a public room we also accept a // sibling joinable instance if the exact one somehow isn't usable. // Admin override: the joiner is dropped into the target's EXACT instance, public or // private (a dorm, a private-instance game, an invite-only room). Two players meet when // their presence carries the same Photon room name, and a private instance has one just // like a public one - the old refusal for private rooms had no technical reason behind // it. Room bans and private-room roles are not consulted: this is the admin's call. HeartbeatDB.RoomInstance dest; if (!string.IsNullOrEmpty(room.photonRoomIdYeee)) { dest = room; } else { // An instance with no Photon room name can't be shared (nothing to meet in) - fall // back to any joinable public instance of the same room. var joinable = HeartbeatDB.FindJoinableInstance((ulong)room.roomId, joinerId); if (joinable == null) return Fail("Target's instance has no shareable session yet - try again in a few seconds."); dest = joinable; } if (dest.maxCapacity > 0) { int occupants = HeartbeatDB.CountInInstance(dest.roomInstanceId); if (occupants >= dest.maxCapacity) Console.WriteLine($"[force_join] instance {dest.roomInstanceId} is at capacity ({occupants}/{dest.maxCapacity}) - admin override, joining anyway"); } HeartbeatDB.UpdatePlayerHeartbeat(joinerId, dest, online: true); Console.WriteLine($"[force_join] admin moving {joinerId} -> {targetId}'s instance {dest.roomInstanceId} ({dest.name})"); return new ContentResult { Content = JsonConvert.SerializeObject(new { success = true, error = "", joinerId, targetId, roomName = dest.name, roomInstanceId = dest.roomInstanceId, roomId = dest.roomId, note = "The joiner moves on their next heartbeat (up to ~20s) - that delay is the client's poll cadence." }), ContentType = "application/json", StatusCode = 200 }; } /// /// Imports rooms from a JSON file placed in Data/Imports. /// /// The file is the shape Rec Room's own room API returns - { "Results": [ ...rooms... ] } /// - which is what RoomDB.ImportRooms already expects. Capture a room (with its /// subrooms) from the real service, save the response, drop it in Data/Imports, and /// import it here. This used to be possible only by uncommenting a line in Program.cs /// and rebuilding. /// /// Importing brings the room and its subrooms in, INCLUDING each save's DataBlob /// filename - but not the blob itself. The response therefore lists every .room file /// the imported rooms reference and whether it is present in Data/CDN/RoomBlobs, so /// you know exactly which ones still need downloading. A save whose blob is missing is /// hidden by FilterUnservableSaves until the file appears; drop it in and the room /// starts loading its build with no code change. /// /// Only reads from Data/Imports - a path from the request is never trusted. /// [HttpPost("/admin/api/import_rooms")] public async Task AdminImportRooms() { var (account, error) = AuthHelper.ValidateTokenDev(HttpContext); if (error != null) return error; string file = (Request.Form["file"].ToString() ?? "").Trim(); if (string.IsNullOrWhiteSpace(file)) return Json(new { success = false, message = "No file given." }); // Filename only - no directory traversal. file = Path.GetFileName(file); string dir = Path.Combine(Environment.CurrentDirectory, "Data", "Imports"); string path = Path.Combine(dir, file); if (!System.IO.File.Exists(path)) return Json(new { success = false, message = $"Data/Imports/{file} not found." }); ulong before = RoomDB.GetAllRoomsCount(); try { await RoomDB.ImportRooms(path); } catch (Exception ex) { Console.WriteLine($"[import] {file} failed: {ex.Message}"); return Json(new { success = false, message = $"Import failed: {ex.Message}" }); } ulong after = RoomDB.GetAllRoomsCount(); // Which .room blobs do the imported rooms now reference, and do we have them? string blobDir = Path.Combine(Environment.CurrentDirectory, "Data", "CDN", "RoomBlobs"); var missing = new List(); var present = new List(); foreach (var room in RoomDB.GetAllRooms()) { foreach (var sub in room.SubRooms ?? new List()) { foreach (var save in sub.AllSaves ?? new List()) { string? blob = save.DataBlob; if (string.IsNullOrWhiteSpace(blob)) continue; var fi = new FileInfo(Path.Combine(blobDir, blob)); if (fi.Exists && fi.Length > 0) { if (!present.Contains(blob)) present.Add(blob); } else if (!missing.Contains(blob)) missing.Add(blob); } } } Console.WriteLine($"[import] {file}: rooms {before} -> {after}; " + $"{present.Count} blob(s) present, {missing.Count} missing"); return Json(new { success = true, message = $"Imported {after - before} room(s) from {file}. " + $"{missing.Count} .room blob(s) still needed.", roomsBefore = before, roomsAfter = after, blobsPresent = present.Count, blobsMissing = missing }); } // Drag-and-drop upload (admin "Rooms" tab). Accepts any mix of dropped files: // • a .json room export -> imported as rooms OWNED BY @Importer (ids reassigned) // • a .room / .META blob -> dropped into Data/CDN/RoomBlobs (makes saved builds load) // Does the whole thing in one request and reports what was imported + what blobs are still // missing. This is the one-shot version of drop-into-Imports + POST import_rooms. [HttpPost("/admin/api/import_upload")] [DisableRequestSizeLimit] [RequestFormLimits(MultipartBodyLengthLimit = long.MaxValue)] public async Task AdminImportUpload() { var (account, error) = AuthHelper.ValidateTokenDev(HttpContext); if (error != null) return error; if (!Request.HasFormContentType || Request.Form.Files.Count == 0) return Json(new { success = false, message = "No files uploaded." }); ulong importerId = PlayerDB.GetAccountIdByUsernameCI("Importer"); if (importerId == 0) return Json(new { success = false, message = "The @Importer account does not exist yet (restart the server to seed it)." }); string importsDir = Path.Combine(Environment.CurrentDirectory, "Data", "Imports"); string blobDir = Path.Combine(Environment.CurrentDirectory, "Data", "CDN", "RoomBlobs"); Directory.CreateDirectory(importsDir); Directory.CreateDirectory(blobDir); ulong before = RoomDB.GetAllRoomsCount(); var importedFiles = new List(); var blobFiles = new List(); var errors = new List(); int avatarItemsImported = 0; int exportRooms = 0, exportBlobs = 0; var roomNames = new List(); string imagesDir = Path.Combine(Environment.CurrentDirectory, "Data", "Images"); Directory.CreateDirectory(imagesDir); // Copy one extracted file to the right place by kind. Images -> Data/Images, // logs/readme/descriptor -> skipped, everything else (.room/.META/.binpb/subroom data) // -> RoomBlobs so whatever a save's DataBlob names is present. void RouteFile(string file) { string bn = Path.GetFileName(file); string ext = Path.GetExtension(file).ToLowerInvariant(); if (ext is ".jpg" or ".jpeg" or ".png") { System.IO.File.Copy(file, Path.Combine(imagesDir, bn), true); return; } if (ext is ".log" || bn.Equals("README.txt", StringComparison.OrdinalIgnoreCase)) return; if (ext == ".json") return; // handled explicitly (RoomDetails.json) System.IO.File.Copy(file, Path.Combine(blobDir, bn), true); if (!blobFiles.Contains(bn)) blobFiles.Add(bn); } // Host a single loose .room blob and, if nothing already references it and it's old enough // for the client, wrap it in a joinable @Importer room. Shared by the single-file drop and // the "zip full of .room files" path. `onDiskPath` is where the file already sits on disk. void WrapLooseRoomFile(string onDiskPath) { string bn = Path.GetFileName(onDiskPath); if (!blobFiles.Contains(bn)) blobFiles.Add(bn); var (status, id, ver, roomName, err) = RoomDB.WrapLooseRoom(onDiskPath, importerId); switch (status) { case RoomDB.LooseWrapStatus.Created: exportRooms++; roomNames.Add(roomName); importedFiles.Add($"{bn} → new room '{roomName}' (id {id}, save v{ver})"); break; case RoomDB.LooseWrapStatus.TooNew: errors.Add($"{bn}: save version {ver} is newer than this client can load (max {Campus_live_rewrite.api.server.ConfigCampus.ClientMaxRoomPersistenceVersion}) — blob hosted, no room created (it would freeze/kick to dorm)."); break; case RoomDB.LooseWrapStatus.NameExists: errors.Add($"{bn}: hosted the blob but a room named '{roomName}' already exists (rename the file to make another)."); break; case RoomDB.LooseWrapStatus.Error: errors.Add($"{bn}: {err}"); break; // AlreadyUsed -> silently fine (blob already linked to a room) } } foreach (var f in Request.Form.Files) { string name = Path.GetFileName(f.FileName ?? ""); if (string.IsNullOrWhiteSpace(name)) continue; string ext = Path.GetExtension(name).ToLowerInvariant(); try { if (ext == ".zip") { // Full room-export zip: extract, place blobs+image, import RoomDetails.json. string zipPath = Path.Combine(importsDir, name); using (var fs = new FileStream(zipPath, FileMode.Create, FileAccess.Write)) await f.CopyToAsync(fs); string exDir = Path.Combine(importsDir, Path.GetFileNameWithoutExtension(name) + "_" + DateTime.UtcNow.ToString("yyyyMMddHHmmss")); Directory.CreateDirectory(exDir); ZipFile.ExtractToDirectory(zipPath, exDir, overwriteFiles: true); // A UGC avatar-item pack (folders of avatarItem.json + .assetbundle + .png)? // Route it to the avatar importer: host bundles -> AvatarBlobs, thumbs/textures // -> Images, and add each item to the UGC catalog / dev locker. if (Campus_live_rewrite.api.server.AvatarItemImporter.LooksLikeAvatarPack(exDir)) { var ar = Campus_live_rewrite.api.server.AvatarItemImporter.ImportDir(exDir); importedFiles.Add($"{name} → {ar.Items} avatar item(s), {ar.BundlesHosted} bundle(s), {ar.ThumbsHosted} image(s)"); foreach (var m in ar.MissingAssets.Distinct().Take(50)) errors.Add($"{name}: missing asset {m}"); foreach (var e in ar.Errors.Take(50)) errors.Add($"{name}: {e}"); avatarItemsImported += ar.Items; } else if (RoomDB.LooksLikeRoomExport(exDir)) { // Rec Room's OFFICIAL room export (RoomDetails.json single object + // super_room_data.binpb + SubRoom_*/persisted_room_data.binpb). Place the // .binpb blobs under their DataBlob names (hash-matched) so the room loads // its actual build, not a bare scene. var rr = await RoomDB.ImportRoomExport(exDir, ownerOverride: importerId); exportRooms += rr.Rooms; exportBlobs += rr.Blobs; roomNames.AddRange(rr.Names); importedFiles.Add($"{name} → {rr.Rooms} room(s): {string.Join(", ", rr.Names.Take(10))}; {rr.Blobs} blob(s), {rr.Images} image(s)"); foreach (var e in rr.Errors.Take(50)) errors.Add($"{name}: {e}"); } else if (Directory.EnumerateFiles(exDir, "*.room", SearchOption.AllDirectories).Any()) { // A zip that's just a bunch of loose .room blobs (no RoomDetails.json). Wrap // every loadable one into its own joinable room; too-new ones are hosted + // reported. Any non-.room files (.META room-level blobs, images) are hosted. var roomFiles = Directory.EnumerateFiles(exDir, "*.room", SearchOption.AllDirectories).ToList(); foreach (var file in Directory.EnumerateFiles(exDir, "*", SearchOption.AllDirectories)) if (!file.EndsWith(".room", StringComparison.OrdinalIgnoreCase)) RouteFile(file); foreach (var rf in roomFiles) WrapLooseRoomFile(rf); importedFiles.Add($"{name} → scanned {roomFiles.Count} loose .room file(s)"); } else { // Legacy path: the server's own {Results:[...]} dump whose .room/.meta blobs // are already present as loose, correctly-named files. foreach (var file in Directory.EnumerateFiles(exDir, "*", SearchOption.AllDirectories)) RouteFile(file); var manifest = Directory.EnumerateFiles(exDir, "RoomDetails.json", SearchOption.AllDirectories).FirstOrDefault() ?? Directory.EnumerateFiles(exDir, "*.json", SearchOption.AllDirectories).FirstOrDefault(); if (manifest != null) { await RoomDB.ImportRooms(manifest, ownerOverride: importerId); importedFiles.Add(name + " → " + Path.GetFileName(manifest)); } else errors.Add($"{name}: no RoomDetails.json, avatarItem.json, or .room files found in the zip"); } } else if (ext == ".json") { string dest = Path.Combine(importsDir, name); using (var fs = new FileStream(dest, FileMode.Create, FileAccess.Write)) await f.CopyToAsync(fs); await RoomDB.ImportRooms(dest, ownerOverride: importerId); importedFiles.Add(name); } else { // Host the loose blob on the CDN. string dest = Path.Combine(blobDir, name); using (var fs = new FileStream(dest, FileMode.Create, FileAccess.Write)) await f.CopyToAsync(fs); if (!blobFiles.Contains(name)) blobFiles.Add(name); // A loose .room blob is a single subroom's build with no room record. Wrap it // in a joinable @Importer room (if loadable). .META / other blobs are just hosted // (they're room-level / referenced assets, not standalone rooms). if (ext == ".room") WrapLooseRoomFile(dest); } } catch (Exception ex) { errors.Add($"{name}: {ex.Message}"); } } ulong after = RoomDB.GetAllRoomsCount(); // Which .room blobs are still missing across ALL @Importer rooms? var missing = new List(); foreach (var room in RoomDB.GetAllRooms()) foreach (var sub in room.SubRooms ?? new List()) foreach (var save in sub.AllSaves ?? new List()) { var blob = save.DataBlob; if (string.IsNullOrWhiteSpace(blob)) continue; var fi = new FileInfo(Path.Combine(blobDir, blob)); if ((!fi.Exists || fi.Length == 0) && !missing.Contains(blob)) missing.Add(blob); } Console.WriteLine($"[import-upload] {account.Username}: {importedFiles.Count} file(s), export {exportRooms} room(s)/{exportBlobs} blob(s), {blobFiles.Count} loose blob(s), {avatarItemsImported} avatar item(s); rooms {before}->{after}; {missing.Count} blob(s) still missing"); // Build a message from what ACTUALLY happened. The old line only counted legacy loose // blobs, so a fully-successful room-export import (which places blobs via the CDN path, // not blobFiles) reported "Saved 0 blob(s)" and looked broken. var parts = new List(); if (exportRooms > 0) parts.Add($"Imported {exportRooms} room(s){(roomNames.Count > 0 ? " (" + string.Join(", ", roomNames.Take(5)) + ")" : "")} under @Importer with {exportBlobs} build blob(s) placed on the CDN"); else if (exportBlobs > 0) parts.Add($"Re-placed {exportBlobs} build blob(s) — that room was already imported (rooms are keyed by name, so re-dropping the same export doesn't duplicate it)"); if (avatarItemsImported > 0) parts.Add($"Imported {avatarItemsImported} avatar item(s) into the dev locker ({CustomItemsCache.ImportedCount} total)"); if (blobFiles.Count > 0) parts.Add($"Saved {blobFiles.Count} loose blob(s)"); if (parts.Count == 0) parts.Add($"Processed {importedFiles.Count} file(s) but imported nothing — is this a room export (RoomDetails.json + *room_data*.binpb), a UGC pack (avatarItem.json), or a legacy JSON dump?"); string message = string.Join(". ", parts) + "."; if (missing.Count > 0) message += $" Note: {missing.Count} .room blob(s) referenced by imported rooms aren't on the CDN yet (from other/older imports)."; return Json(new { success = true, message, roomsImported = exportRooms, roomBlobsPlaced = exportBlobs, roomNames, avatarItemsImported, avatarItemsTotal = CustomItemsCache.ImportedCount, jsonFiles = importedFiles, blobFiles, blobsMissing = missing, errors }); } /// /// Bulk-import UGC avatar packs already staged ON the server, WITHOUT a browser upload. /// This is the path for a large library (e.g. the 178GB archive): rsync/scp the extracted /// packs into Data/AvatarImportStaging/ on the box, then POST here. Scans that fixed folder /// (no caller-supplied path - no traversal surface), hosts bundles+images, and registers /// every avatarItem.json into the UGC catalog / dev locker. /// [HttpPost("/admin/api/import_avatar_staging")] public IActionResult AdminImportAvatarStaging() { var (account, error) = AuthHelper.ValidateTokenDev(HttpContext); if (error != null) return error; string staging = Path.Combine(Environment.CurrentDirectory, "Data", "AvatarImportStaging"); Directory.CreateDirectory(staging); if (!Campus_live_rewrite.api.server.AvatarItemImporter.LooksLikeAvatarPack(staging)) return Json(new { success = false, message = $"No avatarItem.json found under Data/AvatarImportStaging. rsync your extracted UGC packs there first ({staging})." }); // Assets are MOVED out of staging into their served folders (see ImportDir), so the // pack doesn't need to exist twice on the box; the staging tree can be deleted after. var r = Campus_live_rewrite.api.server.AvatarItemImporter.ImportDir(staging, moveAssets: true); Console.WriteLine($"[import-avatar-staging] {account.Username}: {r.Items} items, {r.BundlesHosted} bundles, {r.ThumbsHosted} images, {r.MissingAssets.Count} missing"); // Bundles from a NEWER Unity than the clients (PC 2021.3 / Quest 2020.3) can never load, // however correct the metadata is - say so up front rather than let the wardrobe find out. int unloadable = r.BundleUnityVersions.Where(kv => Campus_live_rewrite.api.server.AvatarItemImporter.IsUnloadableUnityVersion(kv.Key)).Sum(kv => kv.Value); string versions = string.Join(", ", r.BundleUnityVersions.Select(kv => $"{kv.Key} x{kv.Value}")); string warn = unloadable > 0 ? $" WARNING: {unloadable} bundle(s) were built with a newer Unity than the game clients can load (clients: PC 2021.3.41f1, Quest 2020.3.33f1) - those items will show in the locker but fail to render. Bundle versions: {versions}." : (r.BundlesHosted > 0 ? $" Bundle versions: {versions}." : ""); if (unloadable > 0) Console.WriteLine($"[import-avatar-staging] {unloadable} bundle(s) unloadable by the clients: {versions}"); return Json(new { success = true, message = $"Imported {r.Items} avatar item(s) into the dev locker ({CustomItemsCache.ImportedCount} total). Hosted {r.BundlesHosted} bundle(s), {r.ThumbsHosted} image(s).{warn}", bundleUnityVersions = r.BundleUnityVersions, unloadableBundles = unloadable, avatarItemsImported = r.Items, avatarItemsTotal = CustomItemsCache.ImportedCount, bundlesHosted = r.BundlesHosted, imagesHosted = r.ThumbsHosted, missingAssets = r.MissingAssets.Distinct().Take(100), errors = r.Errors.Take(100) }); } /// /// Every .room / .META blob in Data/CDN/RoomBlobs, and which subroom (if any) points /// at it. /// /// Rec Room is gone, so no blob can ever be downloaded again - the files already on /// disk are the only room content that will ever exist. Several of them are ORPHANS: /// real saved builds that no subroom references, because the save record was lost /// while the file survived. A room whose save is missing loads its bare Unity scene, /// so an orphan is a room's contents sitting there doing nothing. /// /// Zero-byte files are listed separately: they are failed downloads from the old CDN /// caching bug, not content, and are treated as missing everywhere. /// [HttpGet("/admin/api/room_blobs")] public ContentResult AdminListRoomBlobs() { var (account, error) = AuthHelper.ValidateTokenDev(HttpContext); if (error != null) return error; string dir = Path.Combine(Environment.CurrentDirectory, "Data", "CDN", "RoomBlobs"); if (!Directory.Exists(dir)) return Json(new { success = false, message = "Data/CDN/RoomBlobs does not exist." }); // blob filename -> "room 84 / subroom 748" var referencedBy = new Dictionary(StringComparer.OrdinalIgnoreCase); foreach (var room in RoomDB.GetAllRooms()) { foreach (var sub in room.SubRooms ?? new List()) { foreach (var save in sub.AllSaves ?? new List()) if (!string.IsNullOrWhiteSpace(save.DataBlob)) referencedBy[save.DataBlob!] = $"room {room.RoomId} ({room.Name}) / subroom {sub.SubRoomId}"; if (sub.CurrentSave != null && !string.IsNullOrWhiteSpace(sub.CurrentSave.DataBlob)) referencedBy[sub.CurrentSave.DataBlob!] = $"room {room.RoomId} ({room.Name}) / subroom {sub.SubRoomId}"; } if (!string.IsNullOrWhiteSpace(room.DataBlob)) referencedBy[room.DataBlob!] = $"room {room.RoomId} ({room.Name}) [room-level]"; } var blobs = new DirectoryInfo(dir).GetFiles() .OrderByDescending(f => f.Length) .Select(f => new { name = f.Name, size = f.Length, empty = f.Length == 0, modified = f.LastWriteTimeUtc, usedBy = referencedBy.TryGetValue(f.Name, out var who) ? who : null }) .ToList(); return Json(new { success = true, total = blobs.Count, empty = blobs.Count(b => b.empty), orphans = blobs.Count(b => !b.empty && b.usedBy == null), blobs }); } /// /// Points a room's subroom at an existing blob, i.e. records it as that subroom's /// save. This is how an orphaned build gets reconnected to a room. /// /// The blob must already exist and be non-empty. Nothing is downloaded and no file is /// written - only the save record changes, so getting it wrong costs nothing: attach a /// different blob, or attach the right one to a different room. /// [HttpPost("/admin/api/attach_blob")] public ContentResult AdminAttachBlob() { var (account, error) = AuthHelper.ValidateTokenDev(HttpContext); if (error != null) return error; string blob = Path.GetFileName((Request.Form["blob"].ToString() ?? "").Trim()); if (string.IsNullOrWhiteSpace(blob)) return Json(new { success = false, message = "No blob given." }); if (!ulong.TryParse(Request.Form["roomId"].ToString(), out ulong roomId)) return Json(new { success = false, message = "Invalid room id." }); var room = RoomDB.GetRoom(roomId); if (room == null) return Json(new { success = false, message = $"No room {roomId}." }); // Default to the room's only/first subroom - these rooms have exactly one. ulong subRoomId; string rawSub = Request.Form["subRoomId"].ToString() ?? ""; if (!ulong.TryParse(rawSub, out subRoomId) || subRoomId == 0) { var first = (room.SubRooms ?? new List()).FirstOrDefault(); if (first == null) return Json(new { success = false, message = $"Room {roomId} has no subrooms." }); subRoomId = first.SubRoomId; } var fi = new FileInfo(Path.Combine(Environment.CurrentDirectory, "Data", "CDN", "RoomBlobs", blob)); if (!fi.Exists || fi.Length == 0) return Json(new { success = false, message = $"{blob} is missing or empty in Data/CDN/RoomBlobs." }); RoomDB.SetRoomSubroomRoomFile( roomId, subRoomId, blob, room.Description ?? "", account.Id, Platforms.All, persistenceVersion: 227, omVersion: 0, ugcSubVersion: 227); Console.WriteLine($"[admin] {account.Username} ({account.Id}) attached {blob} " + $"({fi.Length} bytes) to room {roomId} subroom {subRoomId}"); return Json(new { success = true, message = $"Attached {blob} to room {roomId} ('{room.Name}') subroom {subRoomId}. " + $"Load the room to see which build it is." }); } /// /// Every subroom and the scene the client loads for it, grouped so placeholders stand /// out. A scene shared by many unrelated rooms is a placeholder that came in with the /// import, not that room's real map. /// [HttpGet("/admin/api/room_scenes")] public ContentResult AdminListRoomScenes() { var (account, error) = AuthHelper.ValidateTokenDev(HttpContext); if (error != null) return error; var rows = new List(); var usage = new Dictionary(StringComparer.OrdinalIgnoreCase); foreach (var room in RoomDB.GetAllRooms()) foreach (var sub in room.SubRooms ?? new List()) { string scene = sub.UnitySceneId ?? ""; if (scene.Length > 0) usage[scene] = usage.TryGetValue(scene, out var n) ? n + 1 : 1; } foreach (var room in RoomDB.GetAllRooms()) foreach (var sub in room.SubRooms ?? new List()) { string scene = sub.UnitySceneId ?? ""; rows.Add(new { roomId = room.RoomId, roomName = room.Name, isRRO = room.IsRRO, subRoomId = sub.SubRoomId, subRoomName = sub.Name, scene, sharedWith = scene.Length > 0 && usage.TryGetValue(scene, out var n) ? n : 0 }); } return Json(new { success = true, total = rows.Count, rooms = rows }); } /// /// Points a subroom at a different Unity scene. See RoomDB.SetSubRoomScene for what /// that means and why an imported room can load the wrong map. /// // Social sign-in apps (Settings > Services on the website). GET = ids + whether a secret // is stored + the redirect URI to register; POST = save one provider's credentials into // Data/social_apps.json (a blank secret keeps the stored one). Dev-only. [HttpGet("/admin/api/social_apps")] public ContentResult AdminSocialApps() { var (account, error) = AuthHelper.ValidateTokenDev(HttpContext); if (error != null) return error; return Json(new { success = true, providers = Campus_live_rewrite.api.server.SocialLinks.AdminView() }); } [HttpPost("/admin/api/social_apps")] public ContentResult AdminSaveSocialApp() { var (account, error) = AuthHelper.ValidateTokenDev(HttpContext); if (error != null) return error; string provider = (Request.Form["provider"].ToString() ?? "").Trim().ToLowerInvariant(); string? clientId = Request.Form.ContainsKey("clientId") ? Request.Form["clientId"].ToString() : null; string? clientSecret = Request.Form.ContainsKey("clientSecret") ? Request.Form["clientSecret"].ToString() : null; string? apiKey = Request.Form.ContainsKey("apiKey") ? Request.Form["apiKey"].ToString() : null; bool ok = Campus_live_rewrite.api.server.SocialLinks.AdminSave(provider, clientId, clientSecret, apiKey, out var message); Console.WriteLine($"[admin] {account.Username} ({account.Id}) social app '{provider}': {(ok ? "saved" : "FAILED " + message)}"); return Json(new { success = ok, message }); } [HttpPost("/admin/api/set_room_scene")] public ContentResult AdminSetRoomScene() { var (account, error) = AuthHelper.ValidateTokenDev(HttpContext); if (error != null) return error; if (!ulong.TryParse(Request.Form["roomId"].ToString(), out ulong roomId)) return Json(new { success = false, message = "Invalid room id." }); string scene = (Request.Form["scene"].ToString() ?? "").Trim(); if (!Guid.TryParse(scene, out _)) return Json(new { success = false, message = "Scene must be a GUID." }); var room = RoomDB.GetRoom(roomId); if (room == null) return Json(new { success = false, message = $"No room {roomId}." }); ulong subRoomId; if (!ulong.TryParse(Request.Form["subRoomId"].ToString(), out subRoomId) || subRoomId == 0) { var first = (room.SubRooms ?? new List()).FirstOrDefault(); if (first == null) return Json(new { success = false, message = "Room has no subrooms." }); subRoomId = first.SubRoomId; } bool ok = RoomDB.SetSubRoomScene(roomId, subRoomId, scene); Console.WriteLine($"[admin] {account.Username} ({account.Id}) set room {roomId} subroom {subRoomId} scene -> {scene}"); return Json(new { success = ok, message = ok ? $"Room {roomId} ('{room.Name}') subroom {subRoomId} now loads scene {scene}. Rejoin to see it." : "Subroom not found." }); } /// /// Every photo in the game, newest first, each attributed to the player who took it. /// Backs the admin gallery. The image url is the same /img path the client fetches, so /// the panel can show a thumbnail; PlayerId names the photographer. /// // Shows the OUTFIT state stored in the database for one player, plus the running build // stamp - so we can tell instantly whether a save is landing. Open /admin/api/outfitdebug/6 // after saving an outfit in-game: if savedOutfits is populated the save IS in the DB (and // any "empty wardrobe" is a client display issue); if it's empty the save never reached the // server, and /admin/api/apilog will show which endpoint the client hit and its status. [HttpGet("/admin/api/outfitdebug/{playerid}")] public IActionResult AdminOutfitDebug(ulong playerid) { var (account, error) = AuthHelper.ValidateTokenDev(HttpContext); if (error != null) return error; string build = ""; try { var bp = Path.Combine(Environment.CurrentDirectory, "Data", "BUILD.txt"); if (System.IO.File.Exists(bp)) build = System.IO.File.ReadAllText(bp).Trim(); } catch { } var p = PlayerDB.GetFullAccount(playerid); var savedRaw = PlayerDB.GetAvatarSaved(playerid); // JSON array string of SavedOutfits int savedCount = p?.player_Extra?.SavedOutfits?.Count ?? 0; return Content(JsonConvert.SerializeObject(new { build, playerid, playerRowExists = p != null, playerExtraExists = p?.player_Extra != null, hasWornAvatar = !string.IsNullOrEmpty(p?.player_Extra?.avatar), savedOutfitCount = savedCount, savedOutfits = savedRaw }, Formatting.Indented), "application/json"); } // The tail of Data/APILog.txt (every request the middleware logs as // "