using Microsoft.AspNetCore.Mvc; using Newtonsoft.Json; using Campus_live_rewrite.api.dbs; using Campus_live_rewrite.api.server; using System.ComponentModel.DataAnnotations; using System.Text; using static Campus_live_rewrite.api.dbs.HeartbeatDB; using static Campus_live_rewrite.api.server.Classes.db_class.LeaderboardDBClasses; using static Campus_live_rewrite.api.server.Images; namespace Campus_live_rewrite.Controllers { [Route("server")] public class PlayerSettingsController : ControllerBase { [HttpGet("PlayerSettings/playersettings")] public IActionResult GetPlayerSettings() { var (account, error) = AuthHelper.ValidateToken(HttpContext); if (error != null) return error; var settings = SettingsDB.LoadSettings(account.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 = JsonConvert.SerializeObject(settings), ContentType = "application/json", StatusCode = 200 }; } [HttpPut("PlayerSettings/playersettings")] public IActionResult UpdatePlayerSetting([FromForm, Required] string key, [FromForm, Required] string value) { var (account, error) = AuthHelper.ValidateToken(HttpContext); if (error != null) return error; if (string.IsNullOrWhiteSpace(key)) return BadRequest(""); SettingsDB.SetPlayerSetting(key, value ?? "", account.Id); // Account-creation / tutorial keys are logged: they are what the client's onboarding // branches on, and a client that flips them itself is otherwise invisible. if (key.StartsWith("Recroom.AccountCreation", StringComparison.OrdinalIgnoreCase) || key.StartsWith("TUTORIAL", StringComparison.OrdinalIgnoreCase) || key.Contains("ORIENTATION", StringComparison.OrdinalIgnoreCase)) Console.WriteLine($"[settings] {account.Id} PUT {key} = {value}"); return Ok(new { success = true, errorId = (string?)null, error = (string?)null }); } [HttpDelete("PlayerSettings/playersettings")] public IActionResult UpdatePlayerSetting([FromForm, Required] string key) { var (account, error) = AuthHelper.ValidateToken(HttpContext); if (error != null) return error; if (string.IsNullOrWhiteSpace(key)) return BadRequest(""); SettingsDB.DeletePlayerKey(key ?? "", account.Id); return Ok(new { success = true, errorId = (string?)null, error = (string?)null }); } [HttpPost("Storage/upload")] public async Task server_storage_upload([FromForm] FileUploadModel model) { var (account, error) = AuthHelper.ValidateToken(HttpContext); if (error != null) return error; var ua = Request.Headers["User-Agent"].ToString(); if (ua.Contains("PostmanRuntime")) { return new ContentResult { Content = "", ContentType = "application/json", StatusCode = 403 }; } if (PlayerDB.HasActiveModerationBlock(account.Id)) { return new ContentResult { Content = "", ContentType = "application/json", StatusCode = 403 }; } try { if (model?.File == null || model.File.Length <= 0) { return new ContentResult() { Content = "", ContentType = "application/json", StatusCode = 400 }; } byte[] array = Array.Empty(); MemoryStream stream = new MemoryStream(); await model.File.CopyToAsync(stream); array = stream.ToArray(); FileType_data data_type = model.FileType; // Only files the game itself produced get stored (see UploadGuard). var refused = UploadGuard.Gate(HttpContext, account, UploadGuard.KindOf(data_type), array, $"Storage/upload:{data_type}"); if (refused != null) return refused; try { var fileResult = Images.SaveFileUnifiedv2(array, data_type); if (fileResult != null && fileResult.saved) { if (fileResult.FileType == FileType_data.Image) { _ = Task.Run(() => Webhook.Send( account.Id, "prod", "Server info", $"({account.Id}) @{account.Username} has uploaded an polaroid image", 5898927, $"https://gameauth.ddns.net/img/{fileResult.rawfilename}" )); } return new ContentResult() { Content = JsonConvert.SerializeObject(new { filename = fileResult.FileName, Hash = "e", // Not needed for game, game expect it so hardcode OwnershipProof = "e" // Not needed for game, game expect it so hardcode }, Formatting.Indented), ContentType = "application/json", StatusCode = 200 }; } return new ContentResult() { Content = JsonConvert.SerializeObject(new { success = false, error = "Failed to upload!", value = (string?)null }), ContentType = "application/json", StatusCode = 200 }; } catch (Exception e) { Console.WriteLine(e.Message); return new ContentResult() { Content = JsonConvert.SerializeObject(new { filename = (string?)null, Hash = (string?)null, OwnershipProof = (string?)null, }, Formatting.Indented), ContentType = "application/json", StatusCode = 200 }; } } catch (Exception e) { Console.WriteLine(e); return new ContentResult() { Content = JsonConvert.SerializeObject(new { filename = (string?)null, Hash = (string?)null, OwnershipProof = (string?)null, }, Formatting.Indented), ContentType = "application/json", StatusCode = 200 }; } } // A defunct limited-time contest's hit recorder (Glitch City grenade event). Was a live // 404; nothing tracks the contest on this server, so ack with an empty object rather than // 404 (the leaderboard/room UI that pings it shouldn't error). [HttpPost("Leaderboard/{contest}/RecordHits")] public ContentResult ContestRecordHits(string contest) => new ContentResult { Content = "{}", ContentType = "application/json", StatusCode = 200 }; // Every leaderboard read is per-player and rank is computed at read time, so no response // may ever be served from a cache - stamped on all three read routes. private void NoCache() { Response.Headers["Cache-Control"] = "no-store, no-cache, must-revalidate"; Response.Headers["Pragma"] = "no-cache"; } [HttpPost("Leaderboard/leaderboard/GetNearbyScores")] public async Task GetNearbyScores() { var (account, error) = AuthHelper.ValidateToken(HttpContext); if (error != null) return error; NoCache(); string body = await new StreamReader(HttpContext.Request.Body).ReadToEndAsync(); try { var req = JsonConvert.DeserializeObject(body); if (req == null) throw new Exception("Invalid"); var rows = LeaderboardDB.GetNearbyScores( req.PlayerId, req.StatChannel, req.RoomId, req.WindowSize, req.SortAscending ); return new ContentResult() { Content = JsonConvert.SerializeObject(new { Rows = rows }), ContentType = "application/json", StatusCode = 200 }; } catch { return new ContentResult() { Content = JsonConvert.SerializeObject(new { Rows = new List() }), ContentType = "application/json", StatusCode = 200 }; } } [HttpPost("Leaderboard/leaderboard/GetPlayerRank")] public async Task GetPlayerRank() { var (account, error) = AuthHelper.ValidateToken(HttpContext); if (error != null) return error; NoCache(); string body = await new StreamReader(HttpContext.Request.Body).ReadToEndAsync(); try { var req = JsonConvert.DeserializeObject(body); if (req == null) throw new Exception("Invalid"); var rank = LeaderboardDB.GetPlayerRank( req.PlayerId, req.StatChannel, req.RoomId, req.SortAscending ); if (rank == null) { // No row on this board = UNRANKED. The client expects NUMERIC sentinels, not // null: Score 0 (no score) and Rank 99999 (a big number, since 0 is a real rank // = first place). Echo the BODY's PlayerId - the client matches the answer to the // player it asked about. return new ContentResult() { Content = JsonConvert.SerializeObject(new { PlayerId = req.PlayerId, Score = 0, Rank = 99999, }), ContentType = "application/json", StatusCode = 200 }; } return new ContentResult() { Content = JsonConvert.SerializeObject(rank), ContentType = "application/json", StatusCode = 200 }; } catch (Exception ex) { Console.WriteLine(ex); return new ContentResult() { Content = JsonConvert.SerializeObject(new { PlayerId = account.Id, Score = 0, Rank = 99999, }), ContentType = "application/json", StatusCode = 200 }; } } [HttpPost("Leaderboard/leaderboard/CheckAndSetStat")] public async Task CheckAndSetStat() { var (account, error) = AuthHelper.ValidateToken(HttpContext); if (error != null) return error; string body = await new StreamReader(HttpContext.Request.Body).ReadToEndAsync(); try { var req = JsonConvert.DeserializeObject(body); if (req == null) throw new Exception("Invalid"); var room = RoomDB.GetRoom(req.RoomId); // Plausibility gate, Rec Room Original rooms only (LeaderboardGuard): a score that // jumps a thousand in a second is a hacked client, not a game. Refused writes are // not stored, are logged, and land in the admin panel's Reports as Cheating. The // answer is still the bare 0 the client expects - a refusal must not read as a // broken leaderboard. var stored = LeaderboardDB.GetEntry(account.Id, req.StatChannel, req.RoomId); var verdict = Campus_live_rewrite.api.server.LeaderboardGuard.Check(account.Id, req.StatChannel, req.RoomId, req.StatValue, stored, out string why); if (verdict == Campus_live_rewrite.api.server.LeaderboardGuard.Verdict.TooFast || verdict == Campus_live_rewrite.api.server.LeaderboardGuard.Verdict.TooMuchToday) { Campus_live_rewrite.api.server.LeaderboardGuard.Refuse(account.Id, account.Username ?? "", req.StatChannel, req.RoomId, room?.Name ?? "?", req.StatValue, verdict, why); return new ContentResult() { Content = "0", ContentType = "application/json", StatusCode = 200 }; } // Compare-and-set on the caller's own row: write StatValue only if the stored value // still equals CurrentStatValue (null = write unconditionally). LeaderboardDB.SetStat( account.Id, req.StatChannel, req.RoomId, req.StatValue, req.CurrentStatValue ); // The score pays out (ConfigCampus.LeaderboardPaysRoomCurrency): the gain over the // stored score, in the currency of the room the player is standing in - only a // balance room (Crescendo -> Silver, Isle -> Gold) pays, and only what the guard // above accepted. A run that does not beat the stored score pays nothing. if (ConfigCampus.LeaderboardPaysRoomCurrency) { long gain = (long)req.StatValue - (stored?.Score ?? 0); var live = HeartbeatDB.GetPlayerHeartbeat(account.Id)?.roomInstance; var cur = Campus_live_rewrite.api.server.Classes.GameCurrency.ForBalanceRoom(live?.name); if (gain > 0 && cur != null && live != null && (ulong)live.roomId == req.RoomId && Campus_live_rewrite.api.server.Classes.GameCurrency.IsPlayerGrantable((int)cur.Value)) { long total = PlayerDB.AddBalance(account.Id, (int)cur.Value, gain); Campus_live_rewrite.api.server.GiftBoxes.PushBalance(account.Id, (int)cur.Value, total, "leaderboard"); Console.WriteLine($"[leaderboard] {account.Id} scored {req.StatValue} (was {stored?.Score ?? 0}) in '{room?.Name}' -> +{gain} {cur} = {total}"); } } Webhook.Send( account.Id, "prod", "Leaderboard Info", $"Player @{account.Username} ({account.Id}) has updated their score to {req.StatValue} in {(room != null ? room.Name : "Unknown Room")}", 5898927 ); // The live service answers the BARE number 0 here (not an envelope, not the result // enum) whether or not the compare passed - that's what the client's parser expects. return new ContentResult() { Content = "0", ContentType = "application/json", StatusCode = 200 }; } catch (Exception ex) { Console.WriteLine(ex); // Still answer a bare 0 - the client's parser expects a number here, and a 400 would // read as a failed stat write / broken leaderboard. return new ContentResult() { Content = "0", ContentType = "application/json", StatusCode = 200 }; } } [HttpPost("Leaderboard/leaderboard/GetRanks")] public async Task GetRanks() { var (account, error) = AuthHelper.ValidateToken(HttpContext); if (error != null) return error; NoCache(); string body = await new StreamReader(HttpContext.Request.Body).ReadToEndAsync(); try { var req = JsonConvert.DeserializeObject(body); if (req == null) throw new Exception("Invalid"); var rows = LeaderboardDB.GetRanks( req.StatChannel, req.RoomId, req.RankStart, req.RankEnd, req.SortAscending ); return new ContentResult() { Content = JsonConvert.SerializeObject(new { Rows = rows }), ContentType = "application/json", StatusCode = 200 }; } catch (Exception ex) { return new ContentResult() { Content = JsonConvert.SerializeObject(new { Rows = new List() }), ContentType = "application/json", StatusCode = 200 }; } } } }