using Microsoft.AspNetCore.Mvc; using Newtonsoft.Json; using Campus_live_rewrite.api.dbs; using Campus_live_rewrite.api.server; using Campus_live_rewrite.api.server.Classes; using Campus_live_rewrite.api.server.Classes.db_class; namespace Campus_live_rewrite.Controllers { [ApiController] [Route("/")] public class AccountController : ControllerBase { [HttpGet("acc/account/bulk")] public IActionResult GetAccountsBulk([FromQuery] List id) { // Was: Ok(). ASP.NET's StringOutputFormatter writes a raw string as // Content-Type: text/plain, so these JSON payloads were served mislabelled // and the client would not parse them. Verified live: /outfits/me returned // text/plain while /api/avatar/v2 returned application/json. Same bytes, // correct content type. return new ContentResult { Content = PlayerDB.GetAccountsBulk(id), ContentType = "application/json", StatusCode = 200 }; } // Self-service username set, used during first-run account creation // onboarding. Flips HasChosenUsername so the client's onboarding flow // considers this step done. [HttpPut("acc/account/me/username")] public ContentResult PutMyUsername([FromForm] string username) { var (account, error) = AuthHelper.ValidateToken(HttpContext); if (error != null) return error; // Same rules as every other name path (NameRules): shape, length, swear filter, // reserved / staff look-alikes, case-insensitive uniqueness. This path used to accept // any string at all. if (PlayerDB.HasActiveModerationBlock(account.Id)) return Utils.Error("You cannot change your name at this time."); if (PlayerDB.IsNameLocked(account.Id)) return Utils.Error("Your name has been locked by a moderator and cannot be changed."); string? nameErr = NameRules.CheckUsername(username, account.Id, out username); if (nameErr != null) { Console.WriteLine($"[names] {account.Id} first-run username refused: {nameErr}"); return new ContentResult { Content = JsonConvert.SerializeObject(new { success = false, error = nameErr, value = (string?)null }), ContentType = "application/json", StatusCode = 200 }; } var player = PlayerDB.GetFullAccount(account.Id); if (player == null) { return new ContentResult { Content = JsonConvert.SerializeObject(new { success = false, error = "Player not found", value = (string?)null }), ContentType = "application/json", StatusCode = 200 }; } player.player.Username = username; player.player.DisplayName = username; var col = PlayerDB.PlayerDBFile.GetCollection("players"); col.Update(player); Notifications.FireAndForget(Notifications.PushAccountUpdate(account.Id), $"account-update {account.Id}"); // Set the account creation flag SettingsDB.SetPlayerSetting("Recroom.AccountCreation.HasChosenUsername", "True", account.Id); // Push a live self-refresh so the new @name/display shows in-game without a relog, // exactly like the website edit and the Accounts/account/me/username path do. Notifications.FireAndForget(Notifications.PushAccountUpdate(account.Id), "acc username refresh"); return new ContentResult { Content = JsonConvert.SerializeObject(new { success = true, error = "", value = (string?)null }), ContentType = "application/json", StatusCode = 200 }; } // Self-service password set for onboarding. Password is hashed // before storage — never stored in plaintext. [HttpPut("acc/account/me/password")] public ContentResult PutMyPassword([FromForm] string password) { var (account, error) = AuthHelper.ValidateToken(HttpContext); if (error != null) return error; if (string.IsNullOrWhiteSpace(password) || password.Length < 4) { return new ContentResult { Content = JsonConvert.SerializeObject(new { success = false, error = "Password must be at least 4 characters!", value = (string?)null }), ContentType = "application/json", StatusCode = 200 }; } var player = PlayerDB.GetFullAccount(account.Id); if (player == null) { return new ContentResult { Content = JsonConvert.SerializeObject(new { success = false, error = "Player not found", value = (string?)null }), ContentType = "application/json", StatusCode = 200 }; } player.password = ClientSecurity.HashPassword(password); var col = PlayerDB.PlayerDBFile.GetCollection("players"); col.Update(player); Notifications.FireAndForget(Notifications.PushAccountUpdate(account.Id), $"account-update {account.Id}"); // Set the account creation flag SettingsDB.SetPlayerSetting("Recroom.AccountCreation.HasCreatedPassword", "True", account.Id); // Check if username has also been set var hasUsername = SettingsDB.GetPlayerSettingValue("Recroom.AccountCreation.HasChosenUsername", account.Id) == "True"; if (hasUsername) { SettingsDB.SetPlayerSetting("Recroom.AccountCreation.HasFinished", "True", account.Id); } return new ContentResult { Content = JsonConvert.SerializeObject(new { success = true, error = "", value = (string?)null }), ContentType = "application/json", StatusCode = 200 }; } [HttpPut("acc/account/me/profileimage")] public ContentResult PutMyProfileImage([FromForm] string imageName) { var (account, error) = AuthHelper.ValidateToken(HttpContext); if (error != null) return error; if (string.IsNullOrWhiteSpace(imageName)) { return new ContentResult { Content = JsonConvert.SerializeObject(new { success = false, error = "Image name cannot be empty!", value = (string?)null }), ContentType = "application/json", StatusCode = 200 }; } var player = PlayerDB.GetFullAccount(account.Id); if (player == null) { return new ContentResult { Content = JsonConvert.SerializeObject(new { success = false, error = "Player not found", value = (string?)null }), ContentType = "application/json", StatusCode = 200 }; } player.player.profileImage = imageName; var col = PlayerDB.PlayerDBFile.GetCollection("players"); col.Update(player); Notifications.FireAndForget(Notifications.PushAccountUpdate(account.Id), $"account-update {account.Id}"); return new ContentResult { Content = JsonConvert.SerializeObject(new { success = true, error = "", value = (string?)null }), ContentType = "application/json", StatusCode = 200 }; } [HttpPut("acc/account/me/bannerimage")] public ContentResult PutMyBannerImage([FromForm] string imageName) { var (account, error) = AuthHelper.ValidateToken(HttpContext); if (error != null) return error; if (string.IsNullOrWhiteSpace(imageName)) { return new ContentResult { Content = JsonConvert.SerializeObject(new { success = false, error = "Image name cannot be empty!", value = (string?)null }), ContentType = "application/json", StatusCode = 200 }; } var player = PlayerDB.GetFullAccount(account.Id); if (player == null) { return new ContentResult { Content = JsonConvert.SerializeObject(new { success = false, error = "Player not found", value = (string?)null }), ContentType = "application/json", StatusCode = 200 }; } player.player.bannerImage = imageName; var col = PlayerDB.PlayerDBFile.GetCollection("players"); col.Update(player); Notifications.FireAndForget(Notifications.PushAccountUpdate(account.Id), $"account-update {account.Id}"); return new ContentResult { Content = JsonConvert.SerializeObject(new { success = true, error = "", value = (string?)null }), ContentType = "application/json", StatusCode = 200 }; } [HttpPut("acc/account/me/bio")] public ContentResult PutMyBio([FromForm] string? bio) { var (account, error) = AuthHelper.ValidateToken(HttpContext); if (error != null) return error; bio = System.Net.WebUtility.UrlDecode(bio ?? ""); if (bio.Length > 255) { return new ContentResult { Content = JsonConvert.SerializeObject(new { success = false, error = "Your bio must not exceed 255 characters!", value = (string?)null }), ContentType = "application/json", StatusCode = 200 }; } var player = PlayerDB.GetFullAccount(account.Id); if (player == null) { return new ContentResult { Content = JsonConvert.SerializeObject(new { success = false, error = "Player not found", value = (string?)null }), ContentType = "application/json", StatusCode = 200 }; } player.player_Extra.Bio = bio; var col = PlayerDB.PlayerDBFile.GetCollection("players"); col.Update(player); Notifications.FireAndForget(Notifications.PushAccountUpdate(account.Id), $"account-update {account.Id}"); Notifications.FireAndForget(Notifications.PushAccountUpdate(account.Id), "acc bio refresh"); return new ContentResult { Content = JsonConvert.SerializeObject(new { success = true, error = "", value = (string?)null }), ContentType = "application/json", StatusCode = 200 }; } [HttpPut("acc/account/me/personalpronouns")] public ContentResult PutMyPronouns([FromForm] int pronounFlags) { var (account, error) = AuthHelper.ValidateToken(HttpContext); if (error != null) return error; var player = PlayerDB.GetFullAccount(account.Id); if (player == null) { return new ContentResult { Content = JsonConvert.SerializeObject(new { success = false, error = "Player not found", value = (string?)null }), ContentType = "application/json", StatusCode = 200 }; } player.player.pronounFlags = pronounFlags; var col = PlayerDB.PlayerDBFile.GetCollection("players"); col.Update(player); Notifications.FireAndForget(Notifications.PushAccountUpdate(account.Id), $"account-update {account.Id}"); Notifications.FireAndForget(Notifications.PushAccountUpdate(account.Id), "acc pronouns refresh"); return new ContentResult { Content = JsonConvert.SerializeObject(new { success = true, error = "", value = (string?)null }), ContentType = "application/json", StatusCode = 200 }; } [HttpPut("acc/account/me/identityflags")] public ContentResult PutMyIdentityFlags([FromForm] int identityFlags) { var (account, error) = AuthHelper.ValidateToken(HttpContext); if (error != null) return error; var player = PlayerDB.GetFullAccount(account.Id); if (player == null) { return new ContentResult { Content = JsonConvert.SerializeObject(new { success = false, error = "Player not found", value = (string?)null }), ContentType = "application/json", StatusCode = 200 }; } player.player.identityFlags = identityFlags; var col = PlayerDB.PlayerDBFile.GetCollection("players"); col.Update(player); Notifications.FireAndForget(Notifications.PushAccountUpdate(account.Id), $"account-update {account.Id}"); Notifications.FireAndForget(Notifications.PushAccountUpdate(account.Id), "acc identity refresh"); return new ContentResult { Content = JsonConvert.SerializeObject(new { success = true, error = "", value = (string?)null }), ContentType = "application/json", StatusCode = 200 }; } [HttpGet("acc/account/{accountId}/bio")] public IActionResult GetAccountBio(ulong accountId) { var player = PlayerDB.GetFullAccount(accountId); return Ok(new { AccountId = accountId, Bio = player?.player_Extra?.Bio ?? "" }); } [HttpGet("acc/account/me")] public IActionResult GetAccountMe() { var (account, error) = AuthHelper.ValidateToken(HttpContext); if (error != null) return error; var accountData = PlayerDB.GetAccountMeSafe(account.Id); return accountData != null ? Ok(accountData) : NotFound(); } [HttpPost("acc/account/me/email")] [HttpPost("/Accounts/account/me/email")] // the 2025 client's path (was a 404) [HttpPut("/Accounts/account/me/email")] public IActionResult AccAccountMeEmail() { var (account, error) = AuthHelper.ValidateToken(HttpContext); if (error != null) return error; // Was a no-op that just returned success, so a linked email was thrown away. Store it // on the account (same field the website signup writes and account/me reads back) and // push a live self-refresh so it shows in-game immediately, like every other edit. string email = (Request.Form["email"].ToString() ?? "").Trim(); if (email.Length > 0 && (email.Length > 254 || !System.Text.RegularExpressions.Regex.IsMatch(email, @"^[^@\s]+@[^@\s]+\.[^@\s]+$"))) { return Ok(new { success = false, error = "Enter a valid email address." }); } var player = PlayerDB.GetFullAccount(account.Id); if (player?.player != null) { player.player.Email = email.Length == 0 ? null : email; PlayerDB.PlayerDBFile.GetCollection("players").Update(player); } Notifications.FireAndForget(Notifications.RefreshAccount(account.Id), "acc email refresh"); return Ok(new { success = true }); } [HttpPost("acc/account/me/skip-orientation")] public ContentResult SkipOrientation() { var (account, error) = AuthHelper.ValidateToken(HttpContext); if (error != null) return error; // Set tutorial as complete to skip orientation SettingsDB.SetPlayerSetting("TUTORIAL_COMPLETE_MASK", "4294967295", account.Id); return new ContentResult { Content = JsonConvert.SerializeObject(new { success = true, error = "", value = (string?)null }), ContentType = "application/json", StatusCode = 200 }; } // The client derives role-badge checks from the service map's Auth base, // so it calls /auth/role/... — keep both paths registered. [HttpGet("role/moderator/{playerId}")] [HttpGet("/auth/role/moderator/{playerId}")] public IActionResult CheckModerator(ulong playerId) { var player = PlayerDB.GetFullAccount(playerId); if (player == null) return NotFound(); var isModerator = PlayerDB.CheckModFlag(playerId); return Ok(isModerator.ToString().ToLower()); } [HttpGet("role/developer/{playerId}")] [HttpGet("/auth/role/developer/{playerId}")] public IActionResult CheckDeveloper(ulong playerId) { var player = PlayerDB.GetFullAccount(playerId); if (player == null) return NotFound(); var isDeveloper = PlayerDB.CheckDevFlag(playerId); return Ok(isDeveloper.ToString().ToLower()); } // BULK query form: /Auth/role/developer?id=6&id=10&... — the client fetches this for // player lists (dev badges) and it was 404ing (only the /{playerId} path form existed). // Returns a JSON array of the requested ids that ARE developers. [HttpGet("role/developer")] [HttpGet("/auth/role/developer")] public IActionResult CheckDevelopersBulk() { var ids = Request.Query["id"].Select(s => ulong.TryParse(s, out var v) ? v : 0UL).Where(v => v != 0).Distinct(); var devs = ids.Where(id => PlayerDB.GetFullAccount(id) != null && PlayerDB.CheckDevFlag(id)).ToList(); return Ok(devs); } [HttpGet("role/moderator")] [HttpGet("/auth/role/moderator")] public IActionResult CheckModeratorsBulk() { var ids = Request.Query["id"].Select(s => ulong.TryParse(s, out var v) ? v : 0UL).Where(v => v != 0).Distinct(); var mods = ids.Where(id => PlayerDB.GetFullAccount(id) != null && PlayerDB.CheckModFlag(id)).ToList(); return Ok(mods); } } }