using Mocha2023.Classes.DBs; using Mocha2023.Classes.DBs.DBClasses; using Mocha2023.Classes; using Mocha2023.Auth; using Microsoft.AspNetCore.Hosting; using Microsoft.AspNetCore.Mvc; using System.Diagnostics; using System.Text.Json; namespace Mocha2023.Controllers { [ApiController] [Mocha2023.Classes.ApiProtection] public class RecNetPageController : ControllerBase { private readonly IWebHostEnvironment _env; public RecNetPageController(IWebHostEnvironment env) { _env = env; } [HttpGet("/recnet/")] public IActionResult Index() { return Serve("index.html", "text/html; charset=utf-8"); } [HttpGet("/recnet/banappeal")] public IActionResult BanAppeal() { return Serve("index.html", "text/html; charset=utf-8"); } [HttpGet("/recnet/mocha")] public IActionResult MochaAdminIndex() { return Serve("mocha.html", "text/html; charset=utf-8"); } [HttpGet("/recnet/mocha.html")] public IActionResult MochaAdminHtml() { return Serve("mocha.html", "text/html; charset=utf-8"); } [HttpGet("/recnet/mocha.css")] public IActionResult MochaAdminCss() { return Serve("mocha.css", "text/css; charset=utf-8"); } [HttpGet("/recnet/mocha.js")] public IActionResult MochaAdminScript() { return Serve("mocha.js", "text/javascript; charset=utf-8"); } [HttpGet("/recnet/developer")] public IActionResult DeveloperDashboard() { var account = AuthStuff.GetCurrentPlayer(Request); if (account?.PlayerRoles?.Contains( PlayerDBClasses.PlayerRoles.Developer) != true) return StatusCode(403); return Serve("developer.html", "text/html; charset=utf-8"); } [HttpGet("/privacy")] public IActionResult Privacy() { return Serve("privacy.html", "text/html; charset=utf-8"); } [HttpGet("/tos")] public IActionResult TermsOfService() { return Serve("tos.html", "text/html; charset=utf-8"); } [HttpGet("/recnet/app.js")] public IActionResult Script() { return Serve("app.js", "text/javascript; charset=utf-8"); } [HttpGet("/recnet/styles.css")] public IActionResult Styles() { return Serve("styles.css", "text/css; charset=utf-8"); } [HttpGet("/recnet/tokens.css")] public IActionResult TokenStyles() { return Serve("tokens.css", "text/css; charset=utf-8"); } [HttpGet("/recnet/dark.css")] public IActionResult DarkStyles() { return Serve("dark.css", "text/css; charset=utf-8"); } [HttpGet("/recnet/badges.css")] public IActionResult BadgeStyles() { return Serve("badges.css", "text/css; charset=utf-8"); } [HttpGet("/recnet/features.css")] public IActionResult FeatureStyles() { return Serve("features.css", "text/css; charset=utf-8"); } [HttpGet("/recnet/rrplus.png")] public IActionResult RrPlusImage() { return Serve("rrplus.png", "image/png"); } [HttpGet("/recnet/mocha-logo.png")] public IActionResult MochaLogo() { return Serve("mocha-logo.png", "image/png"); } [HttpGet("/recnet/font.css")] public IActionResult FontStyles() { return Serve("font.css", "text/css; charset=utf-8"); } [HttpGet("/recnet/photo.css")] public IActionResult PhotoStyles() { return Serve("photo.css", "text/css; charset=utf-8"); } [HttpGet("/recnet/admin.css")] public IActionResult AdminStyles() { return Serve("admin.css", "text/css; charset=utf-8"); } [HttpGet("/recnet/community-board.css")] public IActionResult CommunityBoardStyles() { return Serve("community-board.css", "text/css; charset=utf-8"); } [HttpGet("/recnet/community-board-media/{fileName}")] public IActionResult CommunityBoardMedia(string fileName) { if (string.IsNullOrWhiteSpace(fileName) || fileName.Length > 180 || !string.Equals( Path.GetFileName(fileName), fileName, StringComparison.Ordinal) || fileName.Any(character => character == '\0')) return NotFound(); string extension = Path.GetExtension(fileName).ToLowerInvariant(); string? contentType = extension switch { ".png" => "image/png", ".jpg" or ".jpeg" => "image/jpeg", ".webp" => "image/webp", ".gif" => "image/gif", ".bmp" => "image/bmp", ".mp4" or ".m4v" => "video/mp4", ".webm" => "video/webm", ".mov" => "video/quicktime", _ => null }; if (contentType == null) return NotFound(); string root = Path.GetFullPath( Path.Combine(Program.dataDir, "CommunityBoardUploads")); string path = Path.GetFullPath(Path.Combine(root, fileName)); if (!path.StartsWith(root + Path.DirectorySeparatorChar, StringComparison.OrdinalIgnoreCase) || !System.IO.File.Exists(path)) return NotFound(); Response.Headers.CacheControl = "public,max-age=31536000,immutable"; return PhysicalFile(path, contentType, enableRangeProcessing: true); } [HttpGet("/recnet/developer.css")] public IActionResult DeveloperStyles() { return Serve("developer.css", "text/css; charset=utf-8"); } [HttpGet("/recnet/developer.js")] public IActionResult DeveloperScript() { return Serve("developer.js", "text/javascript; charset=utf-8"); } [HttpGet("/recnet/shop.css")] public IActionResult ShopStyles() { return Serve("shop.css", "text/css; charset=utf-8"); } [HttpGet("/recnet/events.css")] public IActionResult EventStyles() { return Serve("events.css", "text/css; charset=utf-8"); } [HttpGet("/recnet/privacy.css")] public IActionResult PrivacyStyles() { return Serve("privacy.css", "text/css; charset=utf-8"); } [HttpGet("/recnet/fonts/{fontName}")] public IActionResult Font(string fontName) { string? file = fontName switch { "FuturaPT-Book.ttf" => "fonts/FuturaPT-Book.ttf", "FuturaPT-Bold.ttf" => "fonts/FuturaPT-Bold.ttf", "FuturaPT-Heavy.ttf" => "fonts/FuturaPT-Heavy.ttf", _ => null }; return file == null ? NotFound() : Serve(file, "font/ttf"); } private IActionResult Serve(string fileName, string contentType) { string path = string.Empty; string? webRootDir = _env.WebRootPath; if (!string.IsNullOrEmpty(webRootDir)) { string candidate = Path.Combine(webRootDir, "recnet", fileName); if (System.IO.File.Exists(candidate)) path = candidate; } if (string.IsNullOrEmpty(path)) { string candidate = Path.GetFullPath( Path.Combine(Program.dataDir, "..", "wwwroot", "recnet", fileName)); if (System.IO.File.Exists(candidate)) path = candidate; } if (string.IsNullOrEmpty(path)) { string candidate = Path.Combine( Directory.GetCurrentDirectory(), "wwwroot", "recnet", fileName); if (System.IO.File.Exists(candidate)) path = candidate; } if (string.IsNullOrEmpty(path)) return NotFound("The RecNet web files are missing from wwwroot/recnet."); Response.Headers.CacheControl = "no-store, no-cache, must-revalidate"; Response.Headers.Pragma = "no-cache"; Response.Headers.Expires = "0"; Response.Headers["X-Content-Type-Options"] = "nosniff"; Response.Headers["X-Frame-Options"] = "DENY"; Response.Headers["Referrer-Policy"] = "strict-origin-when-cross-origin"; Response.Headers["Permissions-Policy"] = "camera=(), microphone=(), geolocation=(), payment=(), usb=()"; if (contentType.StartsWith("text/html", StringComparison.OrdinalIgnoreCase)) { Response.Headers["Content-Security-Policy"] = "default-src 'self'; base-uri 'self'; frame-ancestors 'none'; " + "object-src 'none'; " + "script-src 'self' https://static.cloudflareinsights.com; " + "style-src 'self' 'unsafe-inline' https://cdnjs.cloudflare.com https://fonts.googleapis.com; " + "font-src 'self' https://cdnjs.cloudflare.com https://fonts.gstatic.com data:; " + "img-src 'self' data: blob: https:; media-src 'self' blob: https:; " + "connect-src 'self' https://cloudflareinsights.com; form-action 'self'"; } return PhysicalFile(path, contentType); } } [ApiController] [Mocha2023.Classes.ApiProtection] [Route("recnet/api")] public class RecNetController : ControllerBase { private static readonly Random BonusRarity50Rng = new(); private const int BonusRarity50PercentChance = 5; private static readonly HashSet ImageExtensions = new(StringComparer.OrdinalIgnoreCase) { ".png", ".jpg", ".jpeg", ".webp", ".gif", ".bmp" }; private static readonly SemaphoreSlim CommunityBoardWriteLock = new(1, 1); private static readonly object DeveloperCpuSync = new(); private static DateTime DeveloperCpuSampleAt = DateTime.UtcNow; private static TimeSpan DeveloperCpuTime = Process.GetCurrentProcess().TotalProcessorTime; private static double DeveloperCpuPercent; [HttpPost("auth/login")] [RequestSizeLimit(16 * 1024)] public IActionResult Login([FromBody] RecNetLogin request) { string identity = request.Identity?.Trim() ?? string.Empty; if (string.IsNullOrWhiteSpace(identity) || string.IsNullOrEmpty(request.Password)) return BadRequest(new { error = "Enter your username and password." }); string clientAddress = ClientNetwork.GetClientIp(Request)?.ToString() ?? "unknown"; if (!PasswordSecurity.TryBeginLoginAttempt( identity, clientAddress, out int retryAfterSeconds)) { Response.Headers["Retry-After"] = retryAfterSeconds.ToString( System.Globalization.CultureInfo.InvariantCulture); return StatusCode( StatusCodes.Status429TooManyRequests, new { error = "Too many login attempts. Try again later." }); } var account = PlayerDB.Players.FindAll().FirstOrDefault(x => x.Player != null && (string.Equals(x.Player.Username, identity, StringComparison.OrdinalIgnoreCase) || string.Equals(x.Player.DisplayName, identity, StringComparison.OrdinalIgnoreCase) || x.PlayerId.ToString() == identity)); bool validPassword = PasswordSecurity.VerifyLogin( request.Password, account?.Password, out bool needsUpgrade); PasswordSecurity.CompleteLoginAttempt( identity, clientAddress, account?.Player != null && validPassword); if (account?.Player == null || !validPassword) return Unauthorized(new { error = "That username or password is incorrect." }); if (SteamAccessDB.TryGetBlockedSteamId( account, out ulong blockedSteamId)) { Console.WriteLine( $"[RECNET STEAM BLOCKED] account={account.PlayerId} " + $"steamId={blockedSteamId}"); return StatusCode( StatusCodes.Status403Forbidden, new { error = "This Steam ID has been blocked from Mocha.", code = "steam_id_blacklisted" }); } if (needsUpgrade) account.Password = PasswordSecurity.Hash(request.Password); Response.Cookies.Append("recnet_session", AuthStuff.Encode(account.PlayerId), new CookieOptions { HttpOnly = true, Secure = true, SameSite = SameSiteMode.Strict, Expires = DateTimeOffset.UtcNow.AddDays(30), IsEssential = true, Path = "/recnet" }); account.Player!.LastLoginAt = DateTime.UtcNow; PlayerDB.Players.Update(account); return Ok(ToSession(account)); } [HttpPost("auth/register")] [RequestSizeLimit(16 * 1024)] public IActionResult Register([FromBody] RecNetRegistration request) { if (!RecNetDB.IsAccountCreationEnabled() || !RecNetDB.IsRecNetSignupEnabled()) { return StatusCode( StatusCodes.Status403Forbidden, new { error = "New account creation is currently disabled.", code = "account_creation_disabled" }); } if (!TryValidateRegistration(request, out string username, out var platform, out ulong platformId, out string error, out int status)) return StatusCode(status, new { error }); if (platform == PlayerDBClasses.Platforms.Steam && SteamAccessDB.IsBlacklisted(platformId)) { return StatusCode( StatusCodes.Status403Forbidden, new { error = "This Steam ID has been blocked from Mocha.", code = "steam_id_blacklisted" }); } var account = CreateRegisteredAccount(username, request.Password!, platform, platformId); Response.Cookies.Append("recnet_session", AuthStuff.Encode(account.PlayerId), new CookieOptions { HttpOnly = true, Secure = true, SameSite = SameSiteMode.Strict, Expires = DateTimeOffset.UtcNow.AddDays(30), IsEssential = true, Path = "/recnet" }); return Ok(ToSession(account)); } [HttpGet("admin/accounts")] public IActionResult GetAdminAccounts([FromQuery] string? search = null) { var admin = AuthStuff.GetCurrentPlayer(Request); if (!IsStaff(admin)) return StatusCode(403); string term = search?.Trim() ?? string.Empty; var moderationLocks = RecNetDB.ModerationLocks.FindAll().ToDictionary(x => x.AccountId); var accounts = PlayerDB.Players.FindAll() .Where(x => x.Player != null) .Where(x => string.IsNullOrEmpty(term) || x.PlayerId.ToString().Contains(term) || (x.Player!.Username?.Contains(term, StringComparison.OrdinalIgnoreCase) ?? false) || (x.Player.DisplayName?.Contains(term, StringComparison.OrdinalIgnoreCase) ?? false)) .OrderBy(x => x.PlayerId) .Take(200) .Select(x => { moderationLocks.TryGetValue(x.PlayerId, out var accountLock); PlayerDB.IsPlayerBanned(x.PlayerId, out var nativeBan); return new { accountId = x.PlayerId, username = x.Player!.Username, displayName = x.Player.DisplayName, bio = x.Player.Bio, email = x.Player.Email, profileImage = ImageUrl(x.Player.ProfileImage), profileImagePath = NormalizeImagePath(x.Player.ProfileImage) ?? "DefaultPFP.png", bannerImagePath = NormalizeImagePath(x.Player.BannerImage) ?? string.Empty, level = x.Player.Level, xp = x.Player.XP, isJunior = x.Player.IsJunior ?? false, availableUsernameChanges = x.Player.AvailableUsernameChanges, displayEmoji = x.Player.DisplayEmoji ?? string.Empty, personalPronouns = x.Player.PersonalPronouns, balance = PlayerDB.GetCurrencyBalance( x.PlayerId, PlayerDBClasses.CurrencyType.RecCenterTokens), platforms = x.PlatformIds.Select(p => new { platform = p.Platform.ToString(), platformId = p.PlatformId.ToString() }), roles = x.PlayerRoles.Select(r => r.ToString()), ban = nativeBan == null ? null : new { reason = nativeBan.Message, duration = nativeBan.Duration, issuedAt = DateTimeOffset.FromUnixTimeSeconds(nativeBan.ModerationSetUnixTime).UtcDateTime }, moderationLock = accountLock == null ? null : new { reason = accountLock.Reason, issuedAt = accountLock.IssuedAt, relatedUsername = accountLock.RelatedUsername, isRelated = accountLock.RelatedAccountId.HasValue } }; }).ToList(); return Ok(accounts); } [HttpGet("shop")] public IActionResult GetWebsiteShop() { var account = AuthStuff.GetCurrentPlayer(Request); long? accountId = account?.Player == null ? null : account.PlayerId; var items = APIController.GetWebsiteStorefrontItems() .Select(item => new { skuId = item.SkuId, avatarItemId = item.AvatarItemId, friendlyName = item.FriendlyName, itemType = string.IsNullOrWhiteSpace(item.EquipmentModificationGuid) ? "avatar" : "skin", avatarItemDesc = item.AvatarItemDesc, equipmentPrefabName = item.EquipmentPrefabName, equipmentModificationGuid = item.EquipmentModificationGuid, tooltip = string.IsNullOrWhiteSpace(item.AvatarItemDesc) ? $"{item.EquipmentPrefabName} skin" : item.AvatarItemDesc, thumbnailUrl = string.IsNullOrWhiteSpace(item.ThumbnailImage) ? null : "https://img.rec.net/" + Uri.EscapeDataString(item.ThumbnailImage) + "?width=512&height=512", rarity = item.Rarity, stars = item.Rarity switch { 50 => 5, 30 => 4, 20 => 3, 10 => 2, _ => 1 }, price = item.Price, owned = accountId.HasValue && APIController.IsWebsiteStoreItemOwned(accountId.Value, item) }) .ToList(); return Ok(new { nextRefresh = DateTime.Today.AddDays(1).ToUniversalTime(), loggedIn = accountId.HasValue, balance = accountId.HasValue ? PlayerDB.GetCurrencyBalance( accountId.Value, PlayerDBClasses.CurrencyType.RecCenterTokens) : (int?)null, items }); } [HttpPost("shop/purchase")] public IActionResult PurchaseWebsiteShopItem( [FromBody] WebsiteShopPurchase request) { var account = AuthStuff.GetCurrentPlayer(Request); if (account?.Player == null) return Unauthorized(new { error = "Log in to purchase shop items." }); if (!APIController.TryPurchaseWebsiteStoreItem( account.PlayerId, request.SkuId, out var item, out int balance, out bool alreadyOwned, out string error)) { return BadRequest(new { error, balance }); } return Ok(new { success = true, balance, alreadyOwned, itemName = item?.FriendlyName ?? "Item" }); } [HttpGet("status")] public IActionResult GetWebsiteStatus() { var accounts = PlayerDB.Players.FindAll() .Where(value => value.Player != null) .ToList(); return Ok(new { status = "online", generatedAt = DateTimeOffset.UtcNow, registeredPlayers = accounts.Count, onlinePlayers = accounts.Count(value => PlayerDB.GetPlayerHeartbeat(value.PlayerId).isOnline), connectedSockets = NotiController.ConnectedSocketCount, rooms = RoomDB.Rooms.Count(), photos = EnumeratePublicPhotos().Count() }); } [HttpGet("ageverification/status/{code}")] public IActionResult GetAgeVerificationStatus(string code) { long? accountId = AuthStuff.GetPlayerId(Request); if (accountId == null) return Unauthorized(); var request = AgeVerificationDB.GetByCode(code); if (request == null || request.AccountId != accountId.Value) return NotFound(new { error = "That code doesn't match an active request for your account." }); return Ok(new { code = request.Code, status = request.Status, method = request.Method, createdAt = request.CreatedAt, submittedAt = request.SubmittedAt, reviewedAt = request.ReviewedAt, rejectionReason = request.RejectionReason }); } [HttpPost("ageverification/submit")] [RequestSizeLimit(10 * 1024 * 1024)] public async Task SubmitAgeVerification() { var player = AuthStuff.GetCurrentPlayer(Request); if (player?.Player == null) return Unauthorized(new { error = "Log in first." }); if (!Request.HasFormContentType) return BadRequest(new { error = "Send the code, method, and a photo as multipart form data." }); var form = await Request.ReadFormAsync(HttpContext.RequestAborted); string code = (form["code"].FirstOrDefault() ?? string.Empty).Trim().ToUpperInvariant(); string method = (form["method"].FirstOrDefault() ?? string.Empty).Trim(); if (method is not ("ManualId" or "FaceVerification")) return BadRequest(new { error = "Choose Manual ID Verification or Face Verification." }); var request = AgeVerificationDB.GetByCode(code); if (request == null || request.AccountId != player.PlayerId) return BadRequest(new { error = "That code doesn't match an active request for your account. Ask the client to generate a new one." }); if (request.Status != "Pending") return BadRequest(new { error = $"This code is already {request.Status.ToLowerInvariant()}." }); var file = form.Files.FirstOrDefault(); if (file == null || file.Length == 0) return BadRequest(new { error = "Attach a photo." }); if (file.Length > 10 * 1024 * 1024) return BadRequest(new { error = "Photos must be 10 MB or smaller." }); byte[] bytes; using (var buffer = new MemoryStream()) { await file.CopyToAsync(buffer, HttpContext.RequestAborted); bytes = buffer.ToArray(); } SixLabors.ImageSharp.Formats.IImageFormat? format; try { format = SixLabors.ImageSharp.Image.DetectFormat(bytes); } catch { return BadRequest(new { error = "That file is not a valid image." }); } string extension = format?.FileExtensions.FirstOrDefault()?.ToLowerInvariant() ?? string.Empty; if (extension is not ("png" or "jpg" or "jpeg" or "webp")) return BadRequest(new { error = "Use a PNG, JPG, or WebP image." }); string methodLabel = method == "ManualId" ? "Manual ID Verification" : "Face Verification (Manually)"; string content = $"🪪 **Age verification submitted** - {methodLabel}\n" + $"👤 `{player.Player.Username}` (ID: `{player.PlayerId}`)\n" + $"🔑 Code: `{code}`\n" + $"Review in the admin panel's Age Verification queue with this code, then Approve or Reject there."; DiscordLogger.LogImage(content, bytes, $"ageverification_{code}.{extension}"); if (!AgeVerificationDB.MarkUnderReview(code, player.PlayerId, method)) return StatusCode(500, new { error = "Could not record the submission. Try again." }); return Ok(new { success = true, status = "UnderReview" }); } [HttpPost("admin/accounts")] public IActionResult AdminCreateAccount([FromBody] AdminRegistration request) { var admin = AuthStuff.GetCurrentPlayer(Request); if (!IsAdmin(admin)) return StatusCode(403); if (!RecNetDB.IsAccountCreationEnabled()) { return StatusCode( StatusCodes.Status403Forbidden, new { error = "New account creation is currently disabled.", code = "account_creation_disabled" }); } if (!TryValidateRegistration(request, out string username, out var platform, out ulong platformId, out string error, out int status)) return StatusCode(status, new { error }); if (platform == PlayerDBClasses.Platforms.Steam && SteamAccessDB.IsBlacklisted(platformId)) { return StatusCode( StatusCodes.Status403Forbidden, new { error = "That Steam ID is currently blacklisted.", code = "steam_id_blacklisted" }); } var account = CreateRegisteredAccount(username, request.Password!, platform, platformId); return Ok(new { success = true, accountId = account.PlayerId, username = account.Player!.Username, platformAccountLimit = "unlimited" }); } [HttpGet("admin/overview")] public IActionResult GetAdminOverview() { var admin = AuthStuff.GetCurrentPlayer(Request); if (!IsStaff(admin)) return StatusCode(403); var accounts = PlayerDB.Players.FindAll().ToList(); return Ok(new { accounts = accounts.Count, admins = accounts.Count(IsAdmin), verified = accounts.Count(IsVerified), rrPlus = accounts.Count(HasRRPlus), moderationLocks = RecNetDB.ModerationLocks.Count(), rooms = RoomDB.Rooms.Count(), photos = EnumeratePublicPhotos().Count(), cheers = RecNetDB.PhotoCheers.Count(), comments = RecNetDB.PhotoComments.Count() }); } public class AdminRoomImportFromUrlRequest { public string? Url { get; set; } public long? CreatorAccountId { get; set; } public bool ReplaceExisting { get; set; } = true; } [HttpPost("admin/rooms/import-url")] public IActionResult ImportAdminRoomFromUrl( [FromBody] AdminRoomImportFromUrlRequest request) { var admin = AuthStuff.GetCurrentPlayer(Request); if (!IsDeveloper(admin)) return StatusCode(403, new { error = "Only Developers can import room archives." }); if (string.IsNullOrWhiteSpace(request.Url)) return BadRequest(new { error = "Enter the URL of the room's own folder." }); long creatorAccountId = request.CreatorAccountId is > 0 ? request.CreatorAccountId.Value : admin!.PlayerId; if (request.CreatorAccountId is > 0 && PlayerDB.Players.FindById(request.CreatorAccountId.Value)?.Player == null) { return BadRequest(new { error = "The requested creator account does not exist." }); } string url = request.Url.Trim(); bool replaceExisting = request.ReplaceExisting; MassImportJobs.Job job = MassImportJobs.Create("single-url"); _ = Task.Run(() => RunSingleUrlImport(job, url, creatorAccountId, replaceExisting)); return Ok(new { jobId = job.Id }); } private static async Task RunSingleUrlImport( MassImportJobs.Job job, string url, long creatorAccountId, bool replaceExisting) { job.TotalFound = 1; job.Status = "running"; job.CurrentRoomName = url; var outcome = new MassImportJobs.RoomOutcome { Name = url, SourceUrl = url }; try { ShowdownImporter.ImportResult result = await ShowdownImporter.ImportFromUrlAsync( url, creatorAccountId, replaceExisting, CancellationToken.None); outcome.Success = true; outcome.RoomId = result.RoomId; outcome.SubRoomsImported = result.SubRoomsImported; outcome.SavesImported = result.SavesImported; outcome.BakedAssetsImported = result.BakedAssetsImported; outcome.AssetBundlesCopied = result.AssetBundlesCopied; } catch (Exception ex) { outcome.Success = false; outcome.Error = ex.Message; Console.WriteLine($"[ADMIN ROOM IMPORT URL FAILED] job={job.Id} {ex}"); } job.AddResult(outcome); job.Status = "completed"; job.FinishedAt = DateTime.UtcNow; } [HttpPost("admin/rooms/import")] [HttpPut("admin/rooms/import")] [HttpPost("admin/rooms")] [HttpPut("admin/rooms")] [RequestSizeLimit(512L * 1024L * 1024L)] [RequestFormLimits(MultipartBodyLengthLimit = 512L * 1024L * 1024L)] public async Task ImportAdminRoom() { var admin = AuthStuff.GetCurrentPlayer(Request); if (!IsDeveloper(admin)) return StatusCode(403, new { error = "Only Developers can import room archives." }); if (!Request.HasFormContentType) { string rawJson; using (var reader = new StreamReader(Request.Body)) rawJson = await reader.ReadToEndAsync(HttpContext.RequestAborted); if (string.IsNullOrWhiteSpace(rawJson)) { return BadRequest(new { error = "Send either room metadata JSON or multipart/form-data containing a ZIP/room.json file." }); } try { var directResult = ShowdownImporter.ImportUnityMetadata( rawJson, admin!.PlayerId, replaceExisting: true); return Ok(new { success = true, importMode = "unity-scene-json", roomId = directResult.RoomId, subRoomsImported = directResult.SubRoomsImported, savesImported = directResult.SavesImported, blobsCopied = directResult.BlobsCopied, savesSkipped = directResult.SavesSkipped, playableSubRoomName = directResult.PlayableSubRoomName, playableSubRoomId = directResult.PlayableSubRoomId, bakedAssetsImported = directResult.BakedAssetsImported, assetBundlesCopied = directResult.AssetBundlesCopied, assetBundlesMissing = directResult.AssetBundlesMissing, imageCopied = directResult.ImageCopied, unityEngineVersions = directResult.UnityEngineVersions }); } catch (Exception exception) when ( exception is JsonException or InvalidDataException or InvalidOperationException) { return BadRequest(new { error = exception.Message }); } } var form = await Request.ReadFormAsync(HttpContext.RequestAborted); var archive = form.Files.FirstOrDefault(file => string.Equals(file.Name, "file", StringComparison.OrdinalIgnoreCase) || string.Equals(file.Name, "archive", StringComparison.OrdinalIgnoreCase) || string.Equals(file.Name, "room", StringComparison.OrdinalIgnoreCase) || string.Equals(file.Name, "zip", StringComparison.OrdinalIgnoreCase)) ?? form.Files.FirstOrDefault(); if (archive == null || archive.Length <= 0) return BadRequest(new { error = "No room export ZIP was uploaded." }); if (archive.Length > 512L * 1024L * 1024L) return StatusCode(413, new { error = "Room export ZIPs must be 512 MB or smaller." }); long creatorAccountId = admin!.PlayerId; if (long.TryParse(form["creatorAccountId"].FirstOrDefault(), out long requestedCreator) && requestedCreator > 0) { if (PlayerDB.Players.FindById(requestedCreator)?.Player == null) return BadRequest(new { error = "The requested creator account does not exist." }); creatorAccountId = requestedCreator; } bool replaceExisting = !bool.TryParse( form["replaceExisting"].FirstOrDefault(), out bool parsedReplace) || parsedReplace; string archiveExtension = Path.GetExtension(archive.FileName ?? string.Empty); bool looksLikeJson = archiveExtension.Equals(".json", StringComparison.OrdinalIgnoreCase) || archive.ContentType?.Contains("json", StringComparison.OrdinalIgnoreCase) == true; if (looksLikeJson) { try { using var reader = new StreamReader(archive.OpenReadStream()); string metadataJson = await reader.ReadToEndAsync(HttpContext.RequestAborted); var directResult = ShowdownImporter.ImportUnityMetadata( metadataJson, creatorAccountId, replaceExisting); return Ok(new { success = true, importMode = "unity-scene-json", roomId = directResult.RoomId, subRoomsImported = directResult.SubRoomsImported, savesImported = directResult.SavesImported, blobsCopied = directResult.BlobsCopied, savesSkipped = directResult.SavesSkipped, playableSubRoomName = directResult.PlayableSubRoomName, playableSubRoomId = directResult.PlayableSubRoomId, bakedAssetsImported = directResult.BakedAssetsImported, assetBundlesCopied = directResult.AssetBundlesCopied, assetBundlesMissing = directResult.AssetBundlesMissing, imageCopied = directResult.ImageCopied, unityEngineVersions = directResult.UnityEngineVersions }); } catch (Exception exception) when ( exception is JsonException or InvalidDataException or InvalidOperationException) { return BadRequest(new { error = exception.Message }); } } string importId = Guid.NewGuid().ToString("N"); string importRoot = Path.Combine(Program.dataDir, "Temp", "RoomImports", importId); string archivePath = Path.Combine(importRoot, "room-export.zip"); string extractedRoot = Path.Combine(importRoot, "extracted"); Directory.CreateDirectory(extractedRoot); try { await using (var input = archive.OpenReadStream()) await using (var output = new FileStream( archivePath, FileMode.CreateNew, FileAccess.Write, FileShare.None, 81920, FileOptions.Asynchronous | FileOptions.SequentialScan)) { await input.CopyToAsync(output, HttpContext.RequestAborted); await output.FlushAsync(HttpContext.RequestAborted); } } catch (Exception exception) when (exception is IOException or UnauthorizedAccessException) { try { if (Directory.Exists(importRoot)) Directory.Delete(importRoot, recursive: true); } catch (Exception cleanupException) { Console.WriteLine($"[ADMIN ROOM IMPORT CLEANUP] {cleanupException.Message}"); } Console.WriteLine($"[ADMIN ROOM IMPORT FAILED] {exception}"); return StatusCode(500, new { error = "Could not save the uploaded ZIP.", detail = exception.Message }); } MassImportJobs.Job job = MassImportJobs.Create("single-zip"); _ = Task.Run(() => RunSingleZipImport(job, importRoot, extractedRoot, creatorAccountId, replaceExisting)); Console.WriteLine( $"[ADMIN ROOM IMPORT] admin={admin.PlayerId} creator={creatorAccountId} job={job.Id} started"); return Ok(new { jobId = job.Id }); } private static void RunSingleZipImport( MassImportJobs.Job job, string importRoot, string extractedRoot, long creatorAccountId, bool replaceExisting) { try { string archivePath = Path.Combine(importRoot, "room-export.zip"); ExtractZipSafely(archivePath, extractedRoot); string[] roomJsonFiles = Directory.GetFiles(extractedRoot, "room.json", SearchOption.AllDirectories); if (roomJsonFiles.Length == 0) { job.Status = "failed"; job.FatalError = "The ZIP does not contain room.json."; return; } string roomJson = roomJsonFiles .OrderBy(path => Path.GetRelativePath(extractedRoot, path).Count(ch => ch == Path.DirectorySeparatorChar || ch == Path.AltDirectorySeparatorChar)) .ThenBy(path => path.Length) .First(); string exportRoot = Path.GetDirectoryName(roomJson)!; string roomName = Path.GetFileName(exportRoot); job.TotalFound = 1; job.Status = "running"; job.CurrentRoomName = roomName; var outcome = new MassImportJobs.RoomOutcome { Name = roomName }; try { var result = ShowdownImporter.Import(exportRoot, creatorAccountId, replaceExisting); outcome.Success = true; outcome.RoomId = result.RoomId; outcome.SubRoomsImported = result.SubRoomsImported; outcome.SavesImported = result.SavesImported; outcome.BakedAssetsImported = result.BakedAssetsImported; outcome.AssetBundlesCopied = result.AssetBundlesCopied; Console.WriteLine( $"[ADMIN ROOM IMPORT] job={job.Id} room={result.RoomId} " + $"subrooms={result.SubRoomsImported} saves={result.SavesImported}"); } catch (Exception ex) { outcome.Success = false; outcome.Error = ex.Message; Console.WriteLine($"[ADMIN ROOM IMPORT FAILED] job={job.Id} {ex}"); } job.AddResult(outcome); job.Status = "completed"; } catch (Exception ex) { job.Status = "failed"; job.FatalError = ex.Message; Console.WriteLine($"[ADMIN ROOM IMPORT FAILED] job={job.Id} {ex}"); } finally { job.FinishedAt = DateTime.UtcNow; try { if (Directory.Exists(importRoot)) Directory.Delete(importRoot, recursive: true); } catch (Exception cleanupException) { Console.WriteLine($"[ADMIN ROOM IMPORT CLEANUP] {cleanupException.Message}"); } } } public class AdminRoomImportBatchFromUrlRequest { public string? Url { get; set; } public long? CreatorAccountId { get; set; } public bool ReplaceExisting { get; set; } = true; } [HttpGet("admin/rooms/import-batch/{jobId}")] public IActionResult GetAdminRoomBatchStatus(string jobId) { var admin = AuthStuff.GetCurrentPlayer(Request); if (!IsDeveloper(admin)) return StatusCode(403, new { error = "Only Developers can import room archives." }); MassImportJobs.Job? job = MassImportJobs.Get(jobId); if (job == null) return NotFound(new { error = "Unknown import job - it may have finished more than 6 hours ago." }); List results = job.SnapshotResults(); return Ok(new { jobId = job.Id, kind = job.Kind, status = job.Status, fatalError = job.FatalError, totalFound = job.TotalFound, currentRoomName = job.CurrentRoomName, completedCount = results.Count, successCount = results.Count(r => r.Success), failedCount = results.Count(r => !r.Success), startedAt = job.StartedAt, finishedAt = job.FinishedAt, results = results.Select(r => new { r.Name, r.SourceUrl, r.Success, r.Error, r.RoomId, r.SubRoomsImported, r.SavesImported, r.BakedAssetsImported, r.AssetBundlesCopied }) }); } [HttpPost("admin/rooms/import-batch/url")] public async Task ImportAdminRoomBatchFromUrl( [FromBody] AdminRoomImportBatchFromUrlRequest request) { var admin = AuthStuff.GetCurrentPlayer(Request); if (!IsDeveloper(admin)) return StatusCode(403, new { error = "Only Developers can import room archives." }); if (string.IsNullOrWhiteSpace(request.Url)) return BadRequest(new { error = "Enter the URL of the rooms index folder." }); long creatorAccountId = request.CreatorAccountId is > 0 ? request.CreatorAccountId.Value : admin!.PlayerId; if (request.CreatorAccountId is > 0 && PlayerDB.Players.FindById(request.CreatorAccountId.Value)?.Player == null) { return BadRequest(new { error = "The requested creator account does not exist." }); } List<(string Name, string Url)> roomFolders; try { roomFolders = await ShowdownImporter.DiscoverRoomFoldersAsync( request.Url.Trim(), HttpContext.RequestAborted); } catch (Exception exception) when ( exception is InvalidDataException or HttpRequestException or TaskCanceledException) { return BadRequest(new { error = exception.Message }); } MassImportJobs.Job job = MassImportJobs.Create("url"); job.TotalFound = roomFolders.Count; job.Status = "running"; bool replaceExisting = request.ReplaceExisting; _ = Task.Run(() => RunUrlBatchImport(job, roomFolders, creatorAccountId, replaceExisting)); return Ok(new { jobId = job.Id, roomsFound = roomFolders.Count }); } private static async Task RunUrlBatchImport( MassImportJobs.Job job, List<(string Name, string Url)> roomFolders, long creatorAccountId, bool replaceExisting) { foreach ((string Name, string Url) folder in roomFolders) { job.CurrentRoomName = folder.Name; var outcome = new MassImportJobs.RoomOutcome { Name = folder.Name, SourceUrl = folder.Url }; try { ShowdownImporter.ImportResult result = await ShowdownImporter.ImportFromUrlAsync( folder.Url, creatorAccountId, replaceExisting, CancellationToken.None); outcome.Success = true; outcome.RoomId = result.RoomId; outcome.SubRoomsImported = result.SubRoomsImported; outcome.SavesImported = result.SavesImported; outcome.BakedAssetsImported = result.BakedAssetsImported; outcome.AssetBundlesCopied = result.AssetBundlesCopied; } catch (Exception ex) { outcome.Success = false; outcome.Error = ex.Message; Console.WriteLine($"[ADMIN ROOM BATCH IMPORT URL] {folder.Name} failed: {ex.Message}"); } job.AddResult(outcome); } job.Status = "completed"; job.FinishedAt = DateTime.UtcNow; } [HttpPost("admin/rooms/import-batch/zip")] [RequestSizeLimit(2L * 1024L * 1024L * 1024L)] [RequestFormLimits(MultipartBodyLengthLimit = 2L * 1024L * 1024L * 1024L)] public async Task ImportAdminRoomBatchFromZip() { var admin = AuthStuff.GetCurrentPlayer(Request); if (!IsDeveloper(admin)) return StatusCode(403, new { error = "Only Developers can import room archives." }); if (!Request.HasFormContentType) return BadRequest(new { error = "Send the ZIP as multipart/form-data." }); var form = await Request.ReadFormAsync(HttpContext.RequestAborted); var archive = form.Files.FirstOrDefault(file => string.Equals(file.Name, "file", StringComparison.OrdinalIgnoreCase) || string.Equals(file.Name, "archive", StringComparison.OrdinalIgnoreCase) || string.Equals(file.Name, "zip", StringComparison.OrdinalIgnoreCase)) ?? form.Files.FirstOrDefault(); if (archive == null || archive.Length <= 0) return BadRequest(new { error = "No room export ZIP was uploaded." }); long creatorAccountId = admin!.PlayerId; if (long.TryParse(form["creatorAccountId"].FirstOrDefault(), out long requestedCreator) && requestedCreator > 0) { if (PlayerDB.Players.FindById(requestedCreator)?.Player == null) return BadRequest(new { error = "The requested creator account does not exist." }); creatorAccountId = requestedCreator; } bool replaceExisting = !bool.TryParse( form["replaceExisting"].FirstOrDefault(), out bool parsedReplace) || parsedReplace; string importId = Guid.NewGuid().ToString("N"); string importRoot = Path.Combine(Program.dataDir, "Temp", "RoomImports", importId); string archivePath = Path.Combine(importRoot, "room-export.zip"); string extractedRoot = Path.Combine(importRoot, "extracted"); Directory.CreateDirectory(extractedRoot); await using (var input = archive.OpenReadStream()) await using (var output = new FileStream( archivePath, FileMode.CreateNew, FileAccess.Write, FileShare.None, 81920, FileOptions.Asynchronous | FileOptions.SequentialScan)) { await input.CopyToAsync(output, HttpContext.RequestAborted); await output.FlushAsync(HttpContext.RequestAborted); } MassImportJobs.Job job = MassImportJobs.Create("zip"); _ = Task.Run(() => RunZipBatchImport(job, importRoot, extractedRoot, creatorAccountId, replaceExisting)); return Ok(new { jobId = job.Id }); } private static void RunZipBatchImport( MassImportJobs.Job job, string importRoot, string extractedRoot, long creatorAccountId, bool replaceExisting) { try { string archivePath = Path.Combine(importRoot, "room-export.zip"); ExtractZipSafely(archivePath, extractedRoot); string[] roomJsonFiles = Directory.GetFiles(extractedRoot, "room.json", SearchOption.AllDirectories); job.TotalFound = roomJsonFiles.Length; if (roomJsonFiles.Length == 0) { job.Status = "failed"; job.FatalError = "The ZIP does not contain any room.json files."; return; } job.Status = "running"; foreach (string roomJsonPath in roomJsonFiles.OrderBy(p => p, StringComparer.OrdinalIgnoreCase)) { string exportRoot = Path.GetDirectoryName(roomJsonPath)!; string roomName = Path.GetFileName(exportRoot); job.CurrentRoomName = roomName; var outcome = new MassImportJobs.RoomOutcome { Name = roomName }; try { var result = ShowdownImporter.Import(exportRoot, creatorAccountId, replaceExisting); outcome.Success = true; outcome.RoomId = result.RoomId; outcome.SubRoomsImported = result.SubRoomsImported; outcome.SavesImported = result.SavesImported; outcome.BakedAssetsImported = result.BakedAssetsImported; outcome.AssetBundlesCopied = result.AssetBundlesCopied; } catch (Exception ex) { outcome.Success = false; outcome.Error = ex.Message; Console.WriteLine($"[ADMIN ROOM BATCH IMPORT ZIP] {roomName} failed: {ex.Message}"); } job.AddResult(outcome); } job.Status = "completed"; } catch (Exception ex) { job.Status = "failed"; job.FatalError = ex.Message; } finally { job.FinishedAt = DateTime.UtcNow; try { if (Directory.Exists(importRoot)) Directory.Delete(importRoot, recursive: true); } catch (Exception cleanupEx) { Console.WriteLine($"[ADMIN ROOM BATCH IMPORT CLEANUP] {cleanupEx.Message}"); } } } private static void ExtractZipSafely(string archivePath, string extractedRoot) { const int maxEntries = 20_000; const long maxExtractedBytes = 2L * 1024L * 1024L * 1024L; int entryCount = 0; long extractedBytes = 0; string canonicalExtractedRoot = Path.GetFullPath(extractedRoot) + Path.DirectorySeparatorChar; using var zip = System.IO.Compression.ZipFile.OpenRead(archivePath); foreach (var entry in zip.Entries) { entryCount++; if (entryCount > maxEntries) throw new InvalidDataException("The room archive contains too many files."); string entryName = entry.FullName.Replace('\\', '/'); if (string.IsNullOrWhiteSpace(entryName) || entryName.Length > 1_024 || entryName.Contains('\0')) { throw new InvalidDataException("The room archive contains an invalid filename."); } string destination = Path.GetFullPath( Path.Combine(extractedRoot, entryName.Replace('/', Path.DirectorySeparatorChar))); if (!destination.StartsWith(canonicalExtractedRoot, StringComparison.Ordinal)) throw new InvalidDataException("The room archive attempted to write outside its import folder."); bool isDirectory = entryName.EndsWith("/", StringComparison.Ordinal); if (isDirectory) { Directory.CreateDirectory(destination); continue; } if (entry.Length < 0 || entry.Length > maxExtractedBytes - extractedBytes) throw new InvalidDataException("The extracted room export is larger than 2 GB."); extractedBytes += entry.Length; Directory.CreateDirectory(Path.GetDirectoryName(destination)!); using var entryInput = entry.Open(); using var entryOutput = new FileStream( destination, FileMode.CreateNew, FileAccess.Write, FileShare.None, 81920, FileOptions.Asynchronous | FileOptions.SequentialScan); entryInput.CopyTo(entryOutput); } } [HttpPost("admin/rooms/import-batch/json")] public async Task ImportAdminRoomBatchFromJson() { var admin = AuthStuff.GetCurrentPlayer(Request); if (!IsDeveloper(admin)) return StatusCode(403, new { error = "Only Developers can import room archives." }); string rawBody; using (var reader = new StreamReader(Request.Body)) rawBody = await reader.ReadToEndAsync(HttpContext.RequestAborted); if (string.IsNullOrWhiteSpace(rawBody)) return BadRequest(new { error = "Send a JSON array of room metadata objects." }); long creatorAccountId = admin!.PlayerId; if (long.TryParse(Request.Query["creatorAccountId"], out long requestedCreator) && requestedCreator > 0) { if (PlayerDB.Players.FindById(requestedCreator)?.Player == null) return BadRequest(new { error = "The requested creator account does not exist." }); creatorAccountId = requestedCreator; } bool replaceExisting = !bool.TryParse(Request.Query["replaceExisting"], out bool parsedReplace) || parsedReplace; List<(string Json, string Label)> roomJsonBlobs; try { using JsonDocument document = JsonDocument.Parse(rawBody, new JsonDocumentOptions { AllowTrailingCommas = true, CommentHandling = JsonCommentHandling.Skip }); if (document.RootElement.ValueKind != JsonValueKind.Array) { return BadRequest(new { error = "Expected a JSON array of room metadata objects, e.g. [{...}, {...}]." }); } roomJsonBlobs = document.RootElement.EnumerateArray() .Select((element, index) => { string label = element.ValueKind == JsonValueKind.Object && element.TryGetProperty("Name", out JsonElement nameProp) && nameProp.ValueKind == JsonValueKind.String && !string.IsNullOrWhiteSpace(nameProp.GetString()) ? nameProp.GetString()! : $"item {index + 1}"; return (Json: element.GetRawText(), Label: label); }) .ToList(); } catch (JsonException exception) { return BadRequest(new { error = $"Invalid JSON: {exception.Message}" }); } if (roomJsonBlobs.Count == 0) return BadRequest(new { error = "The JSON array is empty." }); MassImportJobs.Job job = MassImportJobs.Create("json"); job.TotalFound = roomJsonBlobs.Count; job.Status = "running"; _ = Task.Run(() => RunJsonBatchImport(job, roomJsonBlobs, creatorAccountId, replaceExisting)); return Ok(new { jobId = job.Id, roomsFound = roomJsonBlobs.Count }); } private static void RunJsonBatchImport( MassImportJobs.Job job, List<(string Json, string Label)> roomJsonBlobs, long creatorAccountId, bool replaceExisting) { foreach ((string Json, string Label) item in roomJsonBlobs) { job.CurrentRoomName = item.Label; var outcome = new MassImportJobs.RoomOutcome { Name = item.Label }; try { var result = ShowdownImporter.ImportUnityMetadata(item.Json, creatorAccountId, replaceExisting); outcome.Success = true; outcome.RoomId = result.RoomId; outcome.SubRoomsImported = result.SubRoomsImported; outcome.SavesImported = result.SavesImported; outcome.BakedAssetsImported = result.BakedAssetsImported; outcome.AssetBundlesCopied = result.AssetBundlesCopied; } catch (Exception ex) { outcome.Success = false; outcome.Error = ex.Message; Console.WriteLine($"[ADMIN ROOM BATCH IMPORT JSON] {item.Label} failed: {ex.Message}"); } job.AddResult(outcome); } job.Status = "completed"; job.FinishedAt = DateTime.UtcNow; } [HttpGet("admin/rooms/{roomId:long}/export")] public IActionResult ExportAdminRoom(long roomId) { var admin = AuthStuff.GetCurrentPlayer(Request); if (!IsDeveloper(admin)) return StatusCode(403, new { error = "Only Developers can export room backups." }); var room = RoomDB.GetRoom(roomId); if (room == null || room.RoomId <= 0) return NotFound(new { error = "Room not found." }); string exportId = Guid.NewGuid().ToString("N"); string exportRoot = Path.Combine(Program.dataDir, "Temp", "RoomExports", exportId); string zipPath = exportRoot + ".zip"; try { ShowdownImporter.RoomExportResult result = ShowdownImporter.ExportRoomToDirectory(roomId, exportRoot); if (System.IO.File.Exists(zipPath)) System.IO.File.Delete(zipPath); System.IO.Compression.ZipFile.CreateFromDirectory( exportRoot, zipPath, System.IO.Compression.CompressionLevel.Optimal, includeBaseDirectory: false); Console.WriteLine( $"[ADMIN ROOM EXPORT] admin={admin!.PlayerId} room={roomId} " + $"subrooms={result.SubRoomsExported} saves={result.SavesExported} " + $"image={result.ImageExported} bakedAssets={result.BakedAssetsExported} " + $"bundles={result.AssetBundlesExported}"); byte[] zipBytes = System.IO.File.ReadAllBytes(zipPath); string safeName = string.Concat( room.Name.Where(character => !System.IO.Path.GetInvalidFileNameChars().Contains(character))); if (string.IsNullOrWhiteSpace(safeName)) safeName = $"room-{roomId}"; return File( zipBytes, "application/zip", $"{safeName}-{roomId}-backup.zip"); } catch (FileNotFoundException exception) { return NotFound(new { error = exception.Message }); } catch (InvalidDataException exception) { return BadRequest(new { error = exception.Message }); } catch (Exception exception) { Console.WriteLine($"[ADMIN ROOM EXPORT FAILED] {exception}"); return StatusCode(500, new { error = "Room export failed.", detail = exception.Message }); } finally { try { if (Directory.Exists(exportRoot)) Directory.Delete(exportRoot, recursive: true); if (System.IO.File.Exists(zipPath)) System.IO.File.Delete(zipPath); } catch (Exception cleanupException) { Console.WriteLine($"[ADMIN ROOM EXPORT CLEANUP] {cleanupException.Message}"); } } } [HttpGet("admin/rooms")] public IActionResult GetAdminRooms( [FromQuery] string? search = null, [FromQuery] bool includeDorms = false, [FromQuery] int skip = 0, [FromQuery] int take = 100) { var admin = AuthStuff.GetCurrentPlayer(Request); if (!IsStaff(admin)) return StatusCode(403); string term = search?.Trim() ?? string.Empty; skip = Math.Max(0, skip); take = Math.Clamp(take, 1, 250); var query = RoomDB.Rooms.FindAll() .Where(room => includeDorms || !room.IsDorm) .Where(room => string.IsNullOrEmpty(term) || room.RoomId.ToString().Contains(term, StringComparison.OrdinalIgnoreCase) || (room.Name?.Contains(term, StringComparison.OrdinalIgnoreCase) ?? false) || (room.Description?.Contains(term, StringComparison.OrdinalIgnoreCase) ?? false) || room.CreatorAccountId.ToString().Contains(term, StringComparison.OrdinalIgnoreCase)) .OrderByDescending(room => room.State == RoomDBClasses.RoomState.Active) .ThenByDescending(room => room.CreatedAt) .ThenByDescending(room => room.RoomId); int total = query.Count(); var rooms = query .Skip(skip) .Take(take) .Select(ToAdminRoomSummary) .ToList(); return Ok(new { results = rooms, total, skip, take }); } [HttpGet("admin/rooms/{roomId:long}")] public IActionResult GetAdminRoom(long roomId) { var admin = AuthStuff.GetCurrentPlayer(Request); if (!IsStaff(admin)) return StatusCode(403); var room = RoomDB.Rooms.FindById(roomId); return room == null ? NotFound(new { error = "Room not found." }) : Ok(ToAdminRoomDetails(room)); } [HttpPut("admin/rooms/{roomId:long}")] public IActionResult UpdateAdminRoom( long roomId, [FromBody] AdminRoomUpdate request) { var admin = AuthStuff.GetCurrentPlayer(Request); if (!IsAdmin(admin)) return StatusCode(403); var room = RoomDB.Rooms.FindById(roomId); if (room == null) return NotFound(new { error = "Room not found." }); string name = request.Name?.Trim().TrimStart('^') ?? string.Empty; if (name.Length is < 1 or > 50 || name.Any(character => character == '\0')) { return BadRequest(new { error = "Room name must be 1-50 characters." }); } bool duplicateName = RoomDB.Rooms.FindAll().Any(other => other.RoomId != roomId && string.Equals(other.Name, name, StringComparison.OrdinalIgnoreCase)); if (duplicateName) return Conflict(new { error = "Another room already uses that name." }); string description = request.Description?.Trim() ?? string.Empty; if (description.Length > 2_000 || description.Any(character => character == '\0')) return BadRequest(new { error = "Description cannot exceed 2,000 characters." }); if (request.MaxPlayers is < 1 or > 100) return BadRequest(new { error = "Max players must be between 1 and 100." }); if (request.MinLevel is < 0 or > 50) return BadRequest(new { error = "Minimum level must be between 0 and 50." }); if (!Enum.TryParse( request.Accessibility, true, out RoomDBClasses.RoomAccessibility accessibility)) { return BadRequest(new { error = "Choose Private, Public, or Unlisted accessibility." }); } if (!Enum.TryParse( request.State, true, out RoomDBClasses.RoomState state)) { return BadRequest(new { error = "Choose a valid room state." }); } string imageName = request.ImageName?.Trim() ?? string.Empty; if (imageName.Length > 300 || imageName.Contains("..", StringComparison.Ordinal) || imageName.Any(character => character == '\0')) { return BadRequest(new { error = "Room image path is invalid." }); } string[] tags = (request.Tags ?? Array.Empty()) .Select(tag => tag.Trim().TrimStart('#').ToLowerInvariant()) .Where(tag => tag.Length > 0) .Distinct(StringComparer.OrdinalIgnoreCase) .ToArray(); if (tags.Length > 30 || tags.Any(tag => tag.Length > 32 || tag.Any(character => !char.IsLetterOrDigit(character) && character is not '-' and not '_'))) { return BadRequest(new { error = "Use up to 30 tags containing letters, numbers, hyphens, or underscores." }); } room.Name = name; room.Description = description; room.ImageName = imageName; room.Accessibility = accessibility; room.State = state; room.MaxPlayers = request.MaxPlayers; room.MinLevel = request.MinLevel; room.CloningAllowed = request.CloningAllowed; room.DisableMicAutoMute = request.DisableMicAutoMute; room.DisableRoomComments = request.DisableRoomComments; room.EncryptVoiceChat = request.EncryptVoiceChat; room.ToxmodEnabled = request.ToxmodEnabled; room.LoadScreenLocked = request.LoadScreenLocked; room.AutoLocalizeRoom = request.AutoLocalizeRoom; room.IsDeveloperOwned = request.IsDeveloperOwned; room.SupportsLevelVoting = request.SupportsLevelVoting; room.IsRRO = request.IsRRO; room.SupportsScreens = request.SupportsScreens; room.SupportsWalkVR = request.SupportsWalkVR; room.SupportsTeleportVR = request.SupportsTeleportVR; room.SupportsVRLow = request.SupportsVRLow; room.SupportsQuest2 = request.SupportsQuest2; room.SupportsMobile = request.SupportsMobile; room.SupportsJuniors = request.SupportsJuniors; bool betaEnabled = request.CreativeToolsBetaEnabled ?? request.SupportsBetaContent ?? request.IsBeta ?? room.CreativeToolsBetaEnabled; room.CreativeToolsBetaEnabled = betaEnabled; bool canonicalBaseRoom = RoomDB.IsCanonicalBaseRoom(room); room.IsBaseRoom = canonicalBaseRoom || (request.IsBaseRoom ?? room.IsBaseRoom); if (room.IsBaseRoom) { room.IsDorm = false; room.IsRRO = true; room.Accessibility = RoomDBClasses.RoomAccessibility.Public; room.State = RoomDBClasses.RoomState.Active; } var persistedTags = tags.Select(tag => new RoomDBClasses.Tags { Tag = tag, Type = RoomDBClasses.TagType.General }).ToList(); if (betaEnabled && !persistedTags.Any(tag => string.Equals(tag.Tag, "beta", StringComparison.OrdinalIgnoreCase))) { persistedTags.Add(new RoomDBClasses.Tags { Tag = "beta", Type = RoomDBClasses.TagType.Auto }); } if (room.IsBaseRoom) { persistedTags.RemoveAll(tag => string.Equals(tag.Tag, "base", StringComparison.OrdinalIgnoreCase) || string.Equals(tag.Tag, "rro", StringComparison.OrdinalIgnoreCase)); persistedTags.Add(new RoomDBClasses.Tags { Tag = "base", Type = RoomDBClasses.TagType.Auto }); persistedTags.Add(new RoomDBClasses.Tags { Tag = "rro", Type = RoomDBClasses.TagType.AGOnly }); } room.Tags = persistedTags .GroupBy(tag => tag.Tag, StringComparer.OrdinalIgnoreCase) .Select(group => group.First()) .Take(30) .ToList(); room.UgcVersion = Math.Max(1, room.UgcVersion + 1); if (!RoomDB.Rooms.Update(room)) return StatusCode(500, new { error = "Room changes could not be saved." }); LogAdminRoomAction(admin!, room, "updated room settings"); return Ok(ToAdminRoomDetails(room)); } [HttpPut("admin/rooms/{roomId:long}/stats")] public IActionResult UpdateAdminRoomStats( long roomId, [FromBody] AdminRoomStatsUpdate request) { var admin = AuthStuff.GetCurrentPlayer(Request); if (!IsAdmin(admin)) return StatusCode(403); var room = RoomDB.Rooms.FindById(roomId); if (room == null) return NotFound(new { error = "Room not found." }); if (request.Cheers < 0 || request.Favorites < 0 || request.Visitors < 0 || request.Visits < 0) { return BadRequest(new { error = "Room stats cannot be negative." }); } room.Stats ??= new RoomDBClasses.Stats(); room.Stats.CheerCount = request.Cheers; room.Stats.FavoriteCount = request.Favorites; room.Stats.VisitorCount = request.Visitors; room.Stats.VisitCount = request.Visits; RoomDB.Rooms.Update(room); LogAdminRoomAction(admin!, room, "updated room stats"); return Ok(ToAdminRoomDetails(room)); } [HttpPut("admin/rooms/{roomId:long}/roles/{accountId:long}")] public IActionResult SetAdminRoomRole( long roomId, long accountId, [FromBody] AdminRoomRoleUpdate request) { var admin = AuthStuff.GetCurrentPlayer(Request); if (!IsAdmin(admin)) return StatusCode(403); var room = RoomDB.Rooms.FindById(roomId); var account = PlayerDB.Players.FindById(accountId); if (room == null) return NotFound(new { error = "Room not found." }); if (account?.Player == null) return NotFound(new { error = "Player not found." }); if (!Enum.TryParse(request.Role, true, out RoomDBClasses.Role role) || !Enum.TryParse(request.InvitedRole, true, out RoomDBClasses.Role invitedRole)) { return BadRequest(new { error = "Choose valid assigned and invited roles." }); } if (role == RoomDBClasses.Role.Creator || invitedRole == RoomDBClasses.Role.Creator) { return BadRequest(new { error = "Use Transfer Ownership to assign the Creator role." }); } if (accountId == room.CreatorAccountId) return BadRequest(new { error = "The room creator's role cannot be changed here." }); room.Roles ??= new List(); room.Roles.RemoveAll(existing => existing.AccountId == accountId); if (role != RoomDBClasses.Role.None || invitedRole != RoomDBClasses.Role.None) { room.Roles.Add(new RoomDBClasses.Roles { AccountId = accountId, Role = role, InvitedRole = invitedRole }); } room.UgcVersion = Math.Max(1, room.UgcVersion + 1); RoomDB.Rooms.Update(room); LogAdminRoomAction( admin!, room, $"set player {accountId} role={role} invitedRole={invitedRole}"); return Ok(ToAdminRoomDetails(room)); } [HttpDelete("admin/rooms/{roomId:long}/roles/{accountId:long}")] public IActionResult DeleteAdminRoomRole(long roomId, long accountId) { var admin = AuthStuff.GetCurrentPlayer(Request); if (!IsAdmin(admin)) return StatusCode(403); var room = RoomDB.Rooms.FindById(roomId); if (room == null) return NotFound(new { error = "Room not found." }); if (accountId == room.CreatorAccountId) return BadRequest(new { error = "Transfer ownership before removing the creator." }); room.Roles ??= new List(); int removed = room.Roles.RemoveAll(role => role.AccountId == accountId); if (removed == 0) return NotFound(new { error = "That player does not have a room role." }); room.UgcVersion = Math.Max(1, room.UgcVersion + 1); RoomDB.Rooms.Update(room); LogAdminRoomAction(admin!, room, $"removed player {accountId} room role"); return Ok(ToAdminRoomDetails(room)); } [HttpPost("admin/rooms/{roomId:long}/transfer-owner")] public IActionResult TransferAdminRoomOwner( long roomId, [FromBody] AdminRoomOwnerTransfer request) { var admin = AuthStuff.GetCurrentPlayer(Request); if (!IsDeveloper(admin)) return StatusCode(403, new { error = "Only Developers can transfer room ownership." }); var room = RoomDB.Rooms.FindById(roomId); var newOwner = PlayerDB.Players.FindById(request.AccountId); if (room == null) return NotFound(new { error = "Room not found." }); if (room.IsDorm) return BadRequest(new { error = "Dorm ownership cannot be transferred." }); if (newOwner?.Player == null) return NotFound(new { error = "New owner account not found." }); if (request.AccountId == room.CreatorAccountId) return BadRequest(new { error = "That player already owns the room." }); long previousOwnerId = room.CreatorAccountId; room.CreatorAccountId = request.AccountId; room.Roles ??= new List(); room.Roles.RemoveAll(role => role.AccountId == previousOwnerId || role.AccountId == request.AccountId); room.Roles.Add(new RoomDBClasses.Roles { AccountId = previousOwnerId, Role = RoomDBClasses.Role.CoOwner, InvitedRole = RoomDBClasses.Role.None }); room.Roles.Add(new RoomDBClasses.Roles { AccountId = request.AccountId, Role = RoomDBClasses.Role.Creator, InvitedRole = RoomDBClasses.Role.None }); room.UgcVersion = Math.Max(1, room.UgcVersion + 1); RoomDB.Rooms.Update(room); LogAdminRoomAction( admin!, room, $"transferred ownership {previousOwnerId}->{request.AccountId}"); return Ok(ToAdminRoomDetails(room)); } [HttpPost("admin/rooms/{roomId:long}/change-id")] public IActionResult ChangeAdminRoomId( long roomId, [FromBody] AdminRoomIdChange request) { var admin = AuthStuff.GetCurrentPlayer(Request); if (!IsDeveloper(admin)) return StatusCode(403, new { error = "Only Developers can change a room's ID." }); if (!RoomDB.ChangeRoomId(roomId, request.NewRoomId, out string error)) return BadRequest(new { error }); CreatorFeatureDB.MigrateRoomId(roomId, request.NewRoomId); var room = RoomDB.Rooms.FindById(request.NewRoomId); Console.WriteLine( $"[ADMIN ROOM] admin={admin!.PlayerId} changed room id {roomId}->{request.NewRoomId}"); return Ok(ToAdminRoomDetails(room)); } public class AdminRoomIdChange { public long NewRoomId { get; set; } } [HttpPost("admin/rooms/{roomId:long}/subrooms")] public IActionResult AddAdminSubRoom( long roomId, [FromBody] AdminSubRoomCreate request) { var admin = AuthStuff.GetCurrentPlayer(Request); if (!IsAdmin(admin)) return StatusCode(403); if (RoomDB.Rooms.FindById(roomId) == null) return NotFound(new { error = "Room not found." }); string name = request.Name?.Trim() ?? string.Empty; if (name.Length is < 1 or > 50) return BadRequest(new { error = "Subroom name must be 1-50 characters." }); var updated = RoomDB.AddSubRoom(roomId, admin!.PlayerId, name); var room = RoomDB.Rooms.FindById(roomId); if (updated == null || room == null) return StatusCode(500, new { error = "Subroom could not be created." }); LogAdminRoomAction(admin, room, $"created subroom {name}"); return Ok(ToAdminRoomDetails(room)); } [HttpPost("admin/rooms/{roomId:long}/subrooms/{subRoomId:long}/clone")] public IActionResult CloneAdminSubRoom(long roomId, long subRoomId) { var admin = AuthStuff.GetCurrentPlayer(Request); if (!IsAdmin(admin)) return StatusCode(403); var updated = RoomDB.CloneSubRoom( roomId, subRoomId, admin!.PlayerId); var room = RoomDB.Rooms.FindById(roomId); if (updated == null || room == null) return NotFound(new { error = "Room or subroom not found." }); LogAdminRoomAction(admin, room, $"cloned subroom {subRoomId}"); return Ok(ToAdminRoomDetails(room)); } [HttpPut("admin/rooms/{roomId:long}/subrooms/{subRoomId:long}")] public IActionResult UpdateAdminSubRoom( long roomId, long subRoomId, [FromBody] AdminSubRoomUpdate request) { var admin = AuthStuff.GetCurrentPlayer(Request); if (!IsAdmin(admin)) return StatusCode(403); var room = RoomDB.Rooms.FindById(roomId); var subRoom = room?.SubRooms?.FirstOrDefault(value => value.SubRoomId == subRoomId); if (room == null || subRoom == null) return NotFound(new { error = "Room or subroom not found." }); string name = request.Name?.Trim() ?? string.Empty; if (name.Length is < 1 or > 50) return BadRequest(new { error = "Subroom name must be 1-50 characters." }); if (request.MaxPlayers is < 1 or > 100) return BadRequest(new { error = "Subroom capacity must be between 1 and 100." }); if (!Enum.TryParse( request.Accessibility, true, out RoomDBClasses.RoomAccessibility accessibility)) { return BadRequest(new { error = "Choose valid subroom accessibility." }); } string unitySceneId = request.UnitySceneId?.Trim() ?? string.Empty; if (unitySceneId.Length > 200 || unitySceneId.Any(character => character == '\0')) return BadRequest(new { error = "Unity scene ID is invalid." }); subRoom.Name = name; subRoom.MaxPlayers = request.MaxPlayers; subRoom.Accessibility = accessibility; subRoom.IsSandbox = request.IsSandbox; subRoom.UnitySceneId = unitySceneId; if (request.Permissions != null) { subRoom.Permissions = request.Permissions .Where(permission => !string.IsNullOrWhiteSpace(permission.Permission)) .Select(permission => new RoomDBClasses.SubRoomPermission { Override = permission.Override, Permission = permission.Permission.Trim()[..Math.Min( permission.Permission.Trim().Length, 128)], Role = Math.Clamp(permission.Role, 0, byte.MaxValue), Type = Math.Clamp(permission.Type, 0, 32), Value = (permission.Value ?? "True").Trim()[..Math.Min( (permission.Value ?? "True").Trim().Length, 256)] }) .GroupBy(permission => new { Name = permission.Permission.ToUpperInvariant(), permission.Role, permission.Type }) .Select(group => group.Last()) .Take(256) .ToList(); } room.UgcVersion = Math.Max(1, room.UgcVersion + 1); RoomDB.Rooms.Update(room); LogAdminRoomAction(admin!, room, $"updated subroom {subRoomId}"); return Ok(ToAdminRoomDetails(room)); } [HttpPut("admin/rooms/{roomId:long}/subrooms/{subRoomId:long}/blobs")] public IActionResult UpdateAdminSubRoomBlobs( long roomId, long subRoomId, [FromBody] AdminRoomBlobUpdate request) { var admin = AuthStuff.GetCurrentPlayer(Request); if (!IsAdmin(admin)) return StatusCode(403); var room = RoomDB.Rooms.FindById(roomId); var subRoom = room?.SubRooms?.FirstOrDefault(value => value.SubRoomId == subRoomId); if (room == null || subRoom == null) return NotFound(new { error = "Room or subroom not found." }); static bool TryNormalizeBlobName( string? input, out string blobName, out string error) { blobName = input?.Trim() ?? string.Empty; error = string.Empty; if (blobName.Length is < 1 or > 255) { error = "Blob names must be between 1 and 255 characters."; return false; } if (blobName.Any(character => character == '\0') || blobName is "." or ".." || !string.Equals( Path.GetFileName(blobName), blobName, StringComparison.Ordinal)) { error = "Blob names must be filenames only, without folders or traversal characters."; return false; } return true; } if (!TryNormalizeBlobName( request.RoomBlob, out string roomBlob, out string roomBlobError)) { return BadRequest(new { error = $"RoomBlob: {roomBlobError}" }); } if (!TryNormalizeBlobName( request.MetadataBlob, out string metadataBlob, out string metadataBlobError)) { return BadRequest(new { error = $"Metadata blob: {metadataBlobError}" }); } if (string.Equals( roomBlob, metadataBlob, StringComparison.OrdinalIgnoreCase)) { return BadRequest(new { error = "RoomBlob and metadata blob must point to two different files." }); } string blobDirectory = Path.Combine( Program.dataDir, "CDN", "room"); string roomBlobPath = Path.Combine(blobDirectory, roomBlob); string metadataBlobPath = Path.Combine(blobDirectory, metadataBlob); if (!System.IO.File.Exists(roomBlobPath)) { return BadRequest(new { error = $"RoomBlob '{roomBlob}' does not exist in CDN/room." }); } if (!System.IO.File.Exists(metadataBlobPath)) { return BadRequest(new { error = $"Metadata blob '{metadataBlob}' does not exist in CDN/room." }); } string roomBlobHash; using (var stream = System.IO.File.OpenRead(roomBlobPath)) { roomBlobHash = Convert .ToHexString(System.Security.Cryptography.SHA256.HashData(stream)) .ToLowerInvariant(); } var save = RoomDB.CreateSubRoomDataSave( roomId, subRoomId, admin!.PlayerId, roomBlob, room.PersistenceVersion, isPublished: true, dataBlobHash: roomBlobHash, description: "Blob pair changed from the RecNet Admin panel.", roomDataBlob: metadataBlob); room.DataBlob = metadataBlob; subRoom.DataBlob = roomBlob; subRoom.SubRoomDataSaveId = save.SubRoomDataSaveId; subRoom.SubRoomDataSave = save; subRoom.SavedByAccountId = admin.PlayerId; room.UgcVersion = Math.Max(1, room.UgcVersion + 1); if (!RoomDB.Rooms.Update(room)) { RoomDB.SubRoomDataSaves.DeleteMany(existing => existing.SubRoomDataSaveId == save.SubRoomDataSaveId); return StatusCode(500, new { error = "The blob pair could not be saved to the room database." }); } LogAdminRoomAction( admin, room, $"changed subroom {subRoomId} blobs " + $"RoomBlob={roomBlob} MetadataBlob={metadataBlob}"); return Ok(ToAdminRoomDetails(room)); } [HttpDelete("admin/rooms/{roomId:long}/subrooms/{subRoomId:long}")] public IActionResult DeleteAdminSubRoom(long roomId, long subRoomId) { var admin = AuthStuff.GetCurrentPlayer(Request); if (!IsDeveloper(admin)) return StatusCode(403, new { error = "Only Developers can delete subrooms." }); var room = RoomDB.Rooms.FindById(roomId); if (room?.SubRooms == null) return NotFound(new { error = "Room not found." }); if (room.SubRooms.Count <= 1) return BadRequest(new { error = "A room must keep at least one subroom." }); int removed = room.SubRooms.RemoveAll(value => value.SubRoomId == subRoomId); if (removed == 0) return NotFound(new { error = "Subroom not found." }); RoomDB.SubRoomDataSaves.DeleteMany(save => save.RoomId == roomId && save.SubRoomId == subRoomId); room.UgcVersion = Math.Max(1, room.UgcVersion + 1); RoomDB.Rooms.Update(room); LogAdminRoomAction(admin!, room, $"deleted subroom {subRoomId}"); return Ok(ToAdminRoomDetails(room)); } [HttpPost("admin/rooms/{roomId:long}/bans")] public IActionResult AddAdminRoomBan( long roomId, [FromBody] AdminRoomBanUpdate request) { var admin = AuthStuff.GetCurrentPlayer(Request); if (!IsStaff(admin)) return StatusCode(403); var room = RoomDB.Rooms.FindById(roomId); var target = PlayerDB.Players.FindById(request.AccountId); if (room == null) return NotFound(new { error = "Room not found." }); if (target?.Player == null) return NotFound(new { error = "Player not found." }); if (IsDeveloper(target) && !IsDeveloper(admin)) return StatusCode(StatusCodes.Status403Forbidden, new { error = "Moderators cannot room-ban Developer accounts." }); if (request.AccountId == room.CreatorAccountId) return BadRequest(new { error = "The room creator cannot be banned." }); string reason = request.Reason?.Trim() ?? string.Empty; if (reason.Length is < 1 or > 500) return BadRequest(new { error = "Room-ban reason must be 1-500 characters." }); RoomDB.BanPlayerFromRoom( roomId, request.AccountId, admin!.PlayerId, reason); LogAdminRoomAction(admin, room, $"banned player {request.AccountId}"); return Ok(ToAdminRoomDetails(room)); } [HttpDelete("admin/rooms/{roomId:long}/bans/{accountId:long}")] public IActionResult DeleteAdminRoomBan(long roomId, long accountId) { var admin = AuthStuff.GetCurrentPlayer(Request); if (!IsStaff(admin)) return StatusCode(403); var room = RoomDB.Rooms.FindById(roomId); if (room == null) return NotFound(new { error = "Room not found." }); if (!RoomDB.UnbanPlayerFromRoom(roomId, accountId)) return NotFound(new { error = "That player is not banned from this room." }); LogAdminRoomAction(admin!, room, $"unbanned player {accountId}"); return Ok(ToAdminRoomDetails(room)); } [HttpGet("admin/instances")] public IActionResult GetAdminLiveInstances() { var admin = AuthStuff.GetCurrentPlayer(Request); if (!IsStaff(admin)) return StatusCode(403); var accounts = PlayerDB.Players.FindAll() .Where(value => value.Player != null) .ToList(); var onlinePlayers = accounts .Select(account => new { Account = account, Heartbeat = PlayerDB.GetPlayerHeartbeat(account.PlayerId) }) .Where(value => value.Heartbeat.isOnline) .OrderBy(value => value.Account.Player!.DisplayName) .Select(value => new { accountId = value.Account.PlayerId, username = value.Account.Player!.Username, displayName = value.Account.Player.DisplayName, profileImage = ImageUrl(value.Account.Player.ProfileImage), device = value.Heartbeat.deviceClass?.ToString() ?? "Unknown", room = value.Heartbeat.roomInstance?.Name ?? value.Heartbeat.roomInstance?.location ?? "Online", roomId = value.Heartbeat.roomInstance?.roomId, roomInstanceId = value.Heartbeat.roomInstance?.roomInstanceId, lastHeartbeat = value.Heartbeat.lastHeartbeatUnixTime > 0 ? DateTimeOffset.FromUnixTimeSeconds( value.Heartbeat.lastHeartbeatUnixTime) : (DateTimeOffset?)null, sockets = NotiController.GetPlayerSocketCount( value.Account.PlayerId) }) .ToList(); var groupedByRoom = onlinePlayers .GroupBy(p => new { p.room, p.roomId, p.roomInstanceId }) .Select(g => new { room = g.Key.room, roomId = g.Key.roomId, roomInstanceId = g.Key.roomInstanceId, playerCount = g.Count(), players = g.ToList() }) .OrderByDescending(g => g.playerCount) .ThenBy(g => g.room) .ToList(); return Ok(new { totalOnline = onlinePlayers.Count, instances = groupedByRoom }); } [HttpPost("admin/instances/{instanceId:long}/shutdown")] public async Task ShutdownAdminInstance(long instanceId) { var admin = AuthStuff.GetCurrentPlayer(Request); if (!IsStaff(admin)) return StatusCode(403); var accounts = PlayerDB.Players.FindAll() .Where(value => value.Player != null) .ToList(); var playersInInstance = accounts .Select(account => new { Account = account, Heartbeat = PlayerDB.GetPlayerHeartbeat(account.PlayerId) }) .Where(value => value.Heartbeat.isOnline && value.Heartbeat.roomInstance?.roomInstanceId == instanceId) .ToList(); if (!playersInInstance.Any()) return BadRequest(new { error = "No players found in this instance." }); int movedCount = 0; foreach (var playerData in playersInInstance) { try { var dormHeartbeat = Sessions.CreateDorm( playerData.Account.PlayerId, playerData.Account.Player!.Username ?? "Unknown" ); if (dormHeartbeat != null) { Sessions.MarkGuestDormEntry(playerData.Account.PlayerId); movedCount++; } } catch (Exception ex) { Console.WriteLine($"Failed to move player {playerData.Account.PlayerId} to dorm: {ex.Message}"); } } return Ok(new { success = true, movedCount, totalPlayers = playersInInstance.Count }); } [HttpGet("developer/snapshot")] public IActionResult GetDeveloperSnapshot() { var developer = AuthStuff.GetCurrentPlayer(Request); if (!IsDeveloper(developer)) return StatusCode(403); return Ok(BuildDeveloperSnapshot()); } [HttpGet("developer/chats")] public IActionResult GetDeveloperChats( [FromQuery] int take = 100, [FromQuery] long? beforeMessageId = null) { var developer = AuthStuff.GetCurrentPlayer(Request); if (!IsDeveloper(developer)) return StatusCode(403); return Ok(BuildDeveloperChats(take, beforeMessageId)); } [HttpGet("developer/stream")] public async Task StreamDeveloperDashboard() { var developer = AuthStuff.GetCurrentPlayer(Request); if (!IsDeveloper(developer)) return StatusCode(403); Response.StatusCode = StatusCodes.Status200OK; Response.ContentType = "text/event-stream"; Response.Headers.CacheControl = "no-store"; Response.Headers.Connection = "keep-alive"; Response.Headers.Append("X-Accel-Buffering", "no"); try { while (!HttpContext.RequestAborted.IsCancellationRequested) { string json = JsonSerializer.Serialize( BuildDeveloperSnapshot()); await Response.WriteAsync( $"event: snapshot\ndata: {json}\n\n", HttpContext.RequestAborted); await Response.Body.FlushAsync( HttpContext.RequestAborted); await Task.Delay( TimeSpan.FromSeconds(1), HttpContext.RequestAborted); } } catch (OperationCanceledException) { } return new EmptyResult(); } [HttpGet("admin/settings")] public IActionResult GetAdminSiteSettings() { var admin = AuthStuff.GetCurrentPlayer(Request); if (!IsAdmin(admin)) return StatusCode(403); return Ok(new { accountCreationEnabled = RecNetDB.IsAccountCreationEnabled(), recNetSignupEnabled = RecNetDB.IsRecNetSignupEnabled(), vpnBlockingEnabled = RecNetDB.IsVpnBlockingEnabled(), proxyCheckConfigured = !string.IsNullOrWhiteSpace( Program.LoadLocalSetting("PROXYCHECK_API_KEY")) }); } [HttpPut("admin/settings")] public IActionResult UpdateAdminSiteSettings( [FromBody] AdminSiteSettings request) { var admin = AuthStuff.GetCurrentPlayer(Request); if (!IsAdmin(admin)) return StatusCode(403); if (request.AccountCreationEnabled.HasValue) { RecNetDB.SetAccountCreationEnabled( request.AccountCreationEnabled.Value, admin!.PlayerId); } if (request.RecNetSignupEnabled.HasValue) { RecNetDB.SetRecNetSignupEnabled( request.RecNetSignupEnabled.Value, admin!.PlayerId); } if (request.VpnBlockingEnabled.HasValue) { RecNetDB.SetVpnBlockingEnabled( request.VpnBlockingEnabled.Value, admin!.PlayerId); } bool accountCreationEnabled = RecNetDB.IsAccountCreationEnabled(); bool recNetSignupEnabled = RecNetDB.IsRecNetSignupEnabled(); bool vpnBlockingEnabled = RecNetDB.IsVpnBlockingEnabled(); DiscordLogger.Log( $"🔐 **Security settings changed** by `{admin!.PlayerId}` — " + $"account creation: `{accountCreationEnabled}`, " + $"RecNet signup: `{recNetSignupEnabled}`, " + $"VPN blocking: `{vpnBlockingEnabled}`"); return Ok(new { success = true, accountCreationEnabled, recNetSignupEnabled, vpnBlockingEnabled }); } [HttpGet("admin/logs")] public IActionResult GetAdminLogs([FromQuery] int take = 500) { var admin = AuthStuff.GetCurrentPlayer(Request); if (!IsDeveloper(admin)) return StatusCode(403, new { error = "Only Developers can view server logs." }); take = Math.Clamp(take, 1, 5000); List lines = LogBuffer.Snapshot(take); return Ok(new { lines, count = lines.Count }); } [HttpGet("admin/scanner-logs")] public IActionResult GetAdminScannerLogs( [FromQuery] int skip = 0, [FromQuery] int take = 200, [FromQuery] string? ip = null) { var admin = AuthStuff.GetCurrentPlayer(Request); if (!IsDeveloper(admin)) return StatusCode(403, new { error = "Only Developers can view scanner logs." }); skip = Math.Max(0, skip); take = Math.Clamp(take, 1, 500); var query = ScannerLogDB.Attempts.Query(); if (!string.IsNullOrWhiteSpace(ip)) query = query.Where(a => a.IpAddress == ip.Trim()); List page = query .OrderByDescending(a => a.Id) .Skip(skip) .Limit(take) .ToList(); return Ok(new { total = ScannerLogDB.Attempts.LongCount(), results = page.Select(a => new { id = a.Id, timestamp = a.Timestamp, ipAddress = a.IpAddress, method = a.Method, path = a.Path, queryString = a.QueryString, userAgent = a.UserAgent, matchedPattern = a.MatchedPattern }) }); } [HttpGet("admin/anticheat-logs")] public IActionResult GetAdminAnticheatLogs( [FromQuery] int skip = 0, [FromQuery] int take = 200, [FromQuery] string? ip = null) { var admin = AuthStuff.GetCurrentPlayer(Request); if (!IsDeveloper(admin)) return StatusCode(403, new { error = "Only Developers can view anticheat logs." }); skip = Math.Max(0, skip); take = Math.Clamp(take, 1, 500); var query = AnticheatLogDB.Entries.Query(); if (!string.IsNullOrWhiteSpace(ip)) query = query.Where(a => a.IpAddress == ip.Trim()); List page = query .OrderByDescending(a => a.Id) .Skip(skip) .Limit(take) .ToList(); return Ok(new { total = AnticheatLogDB.Entries.LongCount(), results = page.Select(a => new { id = a.Id, timestamp = a.Timestamp, ipAddress = a.IpAddress, accountId = a.AccountId, steamId = a.SteamId, build = a.Build, flags = a.Flags, userAgent = a.UserAgent }) }); } [HttpGet("admin/ip-bans")] public IActionResult GetAdminIpBans() { var admin = AuthStuff.GetCurrentPlayer(Request); if (!IsAdmin(admin)) return StatusCode(403); return Ok(RecNetDB.GetIpBans().Select(ToAdminIpBan)); } [HttpPost("admin/ip-bans")] public IActionResult AddAdminIpBan([FromBody] AdminIpBanRequest request) { var admin = AuthStuff.GetCurrentPlayer(Request); if (!IsAdmin(admin)) return StatusCode(403); try { RecNetDB.IpBan record = RecNetDB.AddIpBan( request.Network ?? string.Empty, request.Reason, admin!.PlayerId); DiscordLogger.Log( $"🚫 **IP ban added** by `{admin.PlayerId}` — " + $"`{record.Network}` — {record.Reason}"); return Ok(ToAdminIpBan(record)); } catch (ArgumentException exception) { return BadRequest(new { error = exception.Message }); } } [HttpDelete("admin/ip-bans/{id}")] public IActionResult DeleteAdminIpBan(string id) { var admin = AuthStuff.GetCurrentPlayer(Request); if (!IsAdmin(admin)) return StatusCode(403); RecNetDB.IpBan? record = RecNetDB.IpBans.FindById(id); if (record == null || !RecNetDB.RemoveIpBan(id)) return NotFound(new { error = "IP ban not found." }); DiscordLogger.Log( $"✅ **IP ban removed** by `{admin!.PlayerId}` — `{record.Network}`"); return Ok(new { success = true }); } private static object ToAdminIpBan(RecNetDB.IpBan value) => new { id = value.Id, network = value.Network, reason = value.Reason, createdByAccountId = value.CreatedByAccountId, createdAt = value.CreatedAt }; [HttpGet("admin/community-board")] public IActionResult GetAdminCommunityBoard() { var admin = AuthStuff.GetCurrentPlayer(Request); if (!IsAdmin(admin)) return StatusCode(403); string path = Path.Combine(Program.dataDir, "communityboard.json"); if (!System.IO.File.Exists(path)) { return NotFound(new { error = "communityboard.json was not found in the live Data folder." }); } try { string json = System.IO.File.ReadAllText(path); using JsonDocument document = JsonDocument.Parse(json); return Content(json, "application/json"); } catch (Exception ex) when (ex is IOException or JsonException) { return StatusCode(500, new { error = $"The live community board could not be read: {ex.Message}" }); } } [HttpPut("admin/community-board")] [RequestSizeLimit(256 * 1024)] public async Task UpdateAdminCommunityBoard( [FromBody] JsonElement request) { var admin = AuthStuff.GetCurrentPlayer(Request); if (!IsAdmin(admin)) return StatusCode(403); if (!TryValidateCommunityBoard(request, out string error)) return BadRequest(new { error }); string path = Path.Combine(Program.dataDir, "communityboard.json"); try { string json = JsonSerializer.Serialize( request, new JsonSerializerOptions { WriteIndented = true }); await WriteCommunityBoardFileAsync( path, json, HttpContext.RequestAborted); await NotiController.BroadcastAnnouncementsUpdatedAsync(); return Content(json, "application/json"); } catch (Exception ex) when (ex is IOException or JsonException or UnauthorizedAccessException) { return StatusCode(500, new { error = $"The community board could not be saved: {ex.Message}" }); } } [HttpPost("admin/community-board/media")] [RequestSizeLimit(105 * 1024 * 1024)] [RequestFormLimits(MultipartBodyLengthLimit = 105 * 1024 * 1024)] public async Task UploadAdminCommunityBoardMedia() { var admin = AuthStuff.GetCurrentPlayer(Request); if (admin?.Player == null) return Unauthorized(new { error = "Log in as a Developer to upload board media." }); if (!IsAdmin(admin)) return StatusCode(403, new { error = "Developer access is required." }); if (!Request.HasFormContentType) return BadRequest(new { error = "Choose a photo or video to upload." }); var form = await Request.ReadFormAsync(HttpContext.RequestAborted); var file = form.Files.FirstOrDefault(); string kind = form["kind"].FirstOrDefault()?.Trim().ToLowerInvariant() ?? string.Empty; if (file == null || file.Length == 0) return BadRequest(new { error = "Choose a photo or video to upload." }); if (kind is not ("image" or "video")) return BadRequest(new { error = "Choose whether this is an image or video." }); long maximumBytes = kind == "image" ? 15L * 1024 * 1024 : 100L * 1024 * 1024; if (file.Length > maximumBytes) { return BadRequest(new { error = kind == "image" ? "Community Board images must be 15 MB or smaller." : "Community Board videos must be 100 MB or smaller." }); } byte[] bytes; using (var buffer = new MemoryStream()) { await file.CopyToAsync(buffer, HttpContext.RequestAborted); bytes = buffer.ToArray(); } string extension; string contentType; if (kind == "image") { SixLabors.ImageSharp.Formats.IImageFormat? format; SixLabors.ImageSharp.ImageInfo? info; try { format = SixLabors.ImageSharp.Image.DetectFormat(bytes); info = SixLabors.ImageSharp.Image.Identify(bytes); } catch { return BadRequest(new { error = "That file is not a valid image." }); } if (format == null || info == null || info.Width <= 0 || info.Height <= 0 || info.Width > 8192 || info.Height > 8192 || (long)info.Width * info.Height > 40_000_000) { return BadRequest(new { error = "Board images cannot exceed 8192 x 8192 or 40 megapixels." }); } extension = format.FileExtensions.FirstOrDefault()?.ToLowerInvariant() ?? string.Empty; if (extension is not ("png" or "jpg" or "jpeg" or "webp" or "gif" or "bmp")) return BadRequest(new { error = "Use a PNG, JPG, WebP, GIF, or BMP image." }); contentType = extension switch { "png" => "image/png", "webp" => "image/webp", "gif" => "image/gif", "bmp" => "image/bmp", _ => "image/jpeg" }; } else { string requestedExtension = Path.GetExtension(file.FileName).ToLowerInvariant(); bool isIsoBaseMedia = bytes.Length >= 12 && bytes[4] == (byte)'f' && bytes[5] == (byte)'t' && bytes[6] == (byte)'y' && bytes[7] == (byte)'p'; bool isWebM = bytes.Length >= 4 && bytes[0] == 0x1A && bytes[1] == 0x45 && bytes[2] == 0xDF && bytes[3] == 0xA3; if (isWebM && requestedExtension == ".webm") { extension = "webm"; contentType = "video/webm"; } else if (isIsoBaseMedia && requestedExtension is ".mp4" or ".m4v" or ".mov") { extension = requestedExtension.TrimStart('.'); contentType = requestedExtension == ".mov" ? "video/quicktime" : "video/mp4"; } else { return BadRequest(new { error = "Use an MP4, M4V, MOV, or WebM video with a matching file extension." }); } } string folder = kind == "image" ? Path.Combine(Program.dataDir, "Images", "CommunityBoardUploads") : Path.Combine(Program.dataDir, "CDN", "video"); Directory.CreateDirectory(folder); string storedFileName = $"board_{kind}_{admin!.PlayerId}_{Guid.NewGuid():N}.{extension}"; await System.IO.File.WriteAllBytesAsync( Path.Combine(folder, storedFileName), bytes, HttpContext.RequestAborted); string relativeUrl = kind == "image" ? "/imageserver/CommunityBoardUploads/" + Uri.EscapeDataString(storedFileName) : "/cdn/video/" + Uri.EscapeDataString(storedFileName); string absoluteUrl = $"{Request.Scheme}://{Request.Host}{relativeUrl}"; return Ok(new { success = true, kind, fileName = storedFileName, originalName = Path.GetFileName(file.FileName), contentType, size = file.Length, imageName = kind == "image" ? "CommunityBoardUploads/" + storedFileName : null, path = relativeUrl, url = absoluteUrl }); } private static async Task WriteCommunityBoardFileAsync( string path, string json, CancellationToken cancellationToken) { await CommunityBoardWriteLock.WaitAsync(cancellationToken); string temporaryPath = path + "." + Guid.NewGuid().ToString("N") + ".tmp"; try { Directory.CreateDirectory(Path.GetDirectoryName(path)!); await System.IO.File.WriteAllTextAsync( temporaryPath, json, cancellationToken); System.IO.File.Move(temporaryPath, path, overwrite: true); } finally { if (System.IO.File.Exists(temporaryPath)) System.IO.File.Delete(temporaryPath); CommunityBoardWriteLock.Release(); } } [HttpPost("admin/maintenance")] [RequestSizeLimit(16 * 1024)] public async Task StartAdminMaintenance( [FromBody] AdminMaintenanceRequest request) { var admin = AuthStuff.GetCurrentPlayer(Request); if (!IsAdmin(admin)) return StatusCode(403); if (request.Minutes is < 0 or > 10_080) return BadRequest(new { error = "Maintenance must be between 0 and 10,080 minutes." }); try { await Program.SetMaintenanceCountdownAsync(request.Minutes); return Ok(new { success = true, minutes = request.Minutes, message = request.Minutes == 0 ? "The maintenance notice was cleared." : $"The {request.Minutes}-minute maintenance countdown is live in-game." }); } catch (Exception ex) when (ex is ArgumentOutOfRangeException or FileNotFoundException or InvalidDataException or JsonException or IOException) { return StatusCode(500, new { error = ex.Message }); } } [HttpGet("admin/steam-blacklist")] public IActionResult GetAdminSteamBlacklist() { var admin = AuthStuff.GetCurrentPlayer(Request); if (!IsAdmin(admin)) return StatusCode(403); return Ok( SteamAccessDB.GetAll() .Select(ToAdminSteamBlacklistEntry) .ToList()); } [HttpPost("admin/steam-blacklist")] public IActionResult AddAdminSteamBlacklist( [FromBody] AdminSteamBlacklistRequest request) { var admin = AuthStuff.GetCurrentPlayer(Request); if (!IsAdmin(admin)) return StatusCode(403); if (!ulong.TryParse( request.SteamId?.Trim(), out ulong steamId) || steamId == 0) { return BadRequest(new { error = "Enter a valid numeric Steam ID." }); } if ((request.Reason?.Trim().Length ?? 0) > 500) { return BadRequest(new { error = "Blacklist reasons cannot exceed 500 characters." }); } SteamAccessDB.SteamBlacklistEntry entry = SteamAccessDB.AddOrUpdate( steamId, request.Reason, admin!.PlayerId); string actor = admin.Player?.Username ?? admin.Player?.DisplayName ?? admin.PlayerId.ToString(); Console.WriteLine( $"[ADMIN STEAM BLACKLIST] admin={admin.PlayerId} " + $"steamId={steamId} action=blocked"); DiscordLogger.Log( $"🚫 **Steam blacklist** — `@{actor}` (`{admin.PlayerId}`) " + $"blocked Steam ID `{steamId}`. Reason: `{entry.Reason}`"); return Ok(ToAdminSteamBlacklistEntry(entry)); } [HttpDelete("admin/steam-blacklist/{steamIdText}")] public IActionResult RemoveAdminSteamBlacklist( string steamIdText) { var admin = AuthStuff.GetCurrentPlayer(Request); if (!IsAdmin(admin)) return StatusCode(403); if (!ulong.TryParse(steamIdText, out ulong steamId) || steamId == 0) { return BadRequest(new { error = "Enter a valid numeric Steam ID." }); } if (!SteamAccessDB.Remove(steamId)) { return NotFound(new { error = "That Steam ID is not blacklisted." }); } string actor = admin!.Player?.Username ?? admin.Player?.DisplayName ?? admin.PlayerId.ToString(); Console.WriteLine( $"[ADMIN STEAM BLACKLIST] admin={admin.PlayerId} " + $"steamId={steamId} action=unblocked"); DiscordLogger.Log( $"✅ **Steam blacklist** — `@{actor}` (`{admin.PlayerId}`) " + $"unblocked Steam ID `{steamId}`."); return Ok(new { success = true, steamId = steamId.ToString() }); } [HttpGet("admin/shop")] public IActionResult GetAdminShop() { var admin = AuthStuff.GetCurrentPlayer(Request); if (!IsAdmin(admin)) return StatusCode(403); return Ok(ToAdminShopPayload(APIController.GetStorefrontAdminInfo())); } [HttpGet("admin/shop/catalog")] public IActionResult SearchAdminShopCatalog( [FromQuery] string? search = null, [FromQuery] string? type = null, [FromQuery] int skip = 0, [FromQuery] int take = 30) { var admin = AuthStuff.GetCurrentPlayer(Request); if (!IsAdmin(admin)) return StatusCode(403); var (items, total) = APIController.SearchStorefrontCatalog(search, type, skip, take); return Ok(new { results = items.Select(ToAdminShopItemPayload), total, skip, take }); } [HttpPost("admin/shop/refresh")] public IActionResult AdminRefreshShop() { var admin = AuthStuff.GetCurrentPlayer(Request); if (!IsAdmin(admin)) return StatusCode(403); long rotation = APIController.RefreshStorefrontRotation(); return Ok(new { success = true, rotation, message = "The shop has been refreshed. Reopen the storefront in-game to see it." }); } [HttpPost("admin/shop/items")] public IActionResult AdminAddShopItem([FromBody] AdminShopItemRequest request) { var admin = AuthStuff.GetCurrentPlayer(Request); if (!IsAdmin(admin)) return StatusCode(403); if (!APIController.TryAddCustomStoreItem(request.SkuId, out string error)) return BadRequest(new { error }); return Ok(new { success = true, shop = ToAdminShopPayload(APIController.GetStorefrontAdminInfo()) }); } [HttpDelete("admin/shop/items/{skuId:long}")] public IActionResult AdminRemoveShopItem(long skuId) { var admin = AuthStuff.GetCurrentPlayer(Request); if (!IsAdmin(admin)) return StatusCode(403); if (!APIController.RemoveCustomStoreItem(skuId)) return NotFound(new { error = "That custom shop item was not found." }); return Ok(new { success = true, shop = ToAdminShopPayload(APIController.GetStorefrontAdminInfo()) }); } [HttpGet("admin/gifts/catalog")] public IActionResult SearchAdminGiftCatalog( [FromQuery] string? search = null, [FromQuery] string? type = null, [FromQuery] int skip = 0, [FromQuery] int take = 50) { var admin = AuthStuff.GetCurrentPlayer(Request); if (!IsAdmin(admin)) return StatusCode(403); var (catalogItems, total) = APIController.SearchStorefrontCatalog(search, type, skip, take); var items = catalogItems .Select(item => new { skuId = item.SkuId, avatarItemId = item.AvatarItemId, friendlyName = item.FriendlyName, avatarItemDesc = item.AvatarItemDesc, consumableItemDesc = item.ConsumableItemDesc, equipmentPrefabName = item.EquipmentPrefabName, equipmentModificationGuid = item.EquipmentModificationGuid, thumbnailImage = item.ThumbnailImage, rarity = item.Rarity, type = !string.IsNullOrWhiteSpace(item.ConsumableItemDesc) ? "consumable" : !string.IsNullOrWhiteSpace(item.EquipmentModificationGuid) ? "equipment" : "avatar" }) .ToList(); return Ok(new { results = items, total, skip, take }); } [HttpPost("admin/gifts")] public async Task AdminSendGift( [FromBody] AdminGiftRequest request) { var admin = AuthStuff.GetCurrentPlayer(Request); if (!IsAdmin(admin)) return StatusCode(403); const int coachAccountId = 1; string giftType = request.GiftType?.Trim().ToLowerInvariant() ?? string.Empty; if (giftType is not ("avatar" or "equipment" or "consumable" or "tokens" or "xp" or "box")) return BadRequest(new { error = "Choose avatar, equipment, consumable, tokens, XP, or box." }); List explicitRecipients = (request.RecipientAccountIds ?? new List()) .Concat(request.RecipientAccountId > 0 ? new[] { request.RecipientAccountId } : Array.Empty()) .Where(id => id > 0) .Distinct() .ToList(); if (!request.SendToAll && explicitRecipients.Count == 0) return BadRequest(new { error = "Choose a recipient, paste multiple IDs, or enable Send to all." }); if (giftType == "tokens" && request.Amount == 0) return BadRequest(new { error = "Token gifts must be a non-zero signed 32-bit amount. Use a negative amount to deduct tokens." }); if (giftType == "xp" && request.Amount is < 1 or > 1000000) return BadRequest(new { error = "XP must be between 1 and 1,000,000." }); if (giftType == "consumable" && request.Amount is < 1 or > 100000) return BadRequest(new { error = "Consumable quantity must be between 1 and 100,000." }); if (giftType == "box" && request.BoxRarity is not (10 or 20 or 30 or 40 or 50)) return BadRequest(new { error = "Box rarity must be 10, 20, 30, 40, or 50." }); if (request.BoxDesign == 0) request.BoxDesign = (int)PlayerDBClasses.GiftContext.Game_Drop; APIController.StorefrontAdminItem? catalogItem = null; List boxPool = new(); if (giftType is "avatar" or "equipment" or "consumable") { catalogItem = APIController.GetStorefrontCatalogItem(request.SkuId); if (catalogItem == null) return BadRequest(new { error = "Choose an item from the gift catalog." }); bool matchesType = giftType switch { "avatar" => !string.IsNullOrWhiteSpace(catalogItem.AvatarItemDesc), "equipment" => !string.IsNullOrWhiteSpace(catalogItem.EquipmentPrefabName) && !string.IsNullOrWhiteSpace(catalogItem.EquipmentModificationGuid), "consumable" => !string.IsNullOrWhiteSpace(catalogItem.ConsumableItemDesc), _ => false }; if (!matchesType) return BadRequest(new { error = "The selected item does not match the gift type." }); } else if (giftType == "box") { boxPool = APIController.GetWebsiteStorefrontItems() .Where(item => item.Rarity == request.BoxRarity && !string.IsNullOrWhiteSpace(item.ConsumableItemDesc)) .ToList(); if (boxPool.Count == 0) return BadRequest(new { error = $"No consumables found at rarity {request.BoxRarity}." }); } List recipients = request.SendToAll ? PlayerDB.Players.FindAll() .Where(account => account?.Player != null && account.PlayerId > 0 && account.PlayerId <= int.MaxValue && account.PlayerId != coachAccountId) .Select(account => account.PlayerId) .Distinct() .OrderBy(id => id) .ToList() : explicitRecipients .Where(id => id != coachAccountId) .OrderBy(id => id) .ToList(); if (request.OnlineOnly) recipients = recipients .Where(id => PlayerDB.GetPlayerHeartbeat(id).isOnline) .ToList(); if (recipients.Count == 0) return BadRequest(new { error = request.OnlineOnly ? "No eligible recipients are currently online." : "No eligible recipients were found." }); string message = (request.Message ?? string.Empty).Trim(); if (message.Length == 0) message = "A gift for you!"; if (message.Length > 200) return BadRequest(new { error = "Gift message cannot exceed 200 characters." }); int queued = 0; var failed = new List(); var liveDeliveries = new List(); foreach (long recipientId in recipients) { var recipient = PlayerDB.Players.FindById(recipientId); if (recipient?.Player == null || recipientId > int.MaxValue) { failed.Add(recipientId); continue; } APIController.StorefrontAdminItem? boxRoll = giftType == "box" ? boxPool[BonusRarity50Rng.Next(boxPool.Count)] : null; var gift = new PlayerDBClasses.GiftPackage { FromPlayerId = coachAccountId, Message = message, AvatarItemDesc = giftType == "avatar" ? catalogItem!.AvatarItemDesc : string.Empty, ConsumableItemDesc = giftType == "consumable" ? catalogItem!.ConsumableItemDesc : giftType == "box" ? boxRoll!.ConsumableItemDesc : string.Empty, ConsumableQuantity = giftType == "consumable" ? Math.Clamp(request.Amount, 1, 100000) : 1, EquipmentPrefabName = giftType == "equipment" ? catalogItem!.EquipmentPrefabName : string.Empty, EquipmentModificationGuid = giftType == "equipment" ? catalogItem!.EquipmentModificationGuid : string.Empty, FriendlyName = giftType == "equipment" ? catalogItem!.FriendlyName : giftType == "box" ? boxRoll!.FriendlyName : string.Empty, ThumbnailImage = giftType == "equipment" ? catalogItem!.ThumbnailImage : giftType == "box" ? boxRoll!.ThumbnailImage : string.Empty, CurrencyType = (int)PlayerDBClasses.CurrencyType.RecCenterTokens, Currency = giftType == "tokens" ? request.Amount : 0, XP = giftType == "xp" ? request.Amount : 0, GiftContext = request.BoxDesign, Rarity = giftType == "box" ? request.BoxRarity : catalogItem?.Rarity ?? 50, Platform = -1, PlatformMask = -1, BalanceType = (int)PlayerDBClasses.BalanceType.NonPurchasedNotUsableInP2P, IsQuery = false, Unique = giftType is "avatar" or "equipment" }; PlayerDBClasses.GiftPackage? queuedGift = PlayerDB.QueueGiftPackage(recipientId, gift); if (queuedGift == null) { failed.Add(recipientId); continue; } queued++; liveDeliveries.Add( new NotiController.GiftLiveDelivery( recipientId, queuedGift)); if (BonusRarity50Rng.Next(100) < BonusRarity50PercentChance) { APIController.StorefrontAdminItem? bonusItem = APIController.GetWebsiteStorefrontItems() .Where(item => item.Rarity == 50 && !string.IsNullOrWhiteSpace(item.ConsumableItemDesc)) .OrderBy(_ => BonusRarity50Rng.Next()) .FirstOrDefault(); if (bonusItem != null) { var bonusGift = new PlayerDBClasses.GiftPackage { FromPlayerId = coachAccountId, Message = "Bonus gift!", ConsumableItemDesc = bonusItem.ConsumableItemDesc, ConsumableQuantity = 1, FriendlyName = bonusItem.FriendlyName, ThumbnailImage = bonusItem.ThumbnailImage, CurrencyType = (int)PlayerDBClasses.CurrencyType.RecCenterTokens, GiftContext = (int)PlayerDBClasses.GiftContext.Consumable, Rarity = 50, Platform = -1, PlatformMask = -1, BalanceType = (int)PlayerDBClasses.BalanceType.NonPurchasedNotUsableInP2P, IsQuery = false, Unique = false }; PlayerDBClasses.GiftPackage? queuedBonus = PlayerDB.QueueGiftPackage(recipientId, bonusGift); if (queuedBonus != null) { liveDeliveries.Add( new NotiController.GiftLiveDelivery( recipientId, queuedBonus)); } } } } NotiController.GiftLiveDeliveryResult liveDelivery = new(); try { liveDelivery = await NotiController.NotifyGiftsAsync( coachAccountId, liveDeliveries, immediate: true); } catch (Exception exception) { liveDelivery.TargetPlayers = queued; liveDelivery.OfflinePlayers = queued; Console.WriteLine( $"[ADMIN GIFT PUSH ERROR] broadcast={request.SendToAll} " + $"queued={queued} error={exception.Message}"); } string giftName = giftType switch { "tokens" => $"{request.Amount:N0} tokens", "xp" => $"{request.Amount:N0} XP", "consumable" => $"{catalogItem!.FriendlyName} x{Math.Clamp(request.Amount, 1, 100000):N0}", "box" => $"a rarity {request.BoxRarity} box", _ => catalogItem?.FriendlyName ?? giftType }; Console.WriteLine( $"[ADMIN GIFT] admin={admin!.PlayerId} from=Coach(1) " + $"gift={giftName} boxDesign={request.BoxDesign} queued={queued} failed={failed.Count} " + $"all={request.SendToAll} onlineOnly={request.OnlineOnly}"); Console.WriteLine( $"[ADMIN GIFT LIVE] queued={queued} " + $"livePlayers={liveDelivery.LivePlayers} " + $"liveSockets={liveDelivery.LiveSockets} " + $"offline={liveDelivery.OfflinePlayers} " + $"all={request.SendToAll} onlineOnly={request.OnlineOnly}"); string targetDescription = request.SendToAll ? $"{queued} player{(queued == 1 ? "" : "s")}{(request.OnlineOnly ? " (online only)" : "")}" : recipients.Count == 1 ? $"account `{recipients[0]}`" : $"{recipients.Count} accounts (`{string.Join(", ", recipients)}`)"; DiscordLogger.Log( $"🎁 **Admin gift** — account `{admin.PlayerId}` sent **{giftName}** " + $"from Coach to {targetDescription}."); if (queued == 0) return StatusCode(StatusCodes.Status500InternalServerError, new { error = "The gift could not be queued.", failedAccountIds = failed }); return Ok(new { success = true, fromAccountId = coachAccountId, from = "Coach", gift = giftName, queued, livePlayers = liveDelivery.LivePlayers, liveSockets = liveDelivery.LiveSockets, pendingPlayers = Math.Max( 0, queued - liveDelivery.LivePlayers), failed = failed.Count, failedAccountIds = failed }); } [HttpPost("admin/gifts/clear-outgoing")] public IActionResult AdminClearOutgoingGifts( [FromBody] AdminClearOutgoingGiftsRequest? request) { var admin = AuthStuff.GetCurrentPlayer(Request); if (!IsAdmin(admin)) return StatusCode(403); long fromPlayerId = request?.FromPlayerId ?? 1; if (fromPlayerId <= 0) return BadRequest(new { error = "Invalid sender account id." }); PlayerDB.ClearOutgoingGiftsResult result = PlayerDB.ClearPendingGiftsFromSender(fromPlayerId); Console.WriteLine( $"[ADMIN GIFT CLEAR] admin={admin!.PlayerId} from={fromPlayerId} " + $"removedBoxes={result.RemovedBoxes} affectedPlayers={result.AffectedPlayers}"); DiscordLogger.Log( $"🧹 **Admin gift clear** — account `{admin.PlayerId}` cleared " + $"**{result.RemovedBoxes}** unclaimed outgoing box(es) from account `{fromPlayerId}` " + $"across **{result.AffectedPlayers}** player(s)."); return Ok(new { success = true, removedBoxes = result.RemovedBoxes, affectedPlayers = result.AffectedPlayers }); } [HttpPut("admin/accounts/{accountId:long}/profile")] public async Task AdminUpdateProfile(long accountId, [FromBody] AdminProfileUpdate request) { var admin = AuthStuff.GetCurrentPlayer(Request); if (!IsAdmin(admin)) return StatusCode(403); var account = PlayerDB.Players.FindById(accountId); if (account?.Player == null) return NotFound(new { error = "Account not found." }); if (IsDeveloper(account) && !IsDeveloper(admin)) return StatusCode(StatusCodes.Status403Forbidden, new { error = "Moderators cannot edit Developer profiles." }); string username = request.Username?.Trim().TrimStart('@') ?? string.Empty; string displayName = request.DisplayName?.Trim() ?? string.Empty; if (username.Length is < 3 or > 20 || username.Any(ch => !char.IsLetterOrDigit(ch) && ch != '_')) return BadRequest(new { error = "Username must be 3-20 letters, numbers, or underscores." }); if (displayName.Length is < 1 or > 32) return BadRequest(new { error = "Display name must be 1-32 characters." }); if (PlayerDB.Players.FindAll().Any(x => x.PlayerId != accountId && string.Equals(x.Player?.Username, username, StringComparison.OrdinalIgnoreCase))) return Conflict(new { error = "That username is already taken." }); account.Player.Username = username; account.Player.DisplayName = displayName; account.Player.Bio = (request.Bio ?? string.Empty).Trim()[..Math.Min((request.Bio ?? string.Empty).Trim().Length, 500)]; account.Player.Email = (request.Email ?? string.Empty).Trim(); PlayerDB.Players.Update(account); await NotiController.NotifyPlayerProfileUpdatedAsync(accountId); return Ok(new { success = true }); } [HttpPost("admin/accounts/{accountId:long}/username/reset")] public async Task AdminResetUsername(long accountId) { var admin = AuthStuff.GetCurrentPlayer(Request); if (!IsStaff(admin)) return StatusCode(403); var account = PlayerDB.Players.FindById(accountId); if (account?.Player == null) return NotFound(new { error = "Account not found." }); if (IsDeveloper(account) && !IsDeveloper(admin)) return StatusCode(StatusCodes.Status403Forbidden, new { error = "Moderators cannot reset Developer usernames." }); var existingUsernames = PlayerDB.Players.FindAll() .Where(candidate => candidate.PlayerId != accountId) .Select(candidate => candidate.Player?.Username) .Where(username => !string.IsNullOrWhiteSpace(username)) .ToHashSet(StringComparer.OrdinalIgnoreCase); string? generatedUsername = null; for (int attempt = 0; attempt < 256; attempt++) { string candidate = NameGen.GetRandomName(); if (candidate.Length is >= 3 and <= 20 && candidate.All(ch => char.IsLetterOrDigit(ch) || ch == '_') && !existingUsernames.Contains(candidate)) { generatedUsername = candidate; break; } } if (generatedUsername == null) return StatusCode(StatusCodes.Status503ServiceUnavailable, new { error = "Could not generate an available username. Try again." }); string? previousUsername = account.Player.Username; account.Player.Username = generatedUsername; account.Player.DisplayName = generatedUsername; if (!PlayerDB.Players.Update(account)) return StatusCode(StatusCodes.Status500InternalServerError, new { error = "Could not save the generated username." }); await NotiController.NotifyPlayerProfileUpdatedAsync(accountId); return Ok(new { success = true, previousUsername, username = generatedUsername, displayName = generatedUsername, availableUsernameChanges = account.Player.AvailableUsernameChanges }); } [HttpPost("admin/accounts/{accountId:long}/images/{imageKind}/reset")] public async Task AdminResetProfileImage(long accountId, string imageKind) { var admin = AuthStuff.GetCurrentPlayer(Request); if (!IsStaff(admin)) return StatusCode(403); var account = PlayerDB.Players.FindById(accountId); if (account?.Player == null) return NotFound(new { error = "Account not found." }); if (IsDeveloper(account) && !IsDeveloper(admin)) return StatusCode(StatusCodes.Status403Forbidden, new { error = "Moderators cannot reset Developer profile images." }); string normalizedKind = imageKind.Trim().ToLowerInvariant(); if (normalizedKind == "pfp") { account.Player.ProfileImage = "DefaultPFP.png"; } else if (normalizedKind == "banner") { account.Player.BannerImage = null; } else { return BadRequest(new { error = "Image type must be pfp or banner." }); } if (!PlayerDB.Players.Update(account)) return StatusCode(StatusCodes.Status500InternalServerError, new { error = "Could not reset the profile image." }); await NotiController.NotifyPlayerProfileUpdatedAsync(accountId); return Ok(new { success = true, imageKind = normalizedKind, profileImage = account.Player.ProfileImage, bannerImage = account.Player.BannerImage }); } [HttpPut("admin/accounts/{accountId:long}/details")] public async Task AdminUpdateAccountDetails(long accountId, [FromBody] AdminAccountDetailsUpdate request) { var admin = AuthStuff.GetCurrentPlayer(Request); if (!IsAdmin(admin)) return StatusCode(403); var account = PlayerDB.Players.FindById(accountId); if (account?.Player == null) return NotFound(new { error = "Account not found." }); if (IsDeveloper(account) && !IsDeveloper(admin)) return StatusCode(StatusCodes.Status403Forbidden, new { error = "Moderators cannot edit Developer account details." }); if (request.Level is < 1 or > 50) return BadRequest(new { error = "Level must be between 1 and 50." }); if (request.XP < 0) return BadRequest(new { error = "XP cannot be negative." }); if (request.AvailableUsernameChanges is < 0 or > 1000000) return BadRequest(new { error = "Username changes must be between 0 and 1,000,000." }); string displayEmoji = request.DisplayEmoji?.Trim() ?? string.Empty; if (displayEmoji.Length > 16 || displayEmoji.Any(char.IsControl)) return BadRequest(new { error = "Display emoji must be 16 characters or fewer and cannot contain control characters." }); if (request.PersonalPronouns < 0 || (request.PersonalPronouns & ~0x3F) != 0) return BadRequest(new { error = "Personal pronouns must be a valid six-bit value from 0 to 63." }); string rawProfileImage = request.ProfileImage?.Trim() ?? string.Empty; string? profileImage = NormalizeImagePath(rawProfileImage); if (rawProfileImage.Length > 0 && profileImage == null) return BadRequest(new { error = "Profile image path is invalid." }); string rawBannerImage = request.BannerImage?.Trim() ?? string.Empty; string? bannerImage = NormalizeImagePath(rawBannerImage); if (rawBannerImage.Length > 0 && bannerImage == null) return BadRequest(new { error = "Banner image path is invalid." }); account.Player.IsJunior = request.IsJunior; account.Player.AvailableUsernameChanges = request.AvailableUsernameChanges; account.Player.ProfileImage = profileImage ?? "DefaultPFP.png"; account.Player.BannerImage = bannerImage; account.Player.DisplayEmoji = displayEmoji; account.Player.PersonalPronouns = request.PersonalPronouns; if (!PlayerDB.Players.Update(account)) return StatusCode(StatusCodes.Status500InternalServerError, new { error = "Could not save account details." }); var progression = PlayerDB.SetProgression(accountId, request.Level, request.XP); if (progression == null) return StatusCode(StatusCodes.Status500InternalServerError, new { error = "Account details saved, but progression could not be updated." }); await NotiController.NotifyPlayerProfileUpdatedAsync(accountId); return Ok(new { success = true, level = progression.Level, xp = progression.XP, profileImage = ImageUrl(account.Player.ProfileImage) }); } [HttpPost("admin/accounts/{accountId:long}/platforms")] public IActionResult AdminSetPlatform(long accountId, [FromBody] AdminPlatformChange request) { var admin = AuthStuff.GetCurrentPlayer(Request); if (!IsAdmin(admin)) return StatusCode(403); var account = PlayerDB.Players.FindById(accountId); if (account?.Player == null) return NotFound(new { error = "Account not found." }); if (IsDeveloper(account) && !IsDeveloper(admin)) return StatusCode(StatusCodes.Status403Forbidden, new { error = "Moderators cannot edit Developer platforms." }); if (!Enum.TryParse(request.Platform, true, out PlayerDBClasses.Platforms platform) || platform is PlayerDBClasses.Platforms.All or PlayerDBClasses.Platforms.HeadlessBot || !ulong.TryParse(request.PlatformId, out ulong platformId) || platformId == 0) return BadRequest(new { error = "Enter a valid platform and numeric platform ID." }); account.PlatformIds ??= new List(); bool exists = account.PlatformIds.Any(x => x.Platform == platform && x.PlatformId == platformId); if (request.Enabled && !exists) { account.PlatformIds.Add(new PlayerDBClasses.mPlatformID { Platform = platform, PlatformId = platformId }); } else if (!request.Enabled && exists) { if (account.PlatformIds.Count == 1) return BadRequest(new { error = "An account must keep at least one platform identity." }); account.PlatformIds.RemoveAll(x => x.Platform == platform && x.PlatformId == platformId); } PlayerDB.Players.Update(account); return Ok(new { success = true, platforms = account.PlatformIds.Select(x => new { platform = x.Platform.ToString(), platformId = x.PlatformId.ToString() }) }); } [HttpPost("admin/accounts/{accountId:long}/roles")] public IActionResult AdminSetRole(long accountId, [FromBody] AdminRoleChange request) { var admin = AuthStuff.GetCurrentPlayer(Request); if (!IsAdmin(admin)) return StatusCode(403); var account = PlayerDB.Players.FindById(accountId); if (account?.Player == null) return NotFound(new { error = "Account not found." }); if (!Enum.TryParse(request.Role, true, out PlayerDBClasses.PlayerRoles role)) return BadRequest(new { error = "Choose a valid role." }); if (role == PlayerDBClasses.PlayerRoles.Developer && !IsDeveloper(admin)) return StatusCode(StatusCodes.Status403Forbidden, new { error = "Only Developers can change the Developer role." }); account.PlayerRoles ??= new List(); if (!request.Enabled && accountId == admin!.PlayerId && role is PlayerDBClasses.PlayerRoles.Developer or PlayerDBClasses.PlayerRoles.Moderator && account.PlayerRoles.Count(x => x is PlayerDBClasses.PlayerRoles.Developer or PlayerDBClasses.PlayerRoles.Moderator) == 1) return BadRequest(new { error = "You cannot remove your own final admin role." }); if (request.Enabled && !account.PlayerRoles.Contains(role)) { account.PlayerRoles.Add(role); if (role == PlayerDBClasses.PlayerRoles.Developer) PlayerDB.GrantDeveloperCheerAccess( account, selectDeveloperBadge: true); } else if (!request.Enabled) { account.PlayerRoles.Remove(role); if (role == PlayerDBClasses.PlayerRoles.Developer && account.Player.Reputation?.SelectedCheer == PlayerDBClasses.CheerCategory.RecRoomDeveloper) { account.Player.Reputation.SelectedCheer = PlayerDBClasses.CheerCategory.General; } } PlayerDB.Players.Update(account); return Ok(new { success = true, roles = account.PlayerRoles.Select(x => x.ToString()) }); } [HttpDelete("admin/accounts/{accountId:long}/roles")] public IActionResult AdminClearRoles(long accountId) { var admin = AuthStuff.GetCurrentPlayer(Request); if (!IsAdmin(admin)) return StatusCode(403); var account = PlayerDB.Players.FindById(accountId); if (account?.Player == null) return NotFound(new { error = "Account not found." }); if (accountId == admin!.PlayerId) return BadRequest(new { error = "You cannot clear your own roles." }); if (IsDeveloper(account) && !IsDeveloper(admin)) return StatusCode(StatusCodes.Status403Forbidden, new { error = "Only Developers can clear a Developer's roles." }); bool wasDeveloper = account.PlayerRoles?.Contains(PlayerDBClasses.PlayerRoles.Developer) == true; account.PlayerRoles = new List(); if (wasDeveloper && account.Player.Reputation?.SelectedCheer == PlayerDBClasses.CheerCategory.RecRoomDeveloper) { account.Player.Reputation.SelectedCheer = PlayerDBClasses.CheerCategory.General; } PlayerDB.Players.Update(account); Console.WriteLine( $"[ADMIN ROLES] admin={admin.PlayerId} cleared all roles on account={accountId}"); return Ok(new { success = true, roles = Array.Empty() }); } [HttpPost("admin/accounts/{accountId:long}/force-join-instance")] public async Task AdminForceJoinInstance(long accountId) { var admin = AuthStuff.GetCurrentPlayer(Request); if (!IsAdmin(admin)) return StatusCode(403); if (accountId == admin!.PlayerId) return BadRequest(new { error = "You're already in your own instance." }); var account = PlayerDB.Players.FindById(accountId); if (account?.Player == null) return NotFound(new { error = "Account not found." }); var adminHeartbeat = PlayerDB.GetPlayerHeartbeat(admin.PlayerId); var instance = adminHeartbeat?.roomInstance; if (instance == null || !adminHeartbeat!.isOnline) return BadRequest(new { error = "You need to be in a live room instance in-game first." }); var targetInstance = new PlayerDBClasses.RoomInstance { encryptVoiceChat = instance.encryptVoiceChat, clubId = instance.clubId, dataBlob = instance.dataBlob, eventId = instance.eventId, isFull = instance.isFull, isInProgress = instance.isInProgress, isPrivate = instance.isPrivate, location = instance.location, maxCapacity = instance.maxCapacity, Name = instance.Name, photonRegion = instance.photonRegion, photonRegionId = instance.photonRegionId, photonRoomId = instance.photonRoomId, roomCode = instance.roomCode, roomId = instance.roomId, roomInstanceId = instance.roomInstanceId, roomInstanceType = instance.roomInstanceType, subRoomId = instance.subRoomId, subRoomDataSaveId = instance.subRoomDataSaveId, createdAt = DateTime.UtcNow }; PlayerDB.UpdatePlayerHeartbeat(accountId, targetInstance, online: true); if (targetInstance.roomInstanceType == PlayerDBClasses.RoomInstanceType.Dormroom) Sessions.MarkGuestDormEntry(accountId); await NotiController.NotifyRoomInviteAsync( admin.PlayerId, accountId, instance.roomId, instance.roomInstanceId, instance.photonRoomId); bool delivered = NotiController.IsPlayerConnected(accountId); return Ok(new { success = true, delivered, roomInstanceId = instance.roomInstanceId, roomName = instance.Name }); } private async Task<(PlayerDBClasses.RoomInstance instance, bool delivered)> ForceIntoInstanceAsync( long moverAccountId, long fromPlayerId, PlayerDBClasses.RoomInstance sourceInstance, bool notifyMover) { var movedInstance = new PlayerDBClasses.RoomInstance { encryptVoiceChat = sourceInstance.encryptVoiceChat, clubId = sourceInstance.clubId, dataBlob = sourceInstance.dataBlob, eventId = sourceInstance.eventId, isFull = sourceInstance.isFull, isInProgress = sourceInstance.isInProgress, isPrivate = sourceInstance.isPrivate, location = sourceInstance.location, maxCapacity = sourceInstance.maxCapacity, Name = sourceInstance.Name, photonRegion = sourceInstance.photonRegion, photonRegionId = sourceInstance.photonRegionId, photonRoomId = sourceInstance.photonRoomId, roomCode = sourceInstance.roomCode, roomId = sourceInstance.roomId, roomInstanceId = sourceInstance.roomInstanceId, roomInstanceType = sourceInstance.roomInstanceType, subRoomId = sourceInstance.subRoomId, subRoomDataSaveId = sourceInstance.subRoomDataSaveId, createdAt = DateTime.UtcNow }; PlayerDB.UpdatePlayerHeartbeat(moverAccountId, movedInstance, online: true); if (movedInstance.roomInstanceType == PlayerDBClasses.RoomInstanceType.Dormroom) Sessions.MarkGuestDormEntry(moverAccountId); if (notifyMover) { await NotiController.NotifyRoomInviteAsync( fromPlayerId, moverAccountId, movedInstance.roomId, movedInstance.roomInstanceId, movedInstance.photonRoomId); } bool delivered = NotiController.IsPlayerConnected(moverAccountId); return (movedInstance, delivered); } [HttpPost("admin/accounts/{targetAccountId:long}/force-me-into")] public async Task AdminForceMeIntoInstance(long targetAccountId) { var admin = AuthStuff.GetCurrentPlayer(Request); if (!IsAdmin(admin)) return StatusCode(403); if (targetAccountId == admin!.PlayerId) return BadRequest(new { error = "That's your own account." }); var target = PlayerDB.Players.FindById(targetAccountId); if (target?.Player == null) return NotFound(new { error = "Account not found." }); var targetHeartbeat = PlayerDB.GetPlayerHeartbeat(targetAccountId); var sourceInstance = targetHeartbeat?.roomInstance; if (sourceInstance == null || !targetHeartbeat!.isOnline) return BadRequest(new { error = "That account isn't currently in a live room instance." }); var (instance, delivered) = await ForceIntoInstanceAsync( admin.PlayerId, admin.PlayerId, sourceInstance, notifyMover: true); return Ok(new { success = true, delivered, roomInstanceId = instance.roomInstanceId, roomName = instance.Name }); } [HttpPost("admin/accounts/{moveAccountId:long}/force-into/{targetAccountId:long}")] public async Task AdminForceUserIntoInstance( long moveAccountId, long targetAccountId) { var admin = AuthStuff.GetCurrentPlayer(Request); if (!IsAdmin(admin)) return StatusCode(403); if (moveAccountId == targetAccountId) return BadRequest(new { error = "Those are the same account." }); var mover = PlayerDB.Players.FindById(moveAccountId); if (mover?.Player == null) return NotFound(new { error = "Account to move not found." }); var target = PlayerDB.Players.FindById(targetAccountId); if (target?.Player == null) return NotFound(new { error = "Target account not found." }); var targetHeartbeat = PlayerDB.GetPlayerHeartbeat(targetAccountId); var sourceInstance = targetHeartbeat?.roomInstance; if (sourceInstance == null || !targetHeartbeat!.isOnline) return BadRequest(new { error = "The target account isn't currently in a live room instance." }); var (instance, delivered) = await ForceIntoInstanceAsync( moveAccountId, admin!.PlayerId, sourceInstance, notifyMover: true); Console.WriteLine( $"[ADMIN FORCE-INTO] admin={admin.PlayerId} moved={moveAccountId} " + $"into={targetAccountId}'s instance ({instance.Name}) targetNotified=false"); return Ok(new { success = true, delivered, roomInstanceId = instance.roomInstanceId, roomName = instance.Name }); } [HttpPost("admin/accounts/{accountId:long}/troll/kick-to-room")] public async Task AdminTrollKickToRoom( long accountId, [FromBody] AdminKickToRoomRequest? request) { var admin = AuthStuff.GetCurrentPlayer(Request); if (!IsDeveloper(admin)) return StatusCode(403, new { error = "Only Developers can do this." }); var account = PlayerDB.Players.FindById(accountId); if (account?.Player == null) return NotFound(new { error = "Account not found." }); RoomDBClasses.Room? targetRoom = null; if (request?.RoomId is > 0) targetRoom = RoomDB.GetRoom(request.RoomId.Value); else if (!string.IsNullOrWhiteSpace(request?.RoomName)) targetRoom = RoomDB.GetRoomByName(request!.RoomName!.Trim()); RoomDBClasses.SubRooms? targetSubRoom = targetRoom?.SubRooms?.FirstOrDefault(); PlayerDBClasses.RoomInstance targetInstance; if (targetRoom != null && targetSubRoom != null) { long instanceId = System.Random.Shared.NextInt64(1_000_000L, 1_000_000_000_000L); string roomName = targetRoom.Name ?? "UnknownRoom"; targetInstance = new PlayerDBClasses.RoomInstance { roomId = targetRoom.RoomId, subRoomId = targetSubRoom.SubRoomId, subRoomDataSaveId = targetSubRoom.SubRoomDataSaveId, roomInstanceId = instanceId, Name = $"^{roomName}", location = targetSubRoom.UnitySceneId, isPrivate = false, isFull = false, isInProgress = false, maxCapacity = Math.Max(1, targetSubRoom.MaxPlayers), roomInstanceType = PlayerDBClasses.RoomInstanceType.Public, photonRegion = ServerConfig.PhotonRegion, photonRegionId = ServerConfig.PhotonRegion, photonRoomId = $"MochaRoom-{roomName}-{instanceId}", createdAt = DateTime.UtcNow }; } else { var adminHeartbeat = PlayerDB.GetPlayerHeartbeat(admin!.PlayerId); var instance = adminHeartbeat?.roomInstance; if (instance == null || !adminHeartbeat!.isOnline) return BadRequest(new { error = "Give a roomId/roomName, or be in a live room instance yourself first." }); targetInstance = new PlayerDBClasses.RoomInstance { encryptVoiceChat = instance.encryptVoiceChat, clubId = instance.clubId, dataBlob = instance.dataBlob, eventId = instance.eventId, isFull = instance.isFull, isInProgress = instance.isInProgress, isPrivate = instance.isPrivate, location = instance.location, maxCapacity = instance.maxCapacity, Name = instance.Name, photonRegion = instance.photonRegion, photonRegionId = instance.photonRegionId, photonRoomId = instance.photonRoomId, roomCode = instance.roomCode, roomId = instance.roomId, roomInstanceId = instance.roomInstanceId, roomInstanceType = instance.roomInstanceType, subRoomId = instance.subRoomId, subRoomDataSaveId = instance.subRoomDataSaveId, createdAt = DateTime.UtcNow }; } int disconnected = NotiController.ForceDisconnectPlayer(accountId); PlayerDB.UpdatePlayerHeartbeat(accountId, targetInstance, online: true); if (targetInstance.roomInstanceType == PlayerDBClasses.RoomInstanceType.Dormroom) Sessions.MarkGuestDormEntry(accountId); await NotiController.NotifyRoomInviteAsync( admin!.PlayerId, accountId, targetInstance.roomId, targetInstance.roomInstanceId, targetInstance.photonRoomId); Console.WriteLine( $"[ADMIN TROLL] admin={admin.PlayerId} kicked player={accountId} " + $"disconnectedSockets={disconnected} toRoom={targetInstance.Name}"); return Ok(new { success = true, disconnectedSockets = disconnected, roomInstanceId = targetInstance.roomInstanceId, roomName = targetInstance.Name }); } [HttpPost("admin/accounts/{accountId:long}/troll/fakebox-and-ban")] public async Task AdminTrollFakeBoxAndBan( long accountId, [FromBody] AdminTrollFakeBoxRequest? request) { var admin = AuthStuff.GetCurrentPlayer(Request); if (!IsDeveloper(admin)) return StatusCode(403, new { error = "Only Developers can do this." }); var target = PlayerDB.Players.FindById(accountId); if (target?.Player == null) return NotFound(new { error = "Account not found." }); int fakeAmount = Math.Clamp(request?.TokenAmount ?? 5000, 1, 1_000_000); int currentLevel = target.Player.Level; int fakeLevel = currentLevel + 1; var fakeBox = new PlayerDBClasses.GiftPackage { FromPlayerId = 1, Message = $"Congrats on getting to level {fakeLevel}!", Currency = 0, CurrencyType = (int)PlayerDBClasses.CurrencyType.RecCenterTokens, GiftContext = (int)PlayerDBClasses.GiftContext.LevelUp, Rarity = 50, Platform = -1, PlatformMask = -1, BalanceType = (int)PlayerDBClasses.BalanceType.NonPurchasedNotUsableInP2P, IsQuery = false, Unique = false }; PlayerDBClasses.GiftPackage? queuedGift = PlayerDB.QueueGiftPackage(accountId, fakeBox); if (queuedGift == null) return BadRequest(new { error = "Couldn't queue the fake box." }); var liveDeliveries = new List { new(accountId, queuedGift) }; try { await NotiController.NotifyGiftsAsync(1, liveDeliveries, immediate: true); await NotiController.NotifyProgressionAsync( accountId, currentLevel, fakeLevel, target.Player.XP); } catch (Exception exception) { Console.WriteLine($"[ADMIN TROLL FAKEBOX] delivery push failed: {exception.Message}"); } if (request?.BanImmediately != true) { Console.WriteLine( $"[ADMIN TROLL FAKEBOX] admin={admin!.PlayerId} target={accountId} " + $"amount={fakeAmount} fakeLevel={fakeLevel} banImmediately=false"); return Ok(new { success = true, tokenAmount = fakeAmount, fakeLevel, banned = false }); } string reason = "Cheating"; const int durationSeconds = int.MaxValue; var targets = new List { target }; var identities = (target.PlatformIds ?? new()) .Select(x => (x.Platform, x.PlatformId)) .ToHashSet(); targets.AddRange(PlayerDB.Players.FindAll().Where(x => x.PlayerId != accountId && x.PlatformIds?.Any(p => identities.Contains((p.Platform, p.PlatformId))) == true)); if (!IsDeveloper(admin) && targets.Any(IsDeveloper)) return StatusCode(StatusCodes.Status403Forbidden, new { error = "A linked Developer account cannot be banned by a Moderator." }); var distinctTargets = targets.DistinctBy(x => x.PlayerId).ToList(); var linkedAccountSummaries = distinctTargets .Select(x => (x.PlayerId, (string?)(x.Player?.Username ?? x.PlayerId.ToString()))) .ToList(); var platformIdentities = (target.PlatformIds ?? new()) .Select(x => (x.Platform.ToString(), x.PlatformId.ToString())) .ToList(); string targetUsername = (target.Player.Username ?? accountId.ToString()).TrimStart('@'); foreach (var bannedAccount in distinctTargets) { string banReason = bannedAccount.PlayerId == accountId ? reason : $"{reason}\n\nRelated account: @{targetUsername} (auto link-banned)"; PlayerDB.BanPlayer(bannedAccount.PlayerId, durationSeconds, banReason, (ulong)admin!.PlayerId); RecNetDB.ModerationLocks.Delete(bannedAccount.PlayerId); DiscordLogger.LogBan( bannedAccount.PlayerId, bannedAccount.Player?.Username, admin.PlayerId, admin.Player?.Username, banReason, durationSeconds, linkedAccounts: distinctTargets.Count > 1 ? linkedAccountSummaries : null, platformIdentities: bannedAccount.PlayerId == accountId ? platformIdentities : null); } Console.WriteLine( $"[ADMIN TROLL FAKEBOX+BAN] admin={admin.PlayerId} target={accountId} " + $"amount={fakeAmount} permaBanned=true linkBanned={distinctTargets.Count}"); return Ok(new { success = true, tokenAmount = fakeAmount, banned = true, permanent = true, affectedCount = distinctTargets.Count }); } [HttpPut("admin/accounts/{accountId:long}/password")] public IActionResult AdminResetPassword(long accountId, [FromBody] AdminPasswordReset request) { var admin = AuthStuff.GetCurrentPlayer(Request); if (!IsAdmin(admin)) return StatusCode(403); var account = PlayerDB.Players.FindById(accountId); if (account?.Player == null) return NotFound(new { error = "Account not found." }); if (IsDeveloper(account) && !IsDeveloper(admin)) return StatusCode(StatusCodes.Status403Forbidden, new { error = "Moderators cannot reset a Developer's password." }); if (string.IsNullOrEmpty(request.NewPassword) || request.NewPassword.Length < PasswordSecurity.MinPasswordLength || request.NewPassword.Length > PasswordSecurity.MaxPasswordLength) return BadRequest(new { error = "Password must be at least 8 characters." }); account.Password = PasswordSecurity.Hash(request.NewPassword); PlayerDB.Players.Update(account); return Ok(new { success = true }); } [HttpGet("admin/accounts/{accountId:long}")] public IActionResult GetAdminAccountDetail(long accountId) { var admin = AuthStuff.GetCurrentPlayer(Request); if (!IsStaff(admin)) return StatusCode(403); var account = PlayerDB.Players.FindById(accountId); if (account?.Player == null) return NotFound(new { error = "Account not found." }); var accountLock = RecNetDB.ModerationLocks.FindById(accountId); PlayerDB.IsPlayerBanned(accountId, out var nativeBan); return Ok(new { accountId = account.PlayerId, username = account.Player.Username, displayName = account.Player.DisplayName, bio = account.Player.Bio, email = account.Player.Email, profileImage = ImageUrl(account.Player.ProfileImage), profileImagePath = NormalizeImagePath(account.Player.ProfileImage) ?? "DefaultPFP.png", bannerImagePath = NormalizeImagePath(account.Player.BannerImage) ?? string.Empty, level = account.Player.Level, xp = account.Player.XP, isJunior = account.Player.IsJunior ?? false, createdAt = account.Player.CreatedAt, availableUsernameChanges = account.Player.AvailableUsernameChanges, displayEmoji = account.Player.DisplayEmoji ?? string.Empty, personalPronouns = account.Player.PersonalPronouns, balance = PlayerDB.GetCurrencyBalance( accountId, PlayerDBClasses.CurrencyType.RecCenterTokens), platforms = account.PlatformIds.Select(p => new { platform = p.Platform.ToString(), platformId = p.PlatformId.ToString() }), roles = account.PlayerRoles.Select(r => r.ToString()), ban = nativeBan == null ? null : new { reason = nativeBan.Message, duration = nativeBan.Duration, issuedAt = DateTimeOffset.FromUnixTimeSeconds(nativeBan.ModerationSetUnixTime).UtcDateTime }, moderationLock = accountLock == null ? null : new { reason = accountLock.Reason, issuedAt = accountLock.IssuedAt, relatedUsername = accountLock.RelatedUsername, isRelated = accountLock.RelatedAccountId.HasValue } }); } [HttpGet("admin/accounts/{accountId:long}/settings")] public IActionResult GetAdminAccountSettings( long accountId, [FromQuery] string? search = null) { var admin = AuthStuff.GetCurrentPlayer(Request); if (!IsStaff(admin)) return StatusCode(403); var account = PlayerDB.Players.FindById(accountId); if (account?.Player == null) return NotFound(new { error = "Account not found." }); string term = search?.Trim() ?? string.Empty; var settings = (account.Player.PlayerExtra?.Settings ?? new List()) .Where(s => string.IsNullOrEmpty(term) || s.Key.Contains(term, StringComparison.OrdinalIgnoreCase) || s.Value.Contains(term, StringComparison.OrdinalIgnoreCase)) .OrderBy(s => s.Key, StringComparer.OrdinalIgnoreCase) .Select(s => new { key = s.Key, value = s.Value }) .ToList(); return Ok(settings); } [HttpPost("admin/avatar-items")] public IActionResult AdminAddAvatarItem([FromBody] object avatarItem) { var admin = AuthStuff.GetCurrentPlayer(Request); if (!IsDeveloper(admin)) return StatusCode(403); try { return Ok(new { success = true, message = "Avatar item received. Note: This endpoint currently only validates the JSON - actual catalog modification requires file system access.", item = avatarItem }); } catch (Exception ex) { return BadRequest(new { error = ex.Message }); } } [HttpGet("admin/preferences")] public IActionResult GetAdminPreferences() { var admin = AuthStuff.GetCurrentPlayer(Request); if (admin?.Player == null) return Unauthorized(); admin.Player.PlayerExtra ??= new PlayerDBClasses.PlayerExtra(); admin.Player.PlayerExtra.Settings ??= new List(); var prefs = admin.Player.PlayerExtra.Settings .Where(s => s.Key.StartsWith("mocha_", StringComparison.OrdinalIgnoreCase)) .ToDictionary(s => s.Key, s => s.Value, StringComparer.OrdinalIgnoreCase); return Ok(new { theme = prefs.TryGetValue("mocha_theme", out var theme) ? theme : "dark", accentColor = prefs.TryGetValue("mocha_accentColor", out var accent) ? accent : "#7c3aed" }); } [HttpPut("admin/preferences")] public IActionResult SetAdminPreferences([FromBody] AdminPreferencesRequest request) { var admin = AuthStuff.GetCurrentPlayer(Request); if (admin?.Player == null) return Unauthorized(); admin.Player.PlayerExtra ??= new PlayerDBClasses.PlayerExtra(); admin.Player.PlayerExtra.Settings ??= new List(); if (!string.IsNullOrWhiteSpace(request.Theme)) { var existing = admin.Player.PlayerExtra.Settings .FirstOrDefault(s => string.Equals(s.Key, "mocha_theme", StringComparison.OrdinalIgnoreCase)); if (existing != null) existing.Value = request.Theme; else admin.Player.PlayerExtra.Settings.Add(new PlayerDBClasses.Setting { Key = "mocha_theme", Value = request.Theme }); } if (!string.IsNullOrWhiteSpace(request.AccentColor)) { var existing = admin.Player.PlayerExtra.Settings .FirstOrDefault(s => string.Equals(s.Key, "mocha_accentColor", StringComparison.OrdinalIgnoreCase)); if (existing != null) existing.Value = request.AccentColor; else admin.Player.PlayerExtra.Settings.Add(new PlayerDBClasses.Setting { Key = "mocha_accentColor", Value = request.AccentColor }); } PlayerDB.Players.Update(admin); return Ok(new { success = true }); } [HttpGet("admin/accounts/{accountId:long}/reputation")] public IActionResult GetAdminAccountReputation(long accountId) { var admin = AuthStuff.GetCurrentPlayer(Request); if (!IsStaff(admin)) return StatusCode(403); var account = PlayerDB.Players.FindById(accountId); if (account?.Player == null) return NotFound(new { error = "Account not found." }); account.Player.Reputation ??= new PlayerDBClasses.Reputation(); return Ok(new { accountId, isCheerful = account.Player.Reputation.IsCheerful, selectedCheer = account.Player.Reputation.SelectedCheer.ToString(), cheerGeneral = account.Player.Reputation.CheerGeneral, cheerHelpful = account.Player.Reputation.CheerHelpful, cheerSportsman = account.Player.Reputation.CheerSportsman, cheerGreatHost = account.Player.Reputation.CheerGreatHost, cheerCreative = account.Player.Reputation.CheerCreative, availableBadges = Enum.GetNames(typeof(PlayerDBClasses.CheerCategory)) }); } [HttpPut("admin/accounts/{accountId:long}/reputation")] public IActionResult SetAdminAccountReputation( long accountId, [FromBody] AdminReputationRequest request) { var admin = AuthStuff.GetCurrentPlayer(Request); if (!IsStaff(admin)) return StatusCode(403); var account = PlayerDB.Players.FindById(accountId); if (account?.Player == null) return NotFound(new { error = "Account not found." }); if (IsDeveloper(account) && !IsDeveloper(admin)) return StatusCode(StatusCodes.Status403Forbidden, new { error = "Moderators cannot edit a Developer's reputation." }); account.Player.Reputation ??= new PlayerDBClasses.Reputation(); if (request.IsCheerful.HasValue) account.Player.Reputation.IsCheerful = request.IsCheerful.Value; if (request.CheerGeneral.HasValue) account.Player.Reputation.CheerGeneral = request.CheerGeneral.Value; if (request.CheerHelpful.HasValue) account.Player.Reputation.CheerHelpful = request.CheerHelpful.Value; if (request.CheerSportsman.HasValue) account.Player.Reputation.CheerSportsman = request.CheerSportsman.Value; if (request.CheerGreatHost.HasValue) account.Player.Reputation.CheerGreatHost = request.CheerGreatHost.Value; if (request.CheerCreative.HasValue) account.Player.Reputation.CheerCreative = request.CheerCreative.Value; if (!string.IsNullOrWhiteSpace(request.SelectedCheer) && Enum.TryParse(request.SelectedCheer, true, out var selectedCheer)) { account.Player.Reputation.SelectedCheer = selectedCheer; } PlayerDB.Players.Update(account); Console.WriteLine( $"[ADMIN REPUTATION] admin={admin!.PlayerId} account={accountId} updated reputation"); return Ok(new { success = true }); } [HttpGet("admin/accounts/{accountId:long}/photos")] public IActionResult GetAdminAccountPhotos( long accountId, [FromQuery] string? type = null) { var admin = AuthStuff.GetCurrentPlayer(Request); if (!IsStaff(admin)) return StatusCode(403); var account = PlayerDB.Players.FindById(accountId); if (account?.Player == null) return NotFound(new { error = "Account not found." }); var ownedPhotos = GetOwnedPhotos(account) .OrderByDescending(x => x.takenAt) .ToList(); if (type == "mine") { return Ok(new { accountId, photoCount = ownedPhotos.Count, photos = ownedPhotos }); } else if (type == "of-me") { return Ok(new { accountId, photoCount = 0, photos = Array.Empty() }); } return Ok(new { accountId, ownedPhotoCount = ownedPhotos.Count, ownedPhotos, photosOfMe = Array.Empty() }); } [HttpGet("admin/accounts/{accountId:long}/inventory")] public IActionResult GetAdminPlayerInventory( long accountId, [FromQuery] string? search = null) { var admin = AuthStuff.GetCurrentPlayer(Request); if (!IsStaff(admin)) return StatusCode(403); var account = PlayerDB.Players.FindById(accountId); if (account?.Player == null) return NotFound(new { error = "Account not found." }); if (IsDeveloper(account) && !IsDeveloper(admin)) return StatusCode(StatusCodes.Status403Forbidden, new { error = "Moderators cannot edit a Developer's inventory." }); account.Player.PlayerExtra ??= new PlayerDBClasses.PlayerExtra(); account.Player.PlayerExtra.AvatarItems ??= new List(); PlayerInventoryStore.EnsureInitialized( accountId, account.Player.PlayerExtra.AvatarItems); string term = search?.Trim() ?? string.Empty; bool Matches(string name, string descriptor, long id) => string.IsNullOrWhiteSpace(term) || name.Contains(term, StringComparison.OrdinalIgnoreCase) || descriptor.Contains(term, StringComparison.OrdinalIgnoreCase) || id.ToString().Contains(term, StringComparison.OrdinalIgnoreCase); var avatarItems = PlayerInventoryStore.GetAvatarCatalog() .Where(item => Matches( item.FriendlyName ?? string.Empty, item.AvatarItemDesc ?? string.Empty, item.AvatarItemId)) .OrderBy(item => item.FriendlyName, StringComparer.OrdinalIgnoreCase) .Take(100) .Select(item => new { avatarItemId = item.AvatarItemId, avatarItemDesc = item.AvatarItemDesc, friendlyName = item.FriendlyName, owned = PlayerInventoryStore.OwnsAvatarItem( accountId, item.AvatarItemDesc, item.AvatarItemId, account.Player.PlayerExtra.AvatarItems) }) .ToList(); var consumables = PlayerInventoryStore.GetConsumableCatalog() .Where(item => Matches( item.FriendlyName ?? string.Empty, item.ConsumableItemDesc ?? string.Empty, item.ConsumableItemId)) .OrderBy(item => item.FriendlyName, StringComparer.OrdinalIgnoreCase) .Take(100) .Select(item => new { consumableItemId = item.ConsumableItemId, consumableItemDesc = item.ConsumableItemDesc, friendlyName = item.FriendlyName, quantity = PlayerInventoryStore.GetConsumableQuantity( accountId, item.ConsumableItemDesc, item.ConsumableItemId) }) .ToList(); return Ok(new { accountId, avatarItems, consumables }); } [HttpPost("admin/accounts/{accountId:long}/avatar-items")] public IActionResult AdminSetAvatarItemOwnership( long accountId, [FromBody] AdminAvatarItemOwnershipChange request) { var admin = AuthStuff.GetCurrentPlayer(Request); if (!IsStaff(admin)) return StatusCode(403); var account = PlayerDB.Players.FindById(accountId); if (account?.Player == null) return NotFound(new { error = "Account not found." }); if (IsDeveloper(account) && !IsDeveloper(admin)) return StatusCode(StatusCodes.Status403Forbidden, new { error = "Moderators cannot edit a Developer's inventory." }); var catalogItem = PlayerInventoryStore.GetAvatarCatalog() .FirstOrDefault(item => (request.AvatarItemId > 0 && item.AvatarItemId == request.AvatarItemId) || (!string.IsNullOrWhiteSpace(request.AvatarItemDesc) && string.Equals( item.AvatarItemDesc, request.AvatarItemDesc.Trim(), StringComparison.OrdinalIgnoreCase))); if (catalogItem == null) return BadRequest(new { error = "Avatar item was not found in AvatarItems.json." }); bool success; if (request.Owned) { success = string.IsNullOrWhiteSpace(catalogItem.AvatarItemDesc) ? true : PlayerDB.GrantAvatarItem( accountId, catalogItem.AvatarItemDesc); if (success) { account = PlayerDB.Players.FindById(accountId); success = PlayerInventoryStore.SetAvatarItemOwned( accountId, catalogItem.AvatarItemDesc, catalogItem.AvatarItemId, catalogItem.FriendlyName, owned: true, legacyAvatarDescriptors: account?.Player?.PlayerExtra?.AvatarItems); } } else { success = PlayerDB.RemoveAvatarItem( accountId, catalogItem.AvatarItemDesc, catalogItem.AvatarItemId); } if (!success) return StatusCode(StatusCodes.Status500InternalServerError, new { error = "Avatar item ownership could not be saved." }); bool owned = PlayerInventoryStore.OwnsAvatarItem( accountId, catalogItem.AvatarItemDesc, catalogItem.AvatarItemId, PlayerDB.Players.FindById(accountId)? .Player?.PlayerExtra?.AvatarItems); Console.WriteLine( $"[ADMIN INVENTORY] admin={admin!.PlayerId} account={accountId} " + $"avatar={catalogItem.AvatarItemDesc} owned={owned}"); return Ok(new { success = true, owned, avatarItemId = catalogItem.AvatarItemId, avatarItemDesc = catalogItem.AvatarItemDesc }); } [HttpPost("admin/accounts/{accountId:long}/consumables")] public IActionResult AdminSetConsumableQuantity( long accountId, [FromBody] AdminConsumableQuantityChange request) { var admin = AuthStuff.GetCurrentPlayer(Request); if (!IsStaff(admin)) return StatusCode(403); var account = PlayerDB.Players.FindById(accountId); if (account?.Player == null) return NotFound(new { error = "Account not found." }); if (IsDeveloper(account) && !IsDeveloper(admin)) return StatusCode(StatusCodes.Status403Forbidden, new { error = "Moderators cannot edit a Developer's inventory." }); if (request.Quantity is < 0 or > 100000) return BadRequest(new { error = "Consumable quantity must be between 0 and 100000." }); var catalogItem = PlayerInventoryStore.GetConsumableCatalog() .FirstOrDefault(item => (request.ConsumableItemId > 0 && item.ConsumableItemId == request.ConsumableItemId) || (!string.IsNullOrWhiteSpace(request.ConsumableItemDesc) && string.Equals( item.ConsumableItemDesc, request.ConsumableItemDesc.Trim(), StringComparison.OrdinalIgnoreCase))); if (catalogItem == null) return BadRequest(new { error = "Consumable was not found in Consumables.json." }); int quantity = PlayerInventoryStore.SetConsumableQuantity( accountId, catalogItem.ConsumableItemDesc, catalogItem.ConsumableItemId, catalogItem.FriendlyName, request.Quantity); Console.WriteLine( $"[ADMIN INVENTORY] admin={admin!.PlayerId} account={accountId} " + $"consumable={catalogItem.ConsumableItemDesc} quantity={quantity}"); return Ok(new { success = true, quantity, consumableItemId = catalogItem.ConsumableItemId, consumableItemDesc = catalogItem.ConsumableItemDesc }); } [HttpPost("admin/accounts/{accountId:long}/balance")] public IActionResult AdminUpdateBalance( long accountId, [FromBody] AdminBalanceChange request) { var admin = AuthStuff.GetCurrentPlayer(Request); if (!IsAdmin(admin)) return StatusCode(403); var account = PlayerDB.Players.FindById(accountId); if (account?.Player == null) return NotFound(new { error = "Account not found." }); if (IsDeveloper(account) && !IsDeveloper(admin)) return StatusCode(StatusCodes.Status403Forbidden, new { error = "Moderators cannot change a Developer's balance." }); if (request.Amount < 0) return BadRequest(new { error = "Token amount cannot be negative." }); int? balance = PlayerDB.SetCurrencyBalance( accountId, PlayerDBClasses.CurrencyType.RecCenterTokens, request.Amount, request.Add); return balance.HasValue ? Ok(new { success = true, balance = balance.Value }) : StatusCode(StatusCodes.Status500InternalServerError, new { error = "The token balance could not be saved." }); } [HttpPost("admin/accounts/{accountId:long}/moderation-lock")] public IActionResult AdminApplyModerationLock(long accountId) { var admin = AuthStuff.GetCurrentPlayer(Request); if (!IsStaff(admin)) return StatusCode(403); var target = PlayerDB.Players.FindById(accountId); if (target?.Player == null) return NotFound(new { error = "Account not found." }); if (accountId == admin!.PlayerId) return BadRequest(new { error = "You cannot apply a Moderation Lock to yourself." }); if (IsDeveloper(target) && !IsDeveloper(admin)) return StatusCode(StatusCodes.Status403Forbidden, new { error = "Moderators cannot lock Developer accounts." }); string groupId = Guid.NewGuid().ToString("N"); PlayerDB.BanPlayer(accountId, int.MaxValue, "Moderation Lock", (ulong)admin.PlayerId); RecNetDB.ModerationLocks.Upsert(new RecNetDB.ModerationLock { AccountId = accountId, Reason = "Moderation Lock", IssuedByAccountId = admin.PlayerId, IssuedAt = DateTime.UtcNow, BanGroupId = groupId }); return Ok(new { success = true, affectedCount = 1 }); } [HttpPost("admin/accounts/{accountId:long}/ban")] public IActionResult AdminBanAccount(long accountId, [FromBody] AdminBanRequest request) { var admin = AuthStuff.GetCurrentPlayer(Request); if (!IsStaff(admin)) return StatusCode(403); var target = PlayerDB.Players.FindById(accountId); if (target?.Player == null) return NotFound(new { error = "Account not found." }); if (accountId == admin!.PlayerId) return BadRequest(new { error = "You cannot ban yourself." }); if (IsDeveloper(target) && !IsDeveloper(admin)) return StatusCode(StatusCodes.Status403Forbidden, new { error = "Moderators cannot ban Developer accounts." }); string reason = request.Reason?.Trim() ?? string.Empty; if (reason.Length is < 3 or > 500) return BadRequest(new { error = "Ban reason must be 3-500 characters." }); string durationUnit = request.DurationUnit?.Trim().ToLowerInvariant() ?? string.Empty; int durationSeconds; if (durationUnit == "permanent") { durationSeconds = int.MaxValue; } else { long multiplier = durationUnit switch { "seconds" => 1, "minutes" => 60, "hours" => 60 * 60, "days" => 24 * 60 * 60, "weeks" => 7 * 24 * 60 * 60, _ => 0 }; long total = (long)request.DurationAmount * multiplier; if (request.DurationAmount < 1 || multiplier == 0 || total > int.MaxValue) return BadRequest(new { error = "Choose a valid ban duration within the 32-bit limit." }); durationSeconds = (int)total; } var targets = new List { target }; if (request.LinkBan) { var identities = (target.PlatformIds ?? new()).Select(x => (x.Platform, x.PlatformId)).ToHashSet(); targets.AddRange(PlayerDB.Players.FindAll().Where(x => x.PlayerId != accountId && x.PlatformIds?.Any(p => identities.Contains((p.Platform, p.PlatformId))) == true)); } if (!IsDeveloper(admin) && targets.Any(IsDeveloper)) return StatusCode(StatusCodes.Status403Forbidden, new { error = "A linked Developer account cannot be banned by a Moderator." }); string targetUsername = (target.Player.Username ?? accountId.ToString()).TrimStart('@'); var distinctTargets = targets.DistinctBy(x => x.PlayerId).ToList(); var linkedAccountSummaries = distinctTargets .Select(x => (x.PlayerId, (string?)(x.Player?.Username ?? x.PlayerId.ToString()))) .ToList(); var platformIdentities = (target.PlatformIds ?? new()) .Select(x => (x.Platform.ToString(), x.PlatformId.ToString())) .ToList(); foreach (var bannedAccount in distinctTargets) { string banReason = bannedAccount.PlayerId == accountId ? reason : $"{reason}\n\nRelated account: @{targetUsername}"; PlayerDB.BanPlayer(bannedAccount.PlayerId, durationSeconds, banReason, (ulong)admin.PlayerId); RecNetDB.ModerationLocks.Delete(bannedAccount.PlayerId); DiscordLogger.LogBan( bannedAccount.PlayerId, bannedAccount.Player?.Username, admin.PlayerId, admin.Player?.Username, banReason, durationSeconds, linkedAccounts: distinctTargets.Count > 1 ? linkedAccountSummaries : null, platformIdentities: bannedAccount.PlayerId == accountId ? platformIdentities : null); } return Ok(new { success = true, linkBan = request.LinkBan, durationSeconds, affectedCount = distinctTargets.Count }); } private const int TimeoutMaxDurationSeconds = 30 * 24 * 60 * 60; private static bool TryComputeDurationSeconds( string durationUnit, int durationAmount, out int durationSeconds) { long multiplier = durationUnit switch { "seconds" => 1, "minutes" => 60, "hours" => 60 * 60, "days" => 24 * 60 * 60, "weeks" => 7 * 24 * 60 * 60, _ => 0 }; long total = (long)durationAmount * multiplier; if (durationAmount < 1 || multiplier == 0 || total > int.MaxValue) { durationSeconds = 0; return false; } durationSeconds = (int)total; return true; } private static object ToAdminPlayerReport(ReportsDB.PlayerReport report) => new { id = report.Id, reporterId = report.ReporterId, reporterUsername = report.ReporterUsername, reportedPlayerId = report.ReportedPlayerId, reportedUsername = report.ReportedUsername, reportCategory = report.ReportCategory, details = report.Details, roomId = report.RoomId, roomInstanceType = report.RoomInstanceType, createdAt = report.CreatedAt, status = report.Status, resolvedByAccountId = report.ResolvedByAccountId, resolvedByUsername = report.ResolvedByUsername, resolvedAt = report.ResolvedAt, resolutionNote = report.ResolutionNote, actionDurationSeconds = report.ActionDurationSeconds }; private static object ToAdminBugReport(ReportsDB.BugReport report) => new { id = report.Id, reporterId = report.ReporterId, reporterUsername = report.ReporterUsername, description = report.Description, category = report.Category, createdAt = report.CreatedAt, status = report.Status, resolvedByAccountId = report.ResolvedByAccountId, resolvedByUsername = report.ResolvedByUsername, resolvedAt = report.ResolvedAt }; [HttpGet("admin/reports/players")] public IActionResult GetAdminPlayerReports(string? status) { var admin = AuthStuff.GetCurrentPlayer(Request); if (!IsStaff(admin)) return StatusCode(403); string filter = string.IsNullOrWhiteSpace(status) ? "Pending" : status.Trim(); IEnumerable reports = ReportsDB.PlayerReports.FindAll(); if (!string.Equals(filter, "all", StringComparison.OrdinalIgnoreCase)) reports = reports.Where(report => report.Status == filter); return Ok(reports.OrderByDescending(report => report.CreatedAt).Take(200).Select(ToAdminPlayerReport)); } [HttpPost("admin/reports/players/{reportId:guid}/resolve")] public IActionResult ResolveAdminPlayerReport( Guid reportId, [FromBody] AdminReportResolveRequest request) { var admin = AuthStuff.GetCurrentPlayer(Request); if (!IsStaff(admin)) return StatusCode(403); var report = ReportsDB.PlayerReports.FindById(reportId); if (report == null) return NotFound(new { error = "Report not found." }); if (report.Status != "Pending") return BadRequest(new { error = $"Report was already resolved as {report.Status}." }); string action = request.Action?.Trim().ToLowerInvariant() ?? string.Empty; if (action == "noaction") { report.Status = "NoAction"; report.ResolvedByAccountId = admin!.PlayerId; report.ResolvedByUsername = admin.Player?.Username; report.ResolvedAt = DateTime.UtcNow; report.ResolutionNote = request.Reason?.Trim(); ReportsDB.PlayerReports.Update(report); return Ok(new { success = true, status = report.Status }); } if (action != "ban" && action != "timeout") return BadRequest(new { error = "Action must be 'ban', 'timeout', or 'noaction'." }); if (!report.ReportedPlayerId.HasValue) return BadRequest(new { error = "This report has no reported player to act on." }); long accountId = report.ReportedPlayerId.Value; var target = PlayerDB.Players.FindById(accountId); if (target?.Player == null) return NotFound(new { error = "Reported account no longer exists." }); if (accountId == admin!.PlayerId) return BadRequest(new { error = "You cannot ban yourself." }); if (IsDeveloper(target) && !IsDeveloper(admin)) return StatusCode(StatusCodes.Status403Forbidden, new { error = "Moderators cannot ban Developer accounts." }); string reason = request.Reason?.Trim() ?? string.Empty; if (reason.Length is < 3 or > 500) return BadRequest(new { error = "Reason must be 3-500 characters." }); int durationSeconds; string durationUnit = request.DurationUnit?.Trim().ToLowerInvariant() ?? string.Empty; if (action == "ban") { if (string.IsNullOrEmpty(durationUnit) || durationUnit == "permanent") { durationSeconds = int.MaxValue; } else if (!TryComputeDurationSeconds(durationUnit, request.DurationAmount, out durationSeconds)) { return BadRequest(new { error = "Choose a valid ban duration within the 32-bit limit." }); } } else { if (string.IsNullOrEmpty(durationUnit) || durationUnit == "permanent") return BadRequest(new { error = "Timeouts require a duration - use Ban for a permanent action." }); if (!TryComputeDurationSeconds(durationUnit, request.DurationAmount, out durationSeconds)) return BadRequest(new { error = "Choose a valid timeout duration within the 32-bit limit." }); if (durationSeconds > TimeoutMaxDurationSeconds) return BadRequest(new { error = "Timeouts are capped at 30 days - use Ban for longer durations." }); } PlayerDB.BanPlayer(accountId, durationSeconds, reason, (ulong)admin.PlayerId); RecNetDB.ModerationLocks.Delete(accountId); DiscordLogger.LogBan( accountId, target.Player?.Username, admin.PlayerId, admin.Player?.Username, reason, durationSeconds, linkedAccounts: null, platformIdentities: null); report.Status = action == "ban" ? "Banned" : "TimedOut"; report.ResolvedByAccountId = admin.PlayerId; report.ResolvedByUsername = admin.Player?.Username; report.ResolvedAt = DateTime.UtcNow; report.ResolutionNote = reason; report.ActionDurationSeconds = durationSeconds; ReportsDB.PlayerReports.Update(report); return Ok(new { success = true, status = report.Status, durationSeconds }); } [HttpGet("admin/reports/bugs")] public IActionResult GetAdminBugReports(string? status) { var admin = AuthStuff.GetCurrentPlayer(Request); if (!IsStaff(admin)) return StatusCode(403); string filter = string.IsNullOrWhiteSpace(status) ? "Open" : status.Trim(); IEnumerable reports = ReportsDB.BugReports.FindAll(); if (!string.Equals(filter, "all", StringComparison.OrdinalIgnoreCase)) reports = reports.Where(report => report.Status == filter); return Ok(reports.OrderByDescending(report => report.CreatedAt).Take(200).Select(ToAdminBugReport)); } [HttpPost("admin/reports/bugs/{reportId:guid}/resolve")] public IActionResult ResolveAdminBugReport( Guid reportId, [FromBody] AdminBugReportResolveRequest request) { var admin = AuthStuff.GetCurrentPlayer(Request); if (!IsStaff(admin)) return StatusCode(403); var report = ReportsDB.BugReports.FindById(reportId); if (report == null) return NotFound(new { error = "Bug report not found." }); string status = request.Status?.Trim() ?? string.Empty; if (status != "Open" && status != "Closed") return BadRequest(new { error = "Status must be 'Open' or 'Closed'." }); report.Status = status; if (status == "Closed") { report.ResolvedByAccountId = admin!.PlayerId; report.ResolvedByUsername = admin.Player?.Username; report.ResolvedAt = DateTime.UtcNow; } else { report.ResolvedByAccountId = null; report.ResolvedByUsername = null; report.ResolvedAt = null; } ReportsDB.BugReports.Update(report); return Ok(new { success = true, status = report.Status }); } [HttpPost("admin/accounts/{accountId:long}/message")] public async Task AdminMessageAccount( long accountId, [FromBody] AdminMessageRequest request) { var admin = AuthStuff.GetCurrentPlayer(Request); if (!IsDeveloper(admin)) return StatusCode(403, new { error = "Only Developers can message as Coach." }); var target = PlayerDB.Players.FindById(accountId); if (target?.Player == null) return NotFound(new { error = "Account not found." }); string body = (request.Message ?? string.Empty).Trim(); if (body.Length is < 1 or > 2000) return BadRequest(new { error = "Message must be 1-2000 characters." }); const long coachAccountId = 1; ChatDB.ChatThread? existingThread = ChatDB.FindThreadWithMembers( coachAccountId, new[] { accountId }); ChatDB.ChatThread thread = existingThread ?? ChatDB.GetOrCreateThread( coachAccountId, new[] { accountId }); ChatDB.ChatMessage? message = ChatDB.AddMessage( thread.ThreadId, coachAccountId, body); if (message == null) return BadRequest(new { error = "Message failed to send." }); var messageDto = new { chatMessageId = message.MessageId, chatThreadId = message.ThreadId, senderPlayerId = (int)coachAccountId, contents = message.Body, createdAt = message.CreatedAt }; _ = NotiController.NotifyChatMessageReceivedAsync(accountId, messageDto); Console.WriteLine( $"[ADMIN CHAT] admin={admin!.PlayerId} sentAsCoach=true " + $"to={accountId} threadId={thread.ThreadId}"); return Ok(new { success = true, threadId = thread.ThreadId, messageId = message.MessageId }); } [HttpDelete("admin/accounts/{accountId:long}/ban")] public IActionResult AdminUnbanAccount(long accountId) { var admin = AuthStuff.GetCurrentPlayer(Request); if (!IsStaff(admin)) return StatusCode(403); var target = PlayerDB.Players.FindById(accountId); if (target?.Player == null) return NotFound(new { error = "Account not found." }); if (IsDeveloper(target) && !IsDeveloper(admin)) return StatusCode(StatusCodes.Status403Forbidden, new { error = "Moderators cannot unban Developer accounts." }); PlayerDB.UnbanPlayer(accountId); RecNetDB.ModerationLocks.Delete(accountId); return Ok(new { success = true }); } [HttpDelete("admin/accounts/{accountId:long}/moderation-lock")] public IActionResult AdminRemoveModerationLock(long accountId, [FromBody] AdminModerationUnlockRequest request) { var admin = AuthStuff.GetCurrentPlayer(Request); if (!IsStaff(admin)) return StatusCode(403); var existing = RecNetDB.ModerationLocks.FindById(accountId); if (existing == null) return NotFound(new { error = "This account does not have a Moderation Lock." }); var locks = request.RemoveLinkedAccounts && !string.IsNullOrEmpty(existing.BanGroupId) ? RecNetDB.ModerationLocks.Find(x => x.BanGroupId == existing.BanGroupId).ToList() : new List { existing }; var lockedAccounts = locks.Select(x => PlayerDB.Players.FindById(x.AccountId)).Where(x => x != null).ToList(); if (!IsDeveloper(admin) && lockedAccounts.Any(IsDeveloper)) return StatusCode(StatusCodes.Status403Forbidden, new { error = "A linked Developer lock cannot be removed by a Moderator." }); foreach (var moderationLock in locks) { PlayerDB.UnbanPlayer(moderationLock.AccountId); RecNetDB.ModerationLocks.Delete(moderationLock.AccountId); } return Ok(new { success = true, affectedCount = locks.Count }); } [HttpGet("admin/system-status")] public IActionResult GetAdminSystemStatus() { var admin = AuthStuff.GetCurrentPlayer(Request); if (!IsStaff(admin)) return StatusCode(403); var globalActive = ApiProtectionAttribute.GetGlobalActiveRequests(); var isUnderAttack = globalActive > 500; return Ok(new { globalActiveRequests = globalActive, isUnderAttack, timestamp = DateTime.UtcNow }); } [HttpGet("admin/clubs")] public IActionResult GetAdminClubs([FromQuery] string? search = null, [FromQuery] int skip = 0, [FromQuery] int count = 50) { var admin = AuthStuff.GetCurrentPlayer(Request); if (!IsStaff(admin)) return StatusCode(403); var (results, total) = ClubDB.SearchWithTotal(null, search, 0, skip, Math.Clamp(count, 1, 100)); return Ok(new { clubs = results.Select(ToAdminClubSummary).ToList(), total }); } [HttpGet("admin/clubs/{clubId:long}")] public IActionResult GetAdminClub(long clubId) { var admin = AuthStuff.GetCurrentPlayer(Request); if (!IsStaff(admin)) return StatusCode(403); var club = ClubDB.Get(clubId); if (club == null) return NotFound(new { error = "Club not found." }); return Ok(ToAdminClubDetails(club)); } [HttpPut("admin/clubs/{clubId:long}")] public IActionResult UpdateAdminClub(long clubId, [FromBody] JsonElement request) { var admin = AuthStuff.GetCurrentPlayer(Request); if (!IsStaff(admin)) return StatusCode(403); var club = ClubDB.Get(clubId); if (club == null) return NotFound(new { error = "Club not found." }); if (request.TryGetProperty("name", out JsonElement nameProp)) { string name = nameProp.GetString()?.Trim() ?? string.Empty; if (name.Length is < 3 or > 50) return BadRequest(new { error = "Club name must be 3-50 characters." }); club.Name = name; } if (request.TryGetProperty("description", out JsonElement descProp)) { string description = descProp.GetString()?.Trim() ?? string.Empty; if (description.Length > 1_000) return BadRequest(new { error = "Description too long." }); club.Description = description; } if (request.TryGetProperty("state", out JsonElement stateProp) && Enum.TryParse(stateProp.GetString(), true, out var state)) { club.State = state; } if (request.TryGetProperty("visibility", out JsonElement visProp) && Enum.TryParse(visProp.GetString(), true, out var visibility)) { club.Visibility = visibility; } if (request.TryGetProperty("joinability", out JsonElement joinProp) && Enum.TryParse(joinProp.GetString(), true, out var joinability)) { club.Joinability = joinability; } if (request.TryGetProperty("allowJuniors", out JsonElement juniorsProp)) { club.AllowJuniors = juniorsProp.GetBoolean(); } if (request.TryGetProperty("minLevel", out JsonElement levelProp)) { club.MinLevel = Math.Clamp(levelProp.GetInt32(), 0, 50); } club.UpdatedAt = DateTime.UtcNow; if (!ClubDB.Update(club)) return StatusCode(StatusCodes.Status500InternalServerError, new { error = "Could not save club." }); return Ok(ToAdminClubDetails(club)); } [HttpGet("admin/clubs/{clubId:long}/members")] public IActionResult GetAdminClubMembers(long clubId, [FromQuery] bool includePending = false) { var admin = AuthStuff.GetCurrentPlayer(Request); if (!IsStaff(admin)) return StatusCode(403); if (ClubDB.Get(clubId) == null) return NotFound(new { error = "Club not found." }); var memberships = ClubDB.GetMemberships(clubId, includePending); var memberIds = memberships.Select(m => m.AccountId).ToHashSet(); var players = PlayerDB.Players.FindAll() .Where(p => memberIds.Contains(p.PlayerId)) .ToDictionary(p => p.PlayerId); return Ok(memberships.Select(m => new { accountId = m.AccountId, username = players.TryGetValue(m.AccountId, out var p) ? p.Player?.Username : null, displayName = players.TryGetValue(m.AccountId, out p) ? p.Player?.DisplayName : null, membershipType = m.MembershipType.ToString(), createdAt = m.CreatedAt }).ToList()); } [HttpPost("admin/clubs/{clubId:long}/members/{accountId:long}")] public IActionResult SetAdminClubMember(long clubId, long accountId, [FromBody] JsonElement request) { var admin = AuthStuff.GetCurrentPlayer(Request); if (!IsStaff(admin)) return StatusCode(403); if (ClubDB.Get(clubId) == null) return NotFound(new { error = "Club not found." }); if (!Enum.TryParse(request.GetProperty("membershipType").GetString(), true, out var membershipType)) return BadRequest(new { error = "Invalid membership type." }); if (!ClubDB.SetMembership(clubId, accountId, membershipType)) return StatusCode(StatusCodes.Status500InternalServerError, new { error = "Could not set membership." }); return Ok(new { success = true }); } [HttpDelete("admin/clubs/{clubId:long}/members/{accountId:long}")] public IActionResult RemoveAdminClubMember(long clubId, long accountId) { var admin = AuthStuff.GetCurrentPlayer(Request); if (!IsStaff(admin)) return StatusCode(403); if (!ClubDB.RemoveMembership(clubId, accountId)) return BadRequest(new { error = "Could not remove member (creator cannot be removed)." }); return Ok(new { success = true }); } [HttpGet("admin/accounts/{accountId:long}/relationships")] public IActionResult GetAdminAccountRelationships(long accountId) { var admin = AuthStuff.GetCurrentPlayer(Request); if (!IsStaff(admin)) return StatusCode(403); var relationships = RelationshipDB.GetRelationships(accountId); var playerIds = relationships.Select(r => r.PlayerID).ToHashSet(); var players = PlayerDB.Players.FindAll() .Where(p => playerIds.Contains(p.PlayerId)) .ToDictionary(p => p.PlayerId); return Ok(relationships.Select(r => new { playerId = r.PlayerID, username = players.TryGetValue(r.PlayerID, out var p) ? p.Player?.Username : null, displayName = players.TryGetValue(r.PlayerID, out p) ? p.Player?.DisplayName : null, relationshipType = r.RelationshipType.ToString(), favorited = r.Favorited, muted = r.Muted, ignored = r.Ignored }).ToList()); } [HttpPost("admin/accounts/{accountId:long}/relationships/friend")] public IActionResult AdminForceAddFriend( long accountId, [FromBody] AdminFriendChange request) { var admin = AuthStuff.GetCurrentPlayer(Request); if (!IsAdmin(admin)) return StatusCode(403); if (request.TargetAccountId <= 0 || request.TargetAccountId == accountId) return BadRequest(new { error = "Choose a different, valid account ID." }); var account = PlayerDB.Players.FindById(accountId); var target = PlayerDB.Players.FindById(request.TargetAccountId); if (account?.Player == null || target?.Player == null) return NotFound(new { error = "Account not found." }); RelationshipDB.AddFriend(accountId, request.TargetAccountId); Console.WriteLine( $"[ADMIN RELATIONSHIP] admin={admin!.PlayerId} force-added friend " + $"{request.TargetAccountId} for account={accountId}"); return Ok(new { success = true }); } [HttpDelete("admin/accounts/{accountId:long}/relationships/friend/{targetAccountId:long}")] public IActionResult AdminRemoveFriend(long accountId, long targetAccountId) { var admin = AuthStuff.GetCurrentPlayer(Request); if (!IsAdmin(admin)) return StatusCode(403); bool removed = RelationshipDB.RemoveFriend(accountId, targetAccountId); return removed ? Ok(new { success = true }) : NotFound(new { error = "That friendship does not exist." }); } [HttpGet("admin/accounts/{accountId:long}/gifts")] public IActionResult GetAdminAccountGifts(long accountId) { var admin = AuthStuff.GetCurrentPlayer(Request); if (!IsStaff(admin)) return StatusCode(403); var account = PlayerDB.Players.FindById(accountId); if (account?.Player == null) return NotFound(new { error = "Account not found." }); var senderIds = PlayerDB.GetPendingGiftPackages(accountId) .Select(gift => (long)gift.FromPlayerId) .Distinct() .ToList(); var senders = PlayerDB.Players.FindAll() .Where(p => senderIds.Contains(p.PlayerId)) .ToDictionary(p => p.PlayerId); return Ok(PlayerDB.GetPendingGiftPackages(accountId).Select(gift => new { giftPackageId = gift.GiftPackageId, fromPlayerId = gift.FromPlayerId, fromDisplayName = senders.TryGetValue(gift.FromPlayerId, out var sender) ? (sender.Player?.DisplayName ?? sender.Player?.Username) : (gift.FromPlayerId == 1 ? "Coach" : $"Player {gift.FromPlayerId}"), message = gift.Message, friendlyName = gift.FriendlyName, tooltip = gift.Tooltip, thumbnailImage = gift.ThumbnailImage, avatarItemDesc = gift.AvatarItemDesc, consumableItemDesc = gift.ConsumableItemDesc, consumableQuantity = gift.ConsumableQuantity, equipmentPrefabName = gift.EquipmentPrefabName, currency = gift.Currency, xp = gift.XP, giftContext = gift.GiftContext, rarity = gift.Rarity }).ToList()); } [HttpDelete("admin/accounts/{accountId:long}/gifts/{giftPackageId:long}")] public IActionResult DeleteAdminAccountGift(long accountId, long giftPackageId) { var admin = AuthStuff.GetCurrentPlayer(Request); if (!IsAdmin(admin)) return StatusCode(403); return PlayerDB.RemoveGiftPackage(accountId, giftPackageId) ? Ok(new { success = true }) : NotFound(new { error = "That pending gift no longer exists." }); } [HttpDelete("admin/accounts/{accountId:long}/gifts")] public IActionResult ClearAdminAccountGifts(long accountId) { var admin = AuthStuff.GetCurrentPlayer(Request); if (!IsAdmin(admin)) return StatusCode(403); int removed = PlayerDB.ClearPendingGiftPackages(accountId); return Ok(new { success = true, removedBoxes = removed }); } [HttpGet("admin/age-verification")] public IActionResult GetAdminAgeVerificationQueue() { var admin = AuthStuff.GetCurrentPlayer(Request); if (!IsStaff(admin)) return StatusCode(403); var queue = AgeVerificationDB.GetForReview(); var accountIds = queue.Select(value => value.AccountId).ToHashSet(); var accounts = PlayerDB.Players.FindAll() .Where(value => accountIds.Contains(value.PlayerId)) .ToDictionary(value => value.PlayerId); return Ok(queue.Select(request => { accounts.TryGetValue(request.AccountId, out var account); return new { code = request.Code, accountId = request.AccountId, username = account?.Player?.Username, displayName = account?.Player?.DisplayName ?? account?.Player?.Username ?? $"Player {request.AccountId}", method = request.Method, submittedAt = request.SubmittedAt }; }).ToList()); } [HttpPost("admin/age-verification/{code}/approve")] public IActionResult ApproveAgeVerification(string code) { var admin = AuthStuff.GetCurrentPlayer(Request); if (!IsStaff(admin)) return StatusCode(403); var request = AgeVerificationDB.GetByCode(code); if (request == null || request.Status != "UnderReview") return NotFound(new { error = "That request is not awaiting review." }); var account = PlayerDB.Players.FindById(request.AccountId); if (account?.Player == null) return NotFound(new { error = "Account not found." }); if (!AgeVerificationDB.Review(code, approve: true, admin!.PlayerId, reason: null)) return StatusCode(500, new { error = "Could not save the review." }); account.Player.IsAgeVerified = true; account.Player.AgeVerifiedAt = DateTime.UtcNow; account.Player.IsJunior = false; PlayerDB.Players.Update(account); Console.WriteLine( $"[AGE VERIFICATION] admin={admin.PlayerId} approved code={code} " + $"account={request.AccountId} method={request.Method}"); return Ok(new { success = true }); } [HttpPost("admin/age-verification/{code}/reject")] public IActionResult RejectAgeVerification( string code, [FromBody] AdminAgeVerificationRejection? request) { var admin = AuthStuff.GetCurrentPlayer(Request); if (!IsStaff(admin)) return StatusCode(403); var record = AgeVerificationDB.GetByCode(code); if (record == null || record.Status != "UnderReview") return NotFound(new { error = "That request is not awaiting review." }); if (!AgeVerificationDB.Review(code, approve: false, admin!.PlayerId, request?.Reason)) return StatusCode(500, new { error = "Could not save the review." }); Console.WriteLine( $"[AGE VERIFICATION] admin={admin.PlayerId} rejected code={code} " + $"account={record.AccountId}"); return Ok(new { success = true }); } public class AdminAgeVerificationRejection { public string? Reason { get; set; } } [HttpGet("admin/accounts/{accountId:long}/chat/threads")] public IActionResult GetAdminAccountChatThreads(long accountId) { var admin = AuthStuff.GetCurrentPlayer(Request); if (!IsStaff(admin)) return StatusCode(403); var account = PlayerDB.Players.FindById(accountId); if (account?.Player == null) return NotFound(new { error = "Account not found." }); var threads = ChatDB.GetThreadsForPlayer(accountId, 50); var memberIds = threads.SelectMany(t => t.MemberIds ?? new List()).ToHashSet(); var members = PlayerDB.Players.FindAll() .Where(p => memberIds.Contains(p.PlayerId)) .ToDictionary(p => p.PlayerId); return Ok(threads.Select(thread => { var last = ChatDB.GetLastMessage(thread.ThreadId); return new { threadId = thread.ThreadId, name = thread.Name, updatedAt = thread.UpdatedAt, members = (thread.MemberIds ?? new List()).Select(id => new { accountId = id, displayName = members.TryGetValue(id, out var member) ? (member.Player?.DisplayName ?? member.Player?.Username ?? $"Player {id}") : $"Player {id}" }), lastMessage = last == null ? null : new { body = last.Body, senderAccountId = last.SenderAccountId, createdAt = last.CreatedAt } }; })); } [HttpGet("admin/accounts/{accountId:long}/chat/threads/{threadId:long}/messages")] public IActionResult GetAdminAccountChatMessages( long accountId, long threadId, [FromQuery] int take = 100) { var admin = AuthStuff.GetCurrentPlayer(Request); if (!IsStaff(admin)) return StatusCode(403); var messages = ChatDB.GetMessages(threadId, accountId, take); var senderIds = messages.Select(m => m.SenderAccountId).Distinct().ToList(); var senders = PlayerDB.Players.FindAll() .Where(p => senderIds.Contains(p.PlayerId)) .ToDictionary(p => p.PlayerId); return Ok(messages.Select(message => new { messageId = message.MessageId, senderAccountId = message.SenderAccountId, senderDisplayName = message.SenderAccountId < 0 ? "System" : senders.TryGetValue(message.SenderAccountId, out var sender) ? (sender.Player?.DisplayName ?? sender.Player?.Username ?? $"Player {message.SenderAccountId}") : $"Player {message.SenderAccountId}", body = message.Body, createdAt = message.CreatedAt })); } public class AdminFriendChange { public long TargetAccountId { get; set; } } [HttpGet("admin/accounts/{accountId:long}/clubs")] public IActionResult GetAdminAccountClubs(long accountId) { var admin = AuthStuff.GetCurrentPlayer(Request); if (!IsStaff(admin)) return StatusCode(403); var clubs = ClubDB.GetMemberClubs(accountId); return Ok(clubs.Select(ToAdminClubSummary).ToList()); } private static object ToAdminClubSummary(ClubDBClasses.ClubRecord club) { return new { clubId = club.ClubId, name = club.Name, description = club.Description, mainImageName = club.MainImageName, memberCount = club.MemberCount, primaryCategory = club.PrimaryCategory, categoryTags = club.CategoryTags, visibility = club.Visibility.ToString(), joinability = club.Joinability.ToString(), allowJuniors = club.AllowJuniors, minLevel = club.MinLevel, clubType = club.ClubType.ToString(), state = club.State.ToString(), creatorAccountId = club.CreatorAccountId, createdAt = club.CreatedAt, updatedAt = club.UpdatedAt }; } private static object ToAdminClubDetails(ClubDBClasses.ClubRecord club) { var creator = PlayerDB.Players.FindById(club.CreatorAccountId); return new { summary = ToAdminClubSummary(club), creator = creator != null ? new { accountId = creator.PlayerId, username = creator.Player?.Username, displayName = creator.Player?.DisplayName } : null }; } [HttpDelete("admin/accounts/{accountId:long}")] public IActionResult AdminDeleteAccount(long accountId, [FromBody] AdminAccountDeletion request) { var admin = AuthStuff.GetCurrentPlayer(Request); if (!IsAdmin(admin)) return StatusCode(403); if (accountId == admin!.PlayerId) return BadRequest(new { error = "You cannot delete your own account from the Admin panel." }); var account = PlayerDB.Players.FindById(accountId); if (account?.Player == null) return NotFound(new { error = "Account not found." }); if (IsDeveloper(account) && !IsDeveloper(admin)) return StatusCode(StatusCodes.Status403Forbidden, new { error = "Moderators cannot delete Developer accounts." }); if (!string.Equals(request.Confirmation?.Trim(), $"DELETE {accountId}", StringComparison.OrdinalIgnoreCase)) return BadRequest(new { error = $"Type DELETE {accountId} to confirm." }); RecNetDB.PhotoCheers.DeleteMany(x => x.AccountId == accountId); RecNetDB.PhotoComments.DeleteMany(x => x.AccountId == accountId); RecNetDB.ModerationLocks.Delete(accountId); RoomDB.DeletePlayerDorms(accountId); PlayerDB.Players.Delete(accountId); return Ok(new { success = true }); } private static bool TryValidateRegistration( RecNetRegistration request, out string username, out PlayerDBClasses.Platforms platform, out ulong platformId, out string error, out int status) { username = request.Username?.Trim().TrimStart('@') ?? string.Empty; platform = default; platformId = 0; error = string.Empty; status = StatusCodes.Status400BadRequest; if (username.Length is < 3 or > 20 || username.Any(ch => !char.IsLetterOrDigit(ch) && ch != '_')) error = "Username must be 3-20 letters, numbers, or underscores."; else if (string.IsNullOrEmpty(request.Password) || request.Password.Length < PasswordSecurity.MinPasswordLength || request.Password.Length > PasswordSecurity.MaxPasswordLength) error = "Password must be at least 8 characters."; else if (!string.Equals(request.Password, request.ConfirmPassword, StringComparison.Ordinal)) error = "Passwords do not match."; else if (!Enum.TryParse(request.Platform, true, out platform) || platform is PlayerDBClasses.Platforms.All or PlayerDBClasses.Platforms.HeadlessBot) error = "Choose a valid platform."; else if (!ulong.TryParse(request.PlatformId, out platformId) || platformId == 0) error = "Enter a valid numeric platform ID."; else if (PlayerDB.Players.FindAll().Any(x => string.Equals(x.Player?.Username, request.Username?.Trim().TrimStart('@'), StringComparison.OrdinalIgnoreCase))) { error = "That username is already taken."; status = StatusCodes.Status409Conflict; } return string.IsNullOrEmpty(error); } private static PlayerDBClasses.FullPlayer CreateRegisteredAccount( string username, string password, PlayerDBClasses.Platforms platform, ulong platformId) { var account = PlayerDB.CreateAccount(platform, platformId, false); account.Player!.Username = username; account.Player.DisplayName = username; account.Password = PasswordSecurity.Hash(password); PlayerDB.Players.Update(account); return account; } private static object BuildDeveloperSnapshot() { DeveloperTelemetry.TelemetrySnapshot telemetry = DeveloperTelemetry.Snapshot(160); var accounts = PlayerDB.Players.FindAll() .Where(value => value.Player != null) .ToList(); var onlinePlayers = accounts .Select(account => new { Account = account, Heartbeat = PlayerDB.GetPlayerHeartbeat(account.PlayerId) }) .Where(value => value.Heartbeat.isOnline) .OrderBy(value => value.Account.Player!.DisplayName) .Select(value => new { accountId = value.Account.PlayerId, username = value.Account.Player!.Username, displayName = value.Account.Player.DisplayName, profileImage = ImageUrl(value.Account.Player.ProfileImage), device = value.Heartbeat.deviceClass?.ToString() ?? "Unknown", room = value.Heartbeat.roomInstance?.Name ?? value.Heartbeat.roomInstance?.location ?? "Online", roomId = value.Heartbeat.roomInstance?.roomId, roomInstanceId = value.Heartbeat.roomInstance?.roomInstanceId, lastHeartbeat = value.Heartbeat.lastHeartbeatUnixTime > 0 ? DateTimeOffset.FromUnixTimeSeconds( value.Heartbeat.lastHeartbeatUnixTime) : (DateTimeOffset?)null, sockets = NotiController.GetPlayerSocketCount( value.Account.PlayerId) }) .ToList(); using Process process = Process.GetCurrentProcess(); process.Refresh(); return new { generatedAt = DateTimeOffset.UtcNow, server = new { status = "online", uptimeSeconds = Math.Max( 0, (long)(DateTime.UtcNow - process.StartTime.ToUniversalTime()).TotalSeconds), cpuPercent = SampleDeveloperCpu(process), memoryBytes = process.WorkingSet64, managedMemoryBytes = GC.GetTotalMemory(false), threads = process.Threads.Count, processorCount = Environment.ProcessorCount }, totals = new { requests = telemetry.TotalRequests, inboundBytes = telemetry.TotalInboundBytes, outboundBytes = telemetry.TotalOutboundBytes, errors = telemetry.TotalErrors, registeredPlayers = accounts.Count, onlinePlayers = onlinePlayers.Count, connectedSockets = NotiController.ConnectedSocketCount, rooms = RoomDB.Rooms.Count(), chatThreads = ChatDB.DeveloperThreadCount, chatMessages = ChatDB.DeveloperMessageCount }, onlinePlayers, requests = telemetry.Requests.Select(value => new { at = value.At, method = value.Method, path = value.Path, status = value.StatusCode, durationMs = value.ElapsedMilliseconds, inboundBytes = value.InboundBytes, outboundBytes = value.OutboundBytes }).ToArray(), series = telemetry.Points.Select(value => new { at = DateTimeOffset.FromUnixTimeSeconds( value.UnixSecond), requests = value.Requests, inboundBytes = value.InboundBytes, outboundBytes = value.OutboundBytes, errors = value.Errors, latencyMs = Math.Round( value.AverageLatencyMilliseconds, 2) }).ToArray(), chats = BuildDeveloperChatItems(40, null) }; } private static object BuildDeveloperChats( int take, long? beforeMessageId) { take = Math.Clamp(take, 1, 250); object[] messages = BuildDeveloperChatItems( take, beforeMessageId); long oldestMessageId = messages .Select(value => { JsonElement json = JsonSerializer.SerializeToElement(value); return json.GetProperty("messageId").GetInt64(); }) .DefaultIfEmpty(0) .Min(); return new { messages, oldestMessageId, hasMore = messages.Length == take }; } private static object[] BuildDeveloperChatItems( int take, long? beforeMessageId) { List messages = ChatDB.GetMessagesForDeveloper(take, beforeMessageId); Dictionary threads = ChatDB.GetThreadsForDeveloper( messages.Select(value => value.ThreadId)); Dictionary players = PlayerDB.Players.FindAll() .Where(value => value.Player != null) .ToDictionary(value => value.PlayerId); return messages.Select(message => { threads.TryGetValue(message.ThreadId, out var thread); players.TryGetValue(message.SenderAccountId, out var sender); return (object)new { messageId = message.MessageId, threadId = message.ThreadId, threadName = string.IsNullOrWhiteSpace(thread?.Name) ? $"Thread {message.ThreadId}" : thread.Name, sender = new { accountId = message.SenderAccountId, username = sender?.Player?.Username, displayName = message.SenderAccountId < 0 ? "System" : sender?.Player?.DisplayName ?? sender?.Player?.Username ?? $"Player {message.SenderAccountId}" }, members = (thread?.MemberIds ?? new List()) .Select(accountId => { players.TryGetValue(accountId, out var member); return new { accountId, displayName = member?.Player?.DisplayName ?? member?.Player?.Username ?? $"Player {accountId}" }; }) .ToArray(), body = GetDeveloperChatBody(message.Body), createdAt = message.CreatedAt }; }).ToArray(); } private static string GetDeveloperChatBody(string body) { if (string.IsNullOrWhiteSpace(body)) return string.Empty; try { using JsonDocument document = JsonDocument.Parse(body); if (document.RootElement.ValueKind == JsonValueKind.Object && document.RootElement.TryGetProperty( "Data", out JsonElement data) && data.ValueKind == JsonValueKind.String) return data.GetString() ?? body; } catch (JsonException) { } return body; } private static double SampleDeveloperCpu(Process process) { lock (DeveloperCpuSync) { DateTime now = DateTime.UtcNow; TimeSpan cpu = process.TotalProcessorTime; double elapsedMilliseconds = (now - DeveloperCpuSampleAt).TotalMilliseconds; if (elapsedMilliseconds >= 250) { double cpuMilliseconds = (cpu - DeveloperCpuTime).TotalMilliseconds; DeveloperCpuPercent = Math.Clamp( cpuMilliseconds / (elapsedMilliseconds * Environment.ProcessorCount) * 100, 0, 100); DeveloperCpuSampleAt = now; DeveloperCpuTime = cpu; } return Math.Round(DeveloperCpuPercent, 2); } } private static object ToAdminSteamBlacklistEntry( SteamAccessDB.SteamBlacklistEntry entry) { var addedBy = PlayerDB.Players.FindById(entry.AddedByAccountId); return new { steamId = entry.SteamId.ToString(), entry.Reason, entry.AddedByAccountId, addedByUsername = addedBy?.Player?.Username, addedByDisplayName = addedBy?.Player?.DisplayName, addedAt = entry.AddedAt.ToUniversalTime().ToString("O") }; } private static bool TryValidateCommunityBoard( JsonElement board, out string error) { error = string.Empty; if (board.ValueKind != JsonValueKind.Object) { error = "The community board must be a JSON object."; return false; } if (TryGetPropertyIgnoreCase(board, "FeaturedPlayer", out JsonElement featured) && featured.ValueKind == JsonValueKind.Object) { if (!TryGetPropertyIgnoreCase(featured, "Id", out JsonElement featuredId) || !featuredId.TryGetInt64(out long accountId) || accountId < 1 || PlayerDB.Players.FindById(accountId)?.Player == null) { error = "Choose an existing account for the featured creator."; return false; } if (!TryValidateBoardString(featured, "TitleOverride", 120, out error) || !TryValidateBoardUrl(featured, "UrlOverride", false, out error)) return false; } if (TryGetPropertyIgnoreCase(board, "FeaturedRoomGroup", out JsonElement roomGroup) && roomGroup.ValueKind == JsonValueKind.Object) { foreach (string property in new[] { "FeaturedRooms", "Rooms" }) { if (!TryGetPropertyIgnoreCase(roomGroup, property, out JsonElement rooms)) continue; if (rooms.ValueKind != JsonValueKind.Array || rooms.GetArrayLength() > 10) { error = "The board can contain at most 10 pinned rooms."; return false; } foreach (JsonElement room in rooms.EnumerateArray()) { if (room.ValueKind != JsonValueKind.Object || !TryGetPropertyIgnoreCase(room, "RoomId", out JsonElement roomId) || !roomId.TryGetInt64(out long id) || id < 1 || RoomDB.Rooms.FindById(id) == null) { error = "Every pinned room must use an existing room ID."; return false; } if (!TryValidateBoardString(room, "RoomName", 50, out error) || !TryValidateBoardString(room, "ImageName", 2_048, out error)) return false; } } } if (!TryValidateBoardArray(board, "InstagramImages", 12, out JsonElement images, out error)) return false; foreach (JsonElement image in images.EnumerateArray()) { if (image.ValueKind != JsonValueKind.Object || !TryValidateBoardString(image, "ImageName", 300, out error) || !TryValidateBoardUrl(image, "ImageUrl", true, out error)) return false; } if (!TryValidateBoardArray(board, "Videos", 3, out JsonElement videos, out error)) return false; foreach (JsonElement video in videos.EnumerateArray()) { if (video.ValueKind != JsonValueKind.Object || !TryValidateBoardString(video, "BlobName", 300, out error) || !TryValidateBoardString(video, "Title", 120, out error) || !TryValidateBoardString(video, "Description", 1_000, out error) || !TryValidateBoardUrl(video, "ThumbnailBlobName", true, out error) || !TryValidateBoardUrl(video, "SourceUrl", true, out error)) return false; } if (TryGetPropertyIgnoreCase(board, "CurrentAnnouncement", out JsonElement announcement) && announcement.ValueKind == JsonValueKind.Object && (!TryValidateBoardString(announcement, "Message", 500, out error) || !TryValidateBoardUrl(announcement, "MoreInfoUrl", false, out error))) return false; return true; } private static bool TryValidateBoardArray( JsonElement parent, string property, int maximum, out JsonElement array, out string error) { error = string.Empty; if (!TryGetPropertyIgnoreCase(parent, property, out array)) { using JsonDocument empty = JsonDocument.Parse("[]"); array = empty.RootElement.Clone(); return true; } if (array.ValueKind != JsonValueKind.Array || array.GetArrayLength() > maximum) { error = $"{property} can contain at most {maximum} items."; return false; } return true; } private static bool TryValidateBoardString( JsonElement parent, string property, int maximum, out string error) { error = string.Empty; if (!TryGetPropertyIgnoreCase(parent, property, out JsonElement value)) return true; if (value.ValueKind is not (JsonValueKind.String or JsonValueKind.Null)) { error = $"{property} must be text."; return false; } string text = value.GetString() ?? string.Empty; if (text.Length > maximum || text.Any(character => character == '\0')) { error = $"{property} is too long or contains invalid characters."; return false; } return true; } private static bool TryValidateBoardUrl( JsonElement parent, string property, bool required, out string error) { error = string.Empty; if (!TryGetPropertyIgnoreCase(parent, property, out JsonElement value) || value.ValueKind == JsonValueKind.Null) { if (!required) return true; error = $"{property} is required."; return false; } if (value.ValueKind != JsonValueKind.String) { error = $"{property} must be a URL."; return false; } string url = value.GetString()?.Trim() ?? string.Empty; if (url.Length == 0) { if (!required) return true; error = $"{property} is required."; return false; } if (url.Length > 2_048) { error = $"{property} is too long."; return false; } bool safeLocal = url.StartsWith("/imageserver/", StringComparison.OrdinalIgnoreCase) || url.StartsWith("/recnet/", StringComparison.OrdinalIgnoreCase); bool safeRemote = Uri.TryCreate(url, UriKind.Absolute, out Uri? parsed) && parsed.Scheme is "http" or "https"; if (!safeLocal && !safeRemote) { error = $"{property} must use http, https, or a local site path."; return false; } return true; } private static bool TryGetPropertyIgnoreCase( JsonElement parent, string property, out JsonElement value) { if (parent.ValueKind == JsonValueKind.Object) { foreach (JsonProperty candidate in parent.EnumerateObject()) { if (string.Equals(candidate.Name, property, StringComparison.OrdinalIgnoreCase)) { value = candidate.Value; return true; } } } value = default; return false; } private static bool IsAdmin(PlayerDBClasses.FullPlayer? account) => IsDeveloper(account); private static bool IsModerator(PlayerDBClasses.FullPlayer? account) => account?.PlayerRoles?.Contains(PlayerDBClasses.PlayerRoles.Moderator) == true; private static bool IsStaff(PlayerDBClasses.FullPlayer? account) => IsDeveloper(account) || IsModerator(account); private static object ToAdminRoomSummary(RoomDBClasses.Room room) { var creator = PlayerDB.Players.FindById(room.CreatorAccountId); long activeAfter = DateTimeOffset.UtcNow.ToUnixTimeSeconds() - 120; int onlinePlayers = PlayerDB.Players.FindAll() .Select(player => player.Player?.PlayerExtra?.Heartbeat) .Count(heartbeat => heartbeat?.isOnline == true && heartbeat.lastHeartbeatUnixTime >= activeAfter && heartbeat.roomInstance?.roomId == room.RoomId); return new { roomId = room.RoomId, name = room.Name, description = room.Description, image = ImageUrl(room.ImageName), imageName = room.ImageName, creatorAccountId = room.CreatorAccountId, creatorName = creator?.Player?.DisplayName ?? creator?.Player?.Username ?? $"Player {room.CreatorAccountId}", accessibility = room.Accessibility.ToString(), state = room.State?.ToString() ?? RoomDBClasses.RoomState.Active.ToString(), isDorm = room.IsDorm, isBaseRoom = room.IsBaseRoom || RoomDB.IsCanonicalBaseRoom(room), isRRO = room.IsRRO, creativeToolsBetaEnabled = room.CreativeToolsBetaEnabled, supportsBetaContent = room.CreativeToolsBetaEnabled, isDeveloperOwned = room.IsDeveloperOwned, maxPlayers = room.MaxPlayers, minLevel = room.MinLevel, subRoomCount = room.SubRooms?.Count ?? 0, roleCount = room.Roles?.Count ?? 0, banCount = RoomDB.GetActiveBans(room.RoomId).Count, onlinePlayers, createdAt = room.CreatedAt, version = Math.Max(1, room.UgcVersion), stats = new { cheers = room.Stats?.CheerCount ?? 0, favorites = room.Stats?.FavoriteCount ?? 0, visitors = room.Stats?.VisitorCount ?? 0, visits = room.Stats?.VisitCount ?? 0 } }; } private static object ToAdminRoomDetails(RoomDBClasses.Room room) { var playerMap = PlayerDB.Players.FindAll() .Where(account => account.Player != null) .ToDictionary(account => account.PlayerId); static bool AdminBlobExists(string? blobName) { if (string.IsNullOrWhiteSpace(blobName)) return false; string safeName = Path.GetFileName(blobName); if (!string.Equals(safeName, blobName, StringComparison.Ordinal)) return false; return System.IO.File.Exists(Path.Combine( Program.dataDir, "CDN", "room", safeName)); } object PlayerIdentity(long accountId) { playerMap.TryGetValue( accountId, out PlayerDBClasses.FullPlayer? account); return new { accountId, username = account?.Player?.Username, displayName = account?.Player?.DisplayName ?? account?.Player?.Username ?? $"Player {accountId}", profileImage = ImageUrl( account?.Player?.ProfileImage ?? "DefaultPFP.png") }; } return new { summary = ToAdminRoomSummary(room), roomId = room.RoomId, name = room.Name, description = room.Description ?? string.Empty, imageName = room.ImageName ?? string.Empty, creatorAccountId = room.CreatorAccountId, creator = PlayerIdentity(room.CreatorAccountId), accessibility = room.Accessibility.ToString(), state = room.State?.ToString() ?? RoomDBClasses.RoomState.Active.ToString(), isDorm = room.IsDorm, isBaseRoom = room.IsBaseRoom || RoomDB.IsCanonicalBaseRoom(room), creativeToolsBetaEnabled = room.CreativeToolsBetaEnabled, supportsBetaContent = room.CreativeToolsBetaEnabled, isBeta = room.CreativeToolsBetaEnabled, maxPlayers = room.MaxPlayers, minLevel = room.MinLevel, persistenceVersion = room.PersistenceVersion, version = Math.Max(1, room.UgcVersion), tags = room.Tags? .Select(tag => tag.Tag) .Where(tag => !string.IsNullOrWhiteSpace(tag)) .Distinct(StringComparer.OrdinalIgnoreCase) .OrderBy(tag => tag) .ToArray() ?? Array.Empty(), flags = new { room.CloningAllowed, room.DisableMicAutoMute, room.DisableRoomComments, room.EncryptVoiceChat, room.ToxmodEnabled, room.LoadScreenLocked, room.AutoLocalizeRoom, room.IsDeveloperOwned, room.SupportsLevelVoting, room.IsRRO, room.SupportsScreens, room.SupportsWalkVR, room.SupportsTeleportVR, room.SupportsVRLow, room.SupportsQuest2, room.SupportsMobile, room.SupportsJuniors, room.IsBaseRoom, room.CreativeToolsBetaEnabled }, stats = new { cheers = room.Stats?.CheerCount ?? 0, favorites = room.Stats?.FavoriteCount ?? 0, visitors = room.Stats?.VisitorCount ?? 0, visits = room.Stats?.VisitCount ?? 0 }, roles = (room.Roles ?? new List()) .OrderByDescending(role => role.Role) .ThenBy(role => role.AccountId) .Select(role => new { role.AccountId, player = PlayerIdentity(role.AccountId), role = role.Role.ToString(), invitedRole = role.InvitedRole.ToString() }) .ToList(), subRooms = (room.SubRooms ?? new List()) .OrderBy(subRoom => string.Equals( subRoom.Name, "Home", StringComparison.OrdinalIgnoreCase) ? 0 : 1) .ThenBy(subRoom => subRoom.SubRoomId) .Select(subRoom => { var currentSave = subRoom.SubRoomDataSaveId > 0 ? RoomDB.SubRoomDataSaves.FindById( subRoom.SubRoomDataSaveId) : null; currentSave ??= RoomDBClasses.FindNewestSubRoomSave( RoomDB.SubRoomDataSaves, room.RoomId, subRoom.SubRoomId); string roomBlob = currentSave?.DataBlob ?? subRoom.DataBlob ?? string.Empty; bool isBakedSave = !string.IsNullOrWhiteSpace( currentSave?.UnityAssetId); string metadataBlob = currentSave?.RoomDataBlob ?? room.DataBlob ?? (isBakedSave ? roomBlob : string.Empty); int bakedAssetCount = currentSave?.BakedUnityAssets?.Count ?? 0; return new { subRoomId = subRoom.SubRoomId, name = subRoom.Name, maxPlayers = subRoom.MaxPlayers, accessibility = subRoom.Accessibility.ToString(), isSandbox = subRoom.IsSandbox, unitySceneId = subRoom.UnitySceneId, currentSaveId = currentSave?.SubRoomDataSaveId ?? subRoom.SubRoomDataSaveId, hasData = !string.IsNullOrWhiteSpace(roomBlob) && (isBakedSave || !string.IsNullOrWhiteSpace(metadataBlob)), roomBlob, metadataBlob, roomBlobExists = AdminBlobExists(roomBlob), metadataBlobExists = isBakedSave ? AdminBlobExists(roomBlob) : AdminBlobExists(metadataBlob), unityAssetId = currentSave?.UnityAssetId, bakedAssetCount, bakedTargets = currentSave?.BakedUnityAssets? .Select(asset => asset.Target) .Distinct() .OrderBy(target => target) .ToArray() ?? Array.Empty(), permissionCount = subRoom.Permissions?.Count ?? 0 }; }) .ToList(), bans = RoomDB.GetActiveBans(room.RoomId) .OrderByDescending(ban => ban.BannedAt) .Select(ban => new { ban.RoomBanId, ban.AccountId, player = PlayerIdentity(ban.AccountId), ban.BannedByAccountId, ban.Reason, ban.BannedAt }) .ToList() }; } private static void LogAdminRoomAction( PlayerDBClasses.FullPlayer admin, RoomDBClasses.Room room, string action) { string actor = admin.Player?.Username ?? admin.Player?.DisplayName ?? admin.PlayerId.ToString(); Console.WriteLine( $"[ADMIN ROOM] admin={admin.PlayerId} room={room.RoomId} action={action}"); DiscordLogger.Log( $"🛠️ **Admin Room** — `@{actor}` (`{admin.PlayerId}`) {action} " + $"in `^{room.Name}` (`{room.RoomId}`)"); } private static object ToAdminShopPayload(APIController.StorefrontAdminInfo shop) => new { rotationNonce = shop.RotationNonce, nextRefresh = shop.NextRefresh, customItems = shop.CustomItems.Select(ToAdminShopItemPayload).ToList() }; private static object ToAdminShopItemPayload(APIController.StorefrontAdminItem item) => new { skuId = item.SkuId, avatarItemId = item.AvatarItemId, friendlyName = item.FriendlyName, avatarItemDesc = item.AvatarItemDesc, consumableItemDesc = item.ConsumableItemDesc, equipmentPrefabName = item.EquipmentPrefabName, equipmentModificationGuid = item.EquipmentModificationGuid, thumbnailImage = item.ThumbnailImage, rarity = item.Rarity, price = item.Price }; private static bool IsDeveloper(PlayerDBClasses.FullPlayer? account) => account?.PlayerRoles?.Contains(PlayerDBClasses.PlayerRoles.Developer) == true; [HttpPost("auth/logout")] public IActionResult Logout() { Response.Cookies.Delete("recnet_session", new CookieOptions { Secure = true, SameSite = SameSiteMode.Strict, Path = "/recnet" }); return Ok(new { success = true }); } [HttpGet("auth/me")] public IActionResult Me() { var account = AuthStuff.GetCurrentPlayer(Request); return account?.Player == null ? Unauthorized() : Ok(ToSession(account)); } [HttpGet("account/settings")] public IActionResult GetSettings() { var account = AuthStuff.GetCurrentPlayer(Request); if (account?.Player == null) return Unauthorized(new { error = "Log in to view settings." }); return Ok(new { accountId = account.PlayerId, username = account.Player.Username, displayName = account.Player.DisplayName, bio = account.Player.Bio, email = account.Player.Email, profileImage = account.Player.ProfileImage, bannerImage = account.Player.BannerImage, availableUsernameChanges = account.Player.AvailableUsernameChanges }); } [HttpGet("account/cheer-badge")] public IActionResult GetCheerBadgeSettings() { var account = AuthStuff.GetCurrentPlayer(Request); if (account?.Player == null) return Unauthorized(new { error = "Log in to choose a profile badge." }); if (account.PlayerRoles.Contains(PlayerDBClasses.PlayerRoles.Developer) && PlayerDB.GrantDeveloperCheerAccess( account, selectDeveloperBadge: false)) { PlayerDB.Players.Update(account); } return Ok(ToCheerBadgePayload(account)); } [HttpPut("account/cheer-badge")] public IActionResult UpdateCheerBadge( [FromBody] CheerBadgeSelection request) { var account = AuthStuff.GetCurrentPlayer(Request); if (account?.Player == null) return Unauthorized(new { error = "Log in to choose a profile badge." }); if (!Enum.IsDefined(typeof(PlayerDBClasses.CheerCategory), request.Badge)) return BadRequest(new { error = "Choose a valid profile badge." }); bool isDeveloper = account.PlayerRoles.Contains( PlayerDBClasses.PlayerRoles.Developer); var badge = (PlayerDBClasses.CheerCategory)request.Badge; if (badge == PlayerDBClasses.CheerCategory.RecRoomDeveloper && !isDeveloper) { return StatusCode(403); } if (isDeveloper) PlayerDB.GrantDeveloperCheerAccess(account, selectDeveloperBadge: false); account.Player.Reputation ??= new PlayerDBClasses.Reputation(); int earnedCheers = badge switch { PlayerDBClasses.CheerCategory.General => account.Player.Reputation.CheerGeneral, PlayerDBClasses.CheerCategory.Helpful => account.Player.Reputation.CheerHelpful, PlayerDBClasses.CheerCategory.Sportmanship => account.Player.Reputation.CheerSportsman, PlayerDBClasses.CheerCategory.GreatHost => account.Player.Reputation.CheerGreatHost, PlayerDBClasses.CheerCategory.Creative => account.Player.Reputation.CheerCreative, PlayerDBClasses.CheerCategory.RecRoomDeveloper when isDeveloper => 1, _ => 0 }; if (earnedCheers <= 0) return BadRequest(new { error = "You have not unlocked that badge yet." }); account.Player.Reputation.IsCheerful = true; account.Player.Reputation.SelectedCheer = badge; PlayerDB.Players.Update(account); return Ok(ToCheerBadgePayload(account)); } [HttpPost("account/profile-image")] [RequestSizeLimit(10 * 1024 * 1024)] public async Task UploadProfileImage() { var account = AuthStuff.GetCurrentPlayer(Request); if (account?.Player == null) return Unauthorized(new { error = "Log in to upload a profile picture." }); if (!Request.HasFormContentType) return BadRequest(new { error = "Choose an image file to upload." }); var form = await Request.ReadFormAsync(); var file = form.Files.FirstOrDefault(); if (file == null || file.Length == 0) return BadRequest(new { error = "Choose an image file to upload." }); if (file.Length > 10 * 1024 * 1024) return BadRequest(new { error = "Profile pictures must be 10 MB or smaller." }); byte[] bytes; using (var buffer = new MemoryStream()) { await file.CopyToAsync(buffer); bytes = buffer.ToArray(); } SixLabors.ImageSharp.Formats.IImageFormat? format; SixLabors.ImageSharp.ImageInfo? info; try { format = SixLabors.ImageSharp.Image.DetectFormat(bytes); info = SixLabors.ImageSharp.Image.Identify(bytes); } catch { return BadRequest(new { error = "That file is not a valid image." }); } if (format == null || info == null || info.Width > 4096 || info.Height > 4096 || (long)info.Width * info.Height > 16_777_216) return BadRequest(new { error = "Images cannot be larger than 4096 x 4096 pixels." }); string extension = format.FileExtensions.FirstOrDefault()?.ToLowerInvariant() ?? "png"; if (extension is not ("png" or "jpg" or "jpeg" or "webp" or "gif" or "bmp")) return BadRequest(new { error = "Use a PNG, JPG, WebP, GIF, or BMP image." }); string folder = Path.Combine(Program.dataDir, "Images", "CustomPFPS"); Directory.CreateDirectory(folder); string fileName = $"profile_{account.PlayerId}_{Guid.NewGuid():N}.{extension}"; await System.IO.File.WriteAllBytesAsync(Path.Combine(folder, fileName), bytes); string relativePath = $"CustomPFPS/{fileName}"; account.Player.ProfileImage = relativePath; PlayerDB.Players.Update(account); await NotiController.NotifyPlayerProfileUpdatedAsync(account.PlayerId); return Ok(new { success = true, path = relativePath, url = ImageUrl(relativePath), session = ToSession(account) }); } [HttpPost("account/banner-image")] [RequestSizeLimit(10 * 1024 * 1024)] public async Task UploadBannerImage() { var account = AuthStuff.GetCurrentPlayer(Request); if (account?.Player == null) return Unauthorized(new { error = "Log in to upload a banner." }); if (!Request.HasFormContentType) return BadRequest(new { error = "Choose an image file to upload." }); var form = await Request.ReadFormAsync(); var file = form.Files.FirstOrDefault(); if (file == null || file.Length == 0) return BadRequest(new { error = "Choose an image file to upload." }); if (file.Length > 10 * 1024 * 1024) return BadRequest(new { error = "Banners must be 10 MB or smaller." }); byte[] bytes; using (var buffer = new MemoryStream()) { await file.CopyToAsync(buffer); bytes = buffer.ToArray(); } SixLabors.ImageSharp.Formats.IImageFormat? format; SixLabors.ImageSharp.ImageInfo? info; try { format = SixLabors.ImageSharp.Image.DetectFormat(bytes); info = SixLabors.ImageSharp.Image.Identify(bytes); } catch { return BadRequest(new { error = "That file is not a valid image." }); } if (format == null || info == null || info.Width > 8192 || info.Height > 4096 || (long)info.Width * info.Height > 16_777_216) return BadRequest(new { error = "Banners cannot be larger than 8192 x 4096 pixels." }); string extension = format.FileExtensions.FirstOrDefault()?.ToLowerInvariant() ?? "png"; if (extension is not ("png" or "jpg" or "jpeg" or "webp" or "gif" or "bmp")) return BadRequest(new { error = "Use a PNG, JPG, WebP, GIF, or BMP image." }); string folder = Path.Combine(Program.dataDir, "Images", "CustomPFPS"); Directory.CreateDirectory(folder); string fileName = $"banner_{account.PlayerId}_{Guid.NewGuid():N}.{extension}"; await System.IO.File.WriteAllBytesAsync(Path.Combine(folder, fileName), bytes); string relativePath = $"CustomPFPS/{fileName}"; account.Player.BannerImage = relativePath; PlayerDB.Players.Update(account); await NotiController.NotifyPlayerProfileUpdatedAsync(account.PlayerId); return Ok(new { success = true, path = relativePath, url = ImageUrl(relativePath) }); } [HttpPut("account/settings")] [RequestSizeLimit(64 * 1024)] public async Task UpdateSettings([FromBody] RecNetSettings request) { var account = AuthStuff.GetCurrentPlayer(Request); if (account?.Player == null) return Unauthorized(new { error = "Log in to change settings." }); string displayName = request.DisplayName?.Trim() ?? string.Empty; string username = request.Username?.Trim().TrimStart('@') ?? string.Empty; string bio = request.Bio?.Trim() ?? string.Empty; string email = request.Email?.Trim() ?? string.Empty; if (displayName.Length is < 1 or > 32 || displayName.Any(char.IsControl)) return BadRequest(new { error = "Display name must be between 1 and 32 characters." }); if (bio.Length > 500 || bio.Any(ch => ch == '\0')) return BadRequest(new { error = "Bio must be 500 characters or fewer." }); if (!string.IsNullOrEmpty(email)) { bool validEmail = false; if (email.Length <= 254) { try { validEmail = string.Equals( new System.Net.Mail.MailAddress(email).Address, email, StringComparison.OrdinalIgnoreCase); } catch (FormatException) { } } if (!validEmail) return BadRequest(new { error = "Enter a valid email address." }); } bool usernameChanged = !string.Equals(account.Player.Username, username, StringComparison.OrdinalIgnoreCase); if (usernameChanged) { if (username.Length is < 3 or > 20 || username.Any(ch => !char.IsLetterOrDigit(ch) && ch != '_')) return BadRequest(new { error = "Username must be 3-20 letters, numbers, or underscores." }); if (account.Player.AvailableUsernameChanges <= 0) return BadRequest(new { error = "You have no username changes remaining." }); bool inUse = PlayerDB.Players.FindAll().Any(x => x.PlayerId != account.PlayerId && string.Equals(x.Player?.Username, username, StringComparison.OrdinalIgnoreCase)); if (inUse) return Conflict(new { error = "That username is already taken." }); account.Player.AvailableUsernameChanges--; } account.Player.DisplayName = displayName; account.Player.Username = username; account.Player.Bio = bio; account.Player.Email = email; account.Player.ProfileImage = NormalizeImagePath(request.ProfileImage) ?? "DefaultPFP.png"; account.Player.BannerImage = string.IsNullOrWhiteSpace(request.BannerImage) ? null : NormalizeImagePath(request.BannerImage); PlayerDB.Players.Update(account); await NotiController.NotifyPlayerProfileUpdatedAsync(account.PlayerId); return Ok(new { success = true, session = ToSession(account) }); } [HttpPut("account/password")] [RequestSizeLimit(16 * 1024)] public IActionResult ChangePassword([FromBody] RecNetPasswordChange request) { var account = AuthStuff.GetCurrentPlayer(Request); if (account == null) return Unauthorized(new { error = "Log in to change your password." }); if (!PasswordSecurity.Verify(request.CurrentPassword, account.Password, out _)) return Unauthorized(new { error = "Your current password is incorrect." }); if (string.IsNullOrEmpty(request.NewPassword) || request.NewPassword.Length < PasswordSecurity.MinPasswordLength || request.NewPassword.Length > PasswordSecurity.MaxPasswordLength) return BadRequest(new { error = "New password must be at least 8 characters." }); account.Password = PasswordSecurity.Hash(request.NewPassword); PlayerDB.Players.Update(account); return Ok(new { success = true }); } [HttpDelete("account")] [RequestSizeLimit(16 * 1024)] public IActionResult DeleteAccount([FromBody] RecNetDeleteAccount request) { var account = AuthStuff.GetCurrentPlayer(Request); if (account == null) return Unauthorized(new { error = "Log in to delete your account." }); if (!string.Equals(request.Confirmation, "DELETE", StringComparison.Ordinal)) return BadRequest(new { error = "Type DELETE exactly to confirm." }); if (!PasswordSecurity.Verify(request.Password, account.Password, out _)) return Unauthorized(new { error = "Your password is incorrect." }); RecNetDB.PhotoCheers.DeleteMany(x => x.AccountId == account.PlayerId); RecNetDB.PhotoComments.DeleteMany(x => x.AccountId == account.PlayerId); RecNetDB.ModerationLocks.Delete(account.PlayerId); RecNetDB.ChallengeProgresses.DeleteMany(x => x.AccountId == account.PlayerId); RoomDB.DeletePlayerDorms(account.PlayerId); bool deleted = PlayerDB.Players.Delete(account.PlayerId); if (!deleted) return StatusCode(500, new { error = "The account could not be deleted." }); Response.Cookies.Delete("recnet_session", new CookieOptions { Secure = true, SameSite = SameSiteMode.Strict, Path = "/recnet" }); return Ok(new { success = true }); } [HttpGet("rooms")] public IActionResult GetRooms([FromQuery] string? search = null) { string term = search?.Trim() ?? string.Empty; var rooms = RoomDB.Rooms.FindAll() .Where(IsPublishedRecNetRoom) .Where(x => string.IsNullOrEmpty(term) || (x.Name?.Contains(term, StringComparison.OrdinalIgnoreCase) ?? false) || (x.Description?.Contains(term, StringComparison.OrdinalIgnoreCase) ?? false) || (x.Tags?.Any(t => t.Tag.Contains(term, StringComparison.OrdinalIgnoreCase)) ?? false)) .OrderByDescending(x => x.Stats?.VisitCount ?? 0) .ThenBy(x => x.Name) .Take(250) .Select(ToRoomSummary) .ToList(); return Ok(rooms); } [HttpGet("rooms/{roomId:long}")] public IActionResult GetRoom(long roomId) { var room = RoomDB.Rooms.FindById(roomId); return room == null || !IsPublishedRecNetRoom(room) ? NotFound(new { error = "Room not found." }) : Ok(ToRoomSummary(room)); } [HttpGet("announcements")] public IActionResult GetAnnouncements() { var announcements = RecNetDB.Announcements.FindAll() .Where(x => x.Published) .OrderByDescending(x => x.Pinned) .ThenByDescending(x => x.UpdatedAt) .Take(25) .Select(ToAnnouncementSummary) .ToList(); return Ok(announcements); } [HttpGet("admin/announcements")] public IActionResult GetAdminAnnouncements() { var admin = AuthStuff.GetCurrentPlayer(Request); if (!IsAdmin(admin)) return StatusCode(403); return Ok(RecNetDB.Announcements.FindAll() .OrderByDescending(x => x.Pinned) .ThenByDescending(x => x.UpdatedAt) .Select(ToAnnouncementSummary) .ToList()); } [HttpPost("admin/announcements")] public IActionResult CreateAnnouncement( [FromBody] AdminAnnouncementRequest request) { var admin = AuthStuff.GetCurrentPlayer(Request); if (!IsAdmin(admin)) return StatusCode(403); if (!TryValidateAnnouncement( request, out string title, out string bodyMarkdown, out string kind, out string error)) return BadRequest(new { error }); DateTime now = DateTime.UtcNow; var announcement = new RecNetDB.Announcement { Title = title, BodyMarkdown = bodyMarkdown, Kind = kind, Pinned = request.Pinned, Published = request.Published, CreatedByAccountId = admin!.PlayerId, CreatedAt = now, UpdatedAt = now }; RecNetDB.Announcements.Insert(announcement); return Ok(new { success = true, announcement = ToAnnouncementSummary(announcement) }); } [HttpPut("admin/announcements/{announcementId:long}")] public IActionResult UpdateAnnouncement( long announcementId, [FromBody] AdminAnnouncementRequest request) { var admin = AuthStuff.GetCurrentPlayer(Request); if (!IsAdmin(admin)) return StatusCode(403); var announcement = RecNetDB.Announcements.FindById(announcementId); if (announcement == null) return NotFound(new { error = "Announcement not found." }); if (!TryValidateAnnouncement( request, out string title, out string bodyMarkdown, out string kind, out string error)) return BadRequest(new { error }); announcement.Title = title; announcement.BodyMarkdown = bodyMarkdown; announcement.Kind = kind; announcement.Pinned = request.Pinned; announcement.Published = request.Published; announcement.UpdatedAt = DateTime.UtcNow; RecNetDB.Announcements.Update(announcement); return Ok(new { success = true, announcement = ToAnnouncementSummary(announcement) }); } [HttpDelete("admin/announcements/{announcementId:long}")] public IActionResult DeleteAnnouncement(long announcementId) { var admin = AuthStuff.GetCurrentPlayer(Request); if (!IsAdmin(admin)) return StatusCode(403); return RecNetDB.Announcements.Delete(announcementId) ? Ok(new { success = true }) : NotFound(new { error = "Announcement not found." }); } private static bool TryValidateAnnouncement( AdminAnnouncementRequest request, out string title, out string bodyMarkdown, out string kind, out string error) { title = request.Title?.Trim() ?? string.Empty; bodyMarkdown = request.BodyMarkdown?.Trim() ?? string.Empty; kind = (request.Kind?.Trim().ToLowerInvariant()) switch { "update" => "update", "warning" => "warning", "maintenance" => "maintenance", _ => "info" }; error = string.Empty; if (title.Length is < 1 or > 100) { error = "Title must be 1-100 characters."; return false; } if (bodyMarkdown.Length is < 1 or > 12000) { error = "Announcement body must be 1-12,000 characters."; return false; } return true; } private static object ToAnnouncementSummary( RecNetDB.Announcement announcement) { var author = PlayerDB.Players.FindById( announcement.CreatedByAccountId); string authorName = author?.Player?.DisplayName ?? author?.Player?.Username ?? $"Account #{announcement.CreatedByAccountId}"; return new { id = announcement.Id, title = announcement.Title, bodyMarkdown = announcement.BodyMarkdown, kind = announcement.Kind, pinned = announcement.Pinned, published = announcement.Published, createdByAccountId = announcement.CreatedByAccountId, authorName, createdAt = announcement.CreatedAt, updatedAt = announcement.UpdatedAt }; } [HttpGet("events")] public IActionResult GetEvents() { var now = DateTime.UtcNow; var events = RecNetDB.Events.FindAll() .Where(x => x.EndsAt == null || x.EndsAt >= now) .OrderByDescending(x => x.Pinned) .ThenBy(x => x.StartsAt) .Select(ToEventSummary) .ToList(); return Ok(events); } [HttpGet("admin/events")] public IActionResult GetAdminEvents() { var admin = AuthStuff.GetCurrentPlayer(Request); if (!IsAdmin(admin)) return StatusCode(403); var events = RecNetDB.Events.FindAll() .OrderByDescending(x => x.Pinned) .ThenByDescending(x => x.StartsAt) .Select(ToEventSummary) .ToList(); return Ok(events); } [HttpPost("admin/events/image")] [RequestSizeLimit(10 * 1024 * 1024)] public async Task UploadEventImage() { var admin = AuthStuff.GetCurrentPlayer(Request); if (!IsAdmin(admin)) return StatusCode(403); if (!Request.HasFormContentType) return BadRequest(new { error = "Choose an image file to upload." }); var form = await Request.ReadFormAsync(); var file = form.Files.FirstOrDefault(); if (file == null || file.Length == 0) return BadRequest(new { error = "Choose an image file to upload." }); if (file.Length > 10 * 1024 * 1024) return BadRequest(new { error = "Event images must be 10 MB or smaller." }); byte[] bytes; using (var buffer = new MemoryStream()) { await file.CopyToAsync(buffer, HttpContext.RequestAborted); bytes = buffer.ToArray(); } SixLabors.ImageSharp.Formats.IImageFormat? format; SixLabors.ImageSharp.ImageInfo? info; try { format = SixLabors.ImageSharp.Image.DetectFormat(bytes); info = SixLabors.ImageSharp.Image.Identify(bytes); } catch { return BadRequest(new { error = "That file is not a valid image." }); } if (format == null || info == null || info.Width <= 0 || info.Height <= 0 || info.Width > 8192 || info.Height > 4096 || (long)info.Width * info.Height > 20_000_000) { return BadRequest(new { error = "Event images cannot exceed 8192 x 4096 or 20 megapixels." }); } string extension = format.FileExtensions.FirstOrDefault()?.ToLowerInvariant() ?? string.Empty; if (extension is not ("png" or "jpg" or "jpeg" or "webp" or "gif" or "bmp")) return BadRequest(new { error = "Use a PNG, JPG, WebP, GIF, or BMP image." }); string folder = Path.Combine(Program.dataDir, "Images", "EventImages"); Directory.CreateDirectory(folder); string fileName = $"event_{admin!.PlayerId}_{Guid.NewGuid():N}.{extension}"; await System.IO.File.WriteAllBytesAsync( Path.Combine(folder, fileName), bytes, HttpContext.RequestAborted); string relativePath = $"EventImages/{fileName}"; return Ok(new { success = true, path = relativePath, url = ImageUrl(relativePath) }); } [HttpPost("admin/events")] public IActionResult CreateEvent([FromBody] AdminEventRequest request) { var admin = AuthStuff.GetCurrentPlayer(Request); if (!IsAdmin(admin)) return StatusCode(403); if (!TryValidateEvent(request, out string title, out string description, out DateTime startsAt, out DateTime? endsAt, out string? imagePath, out string error)) return BadRequest(new { error }); var evt = new RecNetDB.Event { Title = title, Description = description, ImageName = imagePath, StartsAt = startsAt, EndsAt = endsAt, Pinned = request.Pinned, CreatedByAccountId = admin!.PlayerId, CreatedAt = DateTime.UtcNow }; RecNetDB.Events.Insert(evt); return Ok(new { success = true, @event = ToEventSummary(evt) }); } [HttpPut("admin/events/{eventId:long}")] public IActionResult UpdateEvent(long eventId, [FromBody] AdminEventRequest request) { var admin = AuthStuff.GetCurrentPlayer(Request); if (!IsAdmin(admin)) return StatusCode(403); var evt = RecNetDB.Events.FindById(eventId); if (evt == null) return NotFound(new { error = "Event not found." }); if (!TryValidateEvent(request, out string title, out string description, out DateTime startsAt, out DateTime? endsAt, out string? imagePath, out string error)) return BadRequest(new { error }); string? previousImage = evt.ImageName; evt.Title = title; evt.Description = description; evt.ImageName = imagePath; evt.StartsAt = startsAt; evt.EndsAt = endsAt; evt.Pinned = request.Pinned; RecNetDB.Events.Update(evt); if (!string.Equals(previousImage, imagePath, StringComparison.OrdinalIgnoreCase)) DeleteUnusedEventImage(previousImage); return Ok(new { success = true, @event = ToEventSummary(evt) }); } [HttpDelete("admin/events/{eventId:long}")] public IActionResult DeleteEvent(long eventId) { var admin = AuthStuff.GetCurrentPlayer(Request); if (!IsAdmin(admin)) return StatusCode(403); var evt = RecNetDB.Events.FindById(eventId); if (evt == null || !RecNetDB.Events.Delete(eventId)) return NotFound(new { error = "Event not found." }); DeleteUnusedEventImage(evt.ImageName); return Ok(new { success = true }); } private static bool TryValidateEvent( AdminEventRequest request, out string title, out string description, out DateTime startsAt, out DateTime? endsAt, out string? imagePath, out string error) { title = request.Title?.Trim() ?? string.Empty; description = request.Description?.Trim() ?? string.Empty; startsAt = default; endsAt = null; imagePath = NormalizeImagePath(request.ImageName); error = string.Empty; if (title.Length is < 1 or > 80) { error = "Title must be 1-80 characters."; return false; } if (description.Length > 1000) { error = "Description must be 1000 characters or fewer."; return false; } if (!string.IsNullOrWhiteSpace(request.ImageName) && !IsStoredImage(imagePath)) { error = "Upload a valid event image first."; return false; } if (request.StartsAt == default) { error = "Choose a start date and time."; return false; } startsAt = request.StartsAt.ToUniversalTime(); if (request.EndsAt.HasValue && request.EndsAt.Value != default) { DateTime end = request.EndsAt.Value.ToUniversalTime(); if (end <= startsAt) { error = "End time must be after the start time."; return false; } endsAt = end; } return true; } private static bool IsStoredImage(string? imagePath) { if (string.IsNullOrWhiteSpace(imagePath)) return false; if (!imagePath.StartsWith("EventImages/", StringComparison.OrdinalIgnoreCase)) return false; string root = Path.GetFullPath(Path.Combine(Program.dataDir, "Images", "EventImages")); string fullPath = Path.GetFullPath(Path.Combine( Program.dataDir, "Images", imagePath.Replace('/', Path.DirectorySeparatorChar))); return fullPath.StartsWith( root + Path.DirectorySeparatorChar, StringComparison.OrdinalIgnoreCase) && System.IO.File.Exists(fullPath) && ImageExtensions.Contains(Path.GetExtension(fullPath)); } private static void DeleteUnusedEventImage(string? imagePath) { string? normalized = NormalizeImagePath(imagePath); if (normalized == null || !normalized.StartsWith("EventImages/", StringComparison.OrdinalIgnoreCase) || RecNetDB.Events.Exists(evt => evt.ImageName == normalized)) return; string root = Path.GetFullPath(Path.Combine(Program.dataDir, "Images", "EventImages")); string fullPath = Path.GetFullPath(Path.Combine( Program.dataDir, "Images", normalized.Replace('/', Path.DirectorySeparatorChar))); if (fullPath.StartsWith( root + Path.DirectorySeparatorChar, StringComparison.OrdinalIgnoreCase) && System.IO.File.Exists(fullPath)) { System.IO.File.Delete(fullPath); } } private static object ToEventSummary(RecNetDB.Event evt) => new { id = evt.Id, title = evt.Title, description = evt.Description, image = string.IsNullOrWhiteSpace(evt.ImageName) ? null : ImageUrl(evt.ImageName), startsAt = evt.StartsAt, endsAt = evt.EndsAt, pinned = evt.Pinned, createdAt = evt.CreatedAt }; public class AdminAnnouncementRequest { public string? Title { get; set; } public string? BodyMarkdown { get; set; } public string? Kind { get; set; } public bool Pinned { get; set; } public bool Published { get; set; } = true; } public class AdminEventRequest { public string? Title { get; set; } public string? Description { get; set; } public string? ImageName { get; set; } public DateTime StartsAt { get; set; } public DateTime? EndsAt { get; set; } public bool Pinned { get; set; } } private static bool IsPublishedRecNetRoom(RoomDBClasses.Room room) { return !room.IsDorm && room.State == RoomDBClasses.RoomState.Active && room.Accessibility == RoomDBClasses.RoomAccessibility.Public; } private static object ToRoomSummary(RoomDBClasses.Room room) { var creator = PlayerDB.Players.FindById(room.CreatorAccountId); return new { roomId = room.RoomId, name = room.Name, description = room.Description, image = ImageUrl(room.ImageName), creatorAccountId = room.CreatorAccountId, creatorName = creator?.Player?.DisplayName ?? creator?.Player?.Username ?? $"Player {room.CreatorAccountId}", maxPlayers = room.MaxPlayers, accessibility = room.Accessibility.ToString(), state = room.State?.ToString(), isDorm = room.IsDorm, isRRO = room.IsRRO, supportsJuniors = room.SupportsJuniors, createdAt = room.CreatedAt, tags = room.Tags?.Select(x => x.Tag).ToArray() ?? Array.Empty(), subRooms = room.SubRooms?.Select(x => (object)new { x.SubRoomId, x.Name, x.MaxPlayers }).ToArray() ?? Array.Empty(), stats = new { cheers = room.Stats?.CheerCount ?? 0, favorites = room.Stats?.FavoriteCount ?? 0, visitors = room.Stats?.VisitorCount ?? 0, visits = room.Stats?.VisitCount ?? 0 } }; } private static object ToSession(PlayerDBClasses.FullPlayer account) => new { accountId = account.PlayerId, username = account.Player!.Username, displayName = account.Player.DisplayName, profileImage = ImageUrl(account.Player.ProfileImage), isAdmin = IsAdmin(account), isDeveloper = IsDeveloper(account), isModerator = IsModerator(account) }; private static object ToCheerBadgePayload( PlayerDBClasses.FullPlayer account) { account.Player!.Reputation ??= new PlayerDBClasses.Reputation(); var reputation = account.Player.Reputation; bool isDeveloper = account.PlayerRoles.Contains( PlayerDBClasses.PlayerRoles.Developer); var badges = new List { new { value = 0, name = "General", count = reputation.CheerGeneral, unlocked = reputation.CheerGeneral > 0 }, new { value = 40, name = "Creative", count = reputation.CheerCreative, unlocked = reputation.CheerCreative > 0 }, new { value = 30, name = "Great Host", count = reputation.CheerGreatHost, unlocked = reputation.CheerGreatHost > 0 }, new { value = 20, name = "Sportsmanship", count = reputation.CheerSportsman, unlocked = reputation.CheerSportsman > 0 }, new { value = 10, name = "Helpful", count = reputation.CheerHelpful, unlocked = reputation.CheerHelpful > 0 } }; if (isDeveloper) { badges.Add(new { value = 9000, name = "Rec Room Developer", count = 1, unlocked = true }); } return new { selectedBadge = (int)reputation.SelectedCheer, isDeveloper, badges }; } [HttpGet("users")] public IActionResult GetUsers([FromQuery] string? search = null) { string term = search?.Trim() ?? string.Empty; var users = PlayerDB.Players.FindAll() .Where(x => x.Player != null) .Where(x => string.IsNullOrEmpty(term) || (x.Player!.Username?.Contains(term, StringComparison.OrdinalIgnoreCase) ?? false) || (x.Player.DisplayName?.Contains(term, StringComparison.OrdinalIgnoreCase) ?? false)) .OrderBy(x => x.Player!.DisplayName ?? x.Player.Username) .Take(100) .Select(ToUserSummary) .ToList(); return Ok(users); } [HttpGet("users/{playerId:long}")] public IActionResult GetUser(long playerId) { var account = PlayerDB.Players.FindById(playerId); if (account?.Player == null) return NotFound(new { error = "User not found" }); var photos = GetOwnedPhotos(account) .OrderByDescending(x => x.takenAt) .ToList(); return Ok(new { accountId = account.PlayerId, username = account.Player.Username, displayName = account.Player.DisplayName, bio = account.Player.Bio, level = account.Player.Level, createdAt = account.Player.CreatedAt, profileImage = ImageUrl(account.Player.ProfileImage), bannerImage = string.IsNullOrWhiteSpace(account.Player.BannerImage) ? null : ImageUrl(account.Player.BannerImage), verified = IsVerified(account), hasRRPlus = HasRRPlus(account), roles = account.PlayerRoles.Select(x => x.ToString()), photoCount = photos.Count, photos }); } [HttpGet("photos/newest")] public IActionResult GetNewestPhotos([FromQuery] int take = 30) { take = Math.Clamp(take, 1, 100); var owners = PlayerDB.Players.FindAll() .Where(x => x.Player != null) .SelectMany(account => GetOwnedPhotos(account)) .GroupBy(x => x.path, StringComparer.OrdinalIgnoreCase) .ToDictionary(x => x.Key, x => x.First(), StringComparer.OrdinalIgnoreCase); var photos = EnumeratePublicPhotos() .Select(photo => owners.TryGetValue(photo.path, out var owned) ? photo with { owner = owned.owner } : photo) .Concat(owners.Values) .GroupBy(photo => photo.path, StringComparer.OrdinalIgnoreCase) .Select(group => group.OrderByDescending(photo => photo.owner != null).First()) .OrderByDescending(x => x.takenAt) .Take(take) .ToList(); return Ok(photos); } [HttpGet("photos/detail")] public IActionResult GetPhotoDetail([FromQuery] string path) { if (!TryResolvePublicPhoto(path, out string normalized, out string fullPath)) return NotFound(new { error = "Photo not found." }); var current = AuthStuff.GetCurrentPlayer(Request); var owner = PlayerDB.Players.FindAll() .Where(x => x.Player != null) .SelectMany(GetOwnedPhotos) .FirstOrDefault(x => string.Equals(x.path, normalized, StringComparison.OrdinalIgnoreCase)) ?.owner; var comments = RecNetDB.PhotoComments.Find(x => x.PhotoPath == normalized) .OrderBy(x => x.CreatedAt) .Select(x => { var author = PlayerDB.Players.FindById(x.AccountId); return new { id = x.Id, text = x.Text, createdAt = x.CreatedAt, author = author?.Player == null ? null : new RecNetOwner( author.PlayerId, author.Player.Username, author.Player.DisplayName, ImageUrl(author.Player.ProfileImage), IsVerified(author), HasRRPlus(author)) }; }).ToList(); int cheerCount = RecNetDB.CountPhotoCheers(normalized); bool cheered = current != null && RecNetDB.HasPhotoCheer(normalized, current.PlayerId); return Ok(new { path = normalized, url = ImageUrl(normalized), takenAt = System.IO.File.GetLastWriteTimeUtc(fullPath), owner, cheerCount, cheered, comments }); } [HttpPost("photos/cheer")] [RequestSizeLimit(16 * 1024)] public IActionResult TogglePhotoCheer([FromBody] PhotoPathRequest request) { var account = AuthStuff.GetCurrentPlayer(Request); if (account == null) return Unauthorized(new { error = "Log in to cheer photos." }); if (!TryResolvePublicPhoto(request.Path, out string normalized, out _)) return NotFound(new { error = "Photo not found." }); bool cheered = !RecNetDB.HasPhotoCheer(normalized, account.PlayerId); RecNetDB.SetPhotoCheer(normalized, account.PlayerId, cheered); return Ok(new { cheered, isCheered = cheered, cheerCount = RecNetDB.CountPhotoCheers(normalized) }); } [HttpPost("photos/comments")] [RequestSizeLimit(16 * 1024)] public IActionResult AddPhotoComment([FromBody] PhotoCommentRequest request) { var account = AuthStuff.GetCurrentPlayer(Request); if (account?.Player == null) return Unauthorized(new { error = "Log in to comment." }); if (!TryResolvePublicPhoto(request.Path, out string normalized, out _)) return NotFound(new { error = "Photo not found." }); string text = request.Text?.Trim() ?? string.Empty; if (text.Length is < 1 or > 300 || text.Any(ch => ch == '\0')) return BadRequest(new { error = "Comments must be between 1 and 300 characters." }); var comment = new RecNetDB.PhotoComment { PhotoPath = normalized, AccountId = account.PlayerId, Text = text, CreatedAt = DateTime.UtcNow }; RecNetDB.PhotoComments.Insert(comment); return Ok(new { success = true, id = comment.Id }); } private static bool TryResolvePublicPhoto(string? path, out string normalized, out string fullPath) { normalized = NormalizeImagePath(path) ?? string.Empty; fullPath = string.Empty; if (string.IsNullOrEmpty(normalized)) return false; string first = normalized.Split('/', StringSplitOptions.RemoveEmptyEntries).FirstOrDefault() ?? string.Empty; if (first is not ("PlayerImages" or "PolaroidImages" or "WhereTaken")) return false; string root = Path.GetFullPath(Path.Combine(Program.dataDir, "Images")); fullPath = Path.GetFullPath(Path.Combine(root, normalized.Replace('/', Path.DirectorySeparatorChar))); if (!fullPath.StartsWith(root + Path.DirectorySeparatorChar, StringComparison.OrdinalIgnoreCase) || !System.IO.File.Exists(fullPath) || !ImageExtensions.Contains(Path.GetExtension(fullPath))) { return false; } string photoPath = normalized; var indexed = RecNetDB.SavedImages.FindOne(image => image.PhotoPath == photoPath); if (indexed != null) return indexed.SavedImageType == 1 && indexed.Accessibility == 1; if (first is "PolaroidImages" or "WhereTaken") return true; return PlayerDB.Players.FindAll().Any(account => (account.Player?.PlayerExtra?.SavedAvatars ?? new List()) .Any(outfit => !HasOutfitState(outfit) && string.Equals( NormalizeImagePath(outfit.PreviewImageName), photoPath, StringComparison.OrdinalIgnoreCase))); } private static object ToUserSummary(PlayerDBClasses.FullPlayer account) { var player = account.Player!; return new { accountId = account.PlayerId, username = player.Username, displayName = player.DisplayName, bio = player.Bio, level = player.Level, profileImage = ImageUrl(player.ProfileImage), verified = IsVerified(account), hasRRPlus = HasRRPlus(account), photoCount = GetOwnedPhotos(account).Count() }; } private static IEnumerable GetOwnedPhotos(PlayerDBClasses.FullPlayer account) { var owner = new RecNetOwner( account.PlayerId, account.Player?.Username, account.Player?.DisplayName, ImageUrl(account.Player?.ProfileImage), IsVerified(account), HasRRPlus(account)); var returned = new HashSet(StringComparer.OrdinalIgnoreCase); foreach (var image in RecNetDB.SavedImages.Find(savedImage => savedImage.AccountId == account.PlayerId && savedImage.SavedImageType == 1 && savedImage.Accessibility == 1)) { string? path = NormalizeImagePath(image.PhotoPath); if (path == null) continue; string fullPath = Path.Combine(Program.dataDir, "Images", path.Replace('/', Path.DirectorySeparatorChar)); if (!System.IO.File.Exists(fullPath) || !returned.Add(path)) continue; yield return CreatePhoto(path, fullPath, owner); } var legacy = account.Player?.PlayerExtra?.SavedAvatars ?? new(); foreach (var item in legacy.Where(item => !HasOutfitState(item))) { string? path = NormalizeImagePath(item.PreviewImageName); if (path == null || !returned.Add(path)) continue; string fullPath = Path.Combine(Program.dataDir, "Images", path.Replace('/', Path.DirectorySeparatorChar)); if (System.IO.File.Exists(fullPath)) yield return CreatePhoto(path, fullPath, owner); } } private static IEnumerable EnumeratePublicPhotos() { string root = Path.Combine(Program.dataDir, "Images"); var allIndexed = RecNetDB.SavedImages.FindAll() .Select(image => new { image, path = NormalizeImagePath(image.PhotoPath) }) .Where(entry => entry.path != null) .ToList(); var indexedPaths = allIndexed .Select(entry => entry.path!) .ToHashSet(StringComparer.OrdinalIgnoreCase); foreach (var entry in allIndexed.Where(entry => entry.image.SavedImageType == 1 && entry.image.Accessibility == 1)) { string path = entry.path!; string file = Path.Combine(root, path.Replace('/', Path.DirectorySeparatorChar)); if (System.IO.File.Exists(file) && ImageExtensions.Contains(Path.GetExtension(file))) yield return CreatePhoto(path, file, null); } string[] folders = { "PolaroidImages", "WhereTaken" }; foreach (string folder in folders) { string directory = Path.Combine(root, folder); if (!Directory.Exists(directory)) continue; foreach (string file in Directory.EnumerateFiles(directory, "*", SearchOption.AllDirectories)) { if (!ImageExtensions.Contains(Path.GetExtension(file))) continue; string path = Path.GetRelativePath(root, file).Replace('\\', '/'); if (indexedPaths.Contains(path)) continue; yield return CreatePhoto(path, file, null); } } } private static bool HasOutfitState(PlayerDBClasses.SavedOutfit outfit) { return !string.IsNullOrWhiteSpace(outfit.OutfitSelections) || !string.IsNullOrWhiteSpace(outfit.FaceFeatures) || !string.IsNullOrWhiteSpace(outfit.SkinColor) || !string.IsNullOrWhiteSpace(outfit.HairColor); } private static RecNetPhoto CreatePhoto(string path, string fullPath, RecNetOwner? owner) { DateTime created = System.IO.File.GetCreationTimeUtc(fullPath); DateTime modified = System.IO.File.GetLastWriteTimeUtc(fullPath); return new RecNetPhoto(path, "/imageserver/" + Uri.EscapeDataString(path).Replace("%2F", "/"), created > modified ? created : modified, owner); } private static string? NormalizeImagePath(string? path) { if (string.IsNullOrWhiteSpace(path)) return null; string clean = path.Replace('\\', '/').TrimStart('/'); if (clean.StartsWith("imageserver/", StringComparison.OrdinalIgnoreCase)) clean = clean[12..]; string[] segments = clean.Split('/', StringSplitOptions.RemoveEmptyEntries); if (clean.Length > 260 || segments.Length == 0 || clean.Any(char.IsControl) || clean.Contains(':') || Path.IsPathRooted(clean) || segments.Any(segment => segment is "." or "..")) { return null; } return string.Join('/', segments); } private static string ImageUrl(string? path) { string clean = NormalizeImagePath(path) ?? "DefaultPFP.png"; return "/imageserver/" + Uri.EscapeDataString(clean).Replace("%2F", "/"); } private static bool IsVerified(PlayerDBClasses.FullPlayer account) => account.PlayerRoles?.Contains(PlayerDBClasses.PlayerRoles.Influencer) == true; private static bool HasRRPlus(PlayerDBClasses.FullPlayer account) => account.PlayerRoles?.Contains(PlayerDBClasses.PlayerRoles.RRPlus) == true; public record RecNetOwner(long accountId, string? username, string? displayName, string profileImage, bool verified, bool hasRRPlus); public record RecNetPhoto(string path, string url, DateTime takenAt, RecNetOwner? owner); public class RecNetLogin { public string? Identity { get; set; } public string? Password { get; set; } } public class RecNetRegistration { public string? Username { get; set; } public string? Password { get; set; } public string? ConfirmPassword { get; set; } public string? Platform { get; set; } public string? PlatformId { get; set; } } public class AdminRegistration : RecNetRegistration { } public class AdminRoleChange { public string? Role { get; set; } public bool Enabled { get; set; } } public class AdminPasswordReset { public string? NewPassword { get; set; } } public class AdminAvatarItemOwnershipChange { public long AvatarItemId { get; set; } public string? AvatarItemDesc { get; set; } public bool Owned { get; set; } } public class AdminConsumableQuantityChange { public long ConsumableItemId { get; set; } public string? ConsumableItemDesc { get; set; } public int Quantity { get; set; } } public class AdminBalanceChange { public int Amount { get; set; } public bool Add { get; set; } } public class WebsiteShopPurchase { public long SkuId { get; set; } } public class CheerBadgeSelection { public int Badge { get; set; } } public class AdminShopItemRequest { public long SkuId { get; set; } } public class AdminGiftRequest { public long RecipientAccountId { get; set; } public List? RecipientAccountIds { get; set; } public bool SendToAll { get; set; } public bool OnlineOnly { get; set; } public string? GiftType { get; set; } public long SkuId { get; set; } public int Amount { get; set; } = 1; public int BoxRarity { get; set; } public int BoxDesign { get; set; } = (int)PlayerDBClasses.GiftContext.Game_Drop; public string? Message { get; set; } } public class AdminClearOutgoingGiftsRequest { public long FromPlayerId { get; set; } = 1; } public class AdminSiteSettings { public bool? AccountCreationEnabled { get; set; } public bool? RecNetSignupEnabled { get; set; } public bool? VpnBlockingEnabled { get; set; } } public class AdminIpBanRequest { public string? Network { get; set; } public string? Reason { get; set; } } public class AdminMaintenanceRequest { public int Minutes { get; set; } } public class AdminSteamBlacklistRequest { public string? SteamId { get; set; } public string? Reason { get; set; } } public class AdminRoomUpdate { public string? Name { get; set; } public string? Description { get; set; } public string? ImageName { get; set; } public string? Accessibility { get; set; } public string? State { get; set; } public int MaxPlayers { get; set; } public int MinLevel { get; set; } public string[]? Tags { get; set; } public bool? CreativeToolsBetaEnabled { get; set; } public bool? SupportsBetaContent { get; set; } public bool? IsBeta { get; set; } public bool? IsBaseRoom { get; set; } public bool CloningAllowed { get; set; } public bool DisableMicAutoMute { get; set; } public bool DisableRoomComments { get; set; } public bool EncryptVoiceChat { get; set; } public bool ToxmodEnabled { get; set; } public bool LoadScreenLocked { get; set; } public bool AutoLocalizeRoom { get; set; } public bool IsDeveloperOwned { get; set; } public bool SupportsLevelVoting { get; set; } public bool IsRRO { get; set; } public bool SupportsScreens { get; set; } public bool SupportsWalkVR { get; set; } public bool SupportsTeleportVR { get; set; } public bool SupportsVRLow { get; set; } public bool SupportsQuest2 { get; set; } public bool SupportsMobile { get; set; } public bool SupportsJuniors { get; set; } } public class AdminRoomStatsUpdate { public int Cheers { get; set; } public int Favorites { get; set; } public int Visitors { get; set; } public int Visits { get; set; } } public class AdminRoomRoleUpdate { public string? Role { get; set; } = "None"; public string? InvitedRole { get; set; } = "None"; } public class AdminRoomOwnerTransfer { public long AccountId { get; set; } } public class AdminRoomBlobUpdate { public string? RoomBlob { get; set; } public string? MetadataBlob { get; set; } } public class AdminSubRoomCreate { public string? Name { get; set; } } public class AdminSubRoomUpdate { public string? Name { get; set; } public int MaxPlayers { get; set; } public string? Accessibility { get; set; } public bool IsSandbox { get; set; } public string? UnitySceneId { get; set; } public List? Permissions { get; set; } } public class AdminRoomBanUpdate { public long AccountId { get; set; } public string? Reason { get; set; } } public class AdminBanRequest { public string? Reason { get; set; } public bool LinkBan { get; set; } public int DurationAmount { get; set; } public string? DurationUnit { get; set; } } public class AdminMessageRequest { public string? Message { get; set; } } public class AdminReportResolveRequest { public string? Action { get; set; } public string? Reason { get; set; } public int DurationAmount { get; set; } public string? DurationUnit { get; set; } } public class AdminBugReportResolveRequest { public string? Status { get; set; } } public class AdminSettingRequest { public string? Key { get; set; } public string? Value { get; set; } } public class AdminReputationRequest { public bool? IsCheerful { get; set; } public int? CheerGeneral { get; set; } public int? CheerHelpful { get; set; } public int? CheerSportsman { get; set; } public int? CheerGreatHost { get; set; } public int? CheerCreative { get; set; } public string? SelectedCheer { get; set; } } public class AdminPreferencesRequest { public string? Theme { get; set; } public string? AccentColor { get; set; } } public class AdminKickToRoomRequest { public long? RoomId { get; set; } public string? RoomName { get; set; } } public class AdminTrollFakeBoxRequest { public int? TokenAmount { get; set; } public bool BanImmediately { get; set; } } public class AdminModerationUnlockRequest { public bool RemoveLinkedAccounts { get; set; } } public class AdminProfileUpdate { public string? Username { get; set; } public string? DisplayName { get; set; } public string? Bio { get; set; } public string? Email { get; set; } } public class AdminAccountDetailsUpdate { public int Level { get; set; } public int XP { get; set; } public bool IsJunior { get; set; } public int AvailableUsernameChanges { get; set; } public string? ProfileImage { get; set; } public string? BannerImage { get; set; } public string? DisplayEmoji { get; set; } public int PersonalPronouns { get; set; } } public class AdminPlatformChange { public string? Platform { get; set; } public string? PlatformId { get; set; } public bool Enabled { get; set; } } public class AdminAccountDeletion { public string? Confirmation { get; set; } } public class RecNetSettings { public string? DisplayName { get; set; } public string? Username { get; set; } public string? Bio { get; set; } public string? Email { get; set; } public string? ProfileImage { get; set; } public string? BannerImage { get; set; } } public class RecNetPasswordChange { public string? CurrentPassword { get; set; } public string? NewPassword { get; set; } } public class RecNetDeleteAccount { public string? Password { get; set; } public string? Confirmation { get; set; } } public class PhotoPathRequest { public string? Path { get; set; } } public class PhotoCommentRequest : PhotoPathRequest { public string? Text { get; set; } } } }