the files
This commit is contained in:
@@ -0,0 +1,466 @@
|
||||
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(<string>). 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<ContentResult> 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<byte>();
|
||||
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<ContentResult> 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<GetNearbyScoresRequest>(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<Entry>() }),
|
||||
ContentType = "application/json",
|
||||
StatusCode = 200
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
[HttpPost("Leaderboard/leaderboard/GetPlayerRank")]
|
||||
public async Task<ContentResult> 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<GetNearbyScoresRequest>(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<ContentResult> 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<CheckAndSetStatRequest>(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<ContentResult> 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<GetRanksRequest>(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<Entry>() }),
|
||||
ContentType = "application/json",
|
||||
StatusCode = 200
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user