diff --git a/.Tests/XUnit.Tests/GameServerAttributeTest.cs b/.Tests/XUnit.Tests/GameServerAttributeTest.cs index 40ad77ef..5b5f1dce 100644 --- a/.Tests/XUnit.Tests/GameServerAttributeTest.cs +++ b/.Tests/XUnit.Tests/GameServerAttributeTest.cs @@ -1,129 +1,80 @@ -/* -using System.Text; -using System.Text.Json; +using System.Text.Json.Nodes; using UT4MasterServer.Models; namespace XUnit.Tests; public class GameServerAttributeTest { - public static TheoryData TestCases = new() + [Fact] + public void ToJObject_EmitsEachStoredType() { - { "3", "3", true, false, true }, - { "3", 3, false, false, false }, - { "3", true, false, false, false }, - { "3", false, false, false, false }, - { "3", null, false, false, false }, - { "2", "3", false, true, true }, - { "4", "3", false, false, false }, - - { 3, "3", false, false, false }, - { 3, 3, true, false, true }, - { 3, true, false, false, false }, - { 3, false, false, false, false }, - { 3, null, false, false, false }, - - { true, true, true, false, true }, - { true, "true", false, false, false }, - { true, 1, false, false, false }, - { true, null, false, false, false }, - { false, false, true, false, true }, - { false, null, false, false, false }, - { true, false, false, false, false }, - { false, true, false, true, true }, - - { " ", "", false, false, false }, - { " ", " ", true, false, true }, - { "", " ", false, true, true }, - }; - - public static TheoryData TestCasesNull = new() + var attributes = new GameServerAttributes(); + attributes.Set("UT_SERVERNAME_s", "My Server"); + attributes.Set("UT_RANKED_i", 1); + attributes.Set("UT_PRIVATE_b", true); + + JsonObject obj = attributes.ToJObject(); + + Assert.Equal(3, obj.Count); + Assert.Equal("My Server", obj["UT_SERVERNAME_s"]!.GetValue()); + Assert.Equal(1, obj["UT_RANKED_i"]!.GetValue()); + Assert.True(obj["UT_PRIVATE_b"]!.GetValue()); + } + + [Fact] + public void ToJObject_KeyWithoutTypeSuffix_DoesNotThrow() { + var attributes = new GameServerAttributes(); + attributes.Set("CUSTOMKEY", "value"); + + // used to throw while serializing the server list because the key + // does not end in a known type suffix + JsonObject obj = attributes.ToJObject(); - { null, true, false, true }, - { true, false, false, false }, - { 3, false, false, false }, - { "null", false, false, false }, - }; + Assert.Equal("value", obj["CUSTOMKEY"]!.GetValue()); + } - [Theory] - [MemberData(nameof(TestCases))] - public void TestAttributesNonNull(object? attrValue, object? compareValue, bool expectedEq, bool expectedLt, bool expectedLte) + [Fact] + public void ToJObject_KeyWithMismatchedTypeSuffix_EmitsActualStoredType() { - JsonElement jsonElem = CreateJsonElement(compareValue); - var gsa = new GameServerAttributes(); - if (attrValue is string attrValueString) - { - gsa.Set("key", attrValueString); - } - else if (attrValue is int attrValueInt) - { - gsa.Set("key", attrValueInt); - } - else if (attrValue is bool attrValueBool) - { - gsa.Set("key", attrValueBool); - } - else - { - Assert.Fail("undesired test case"); - } - - Assert.Equal(expectedEq, gsa.Eq("key", jsonElem)); - Assert.Equal(expectedLt, gsa.Lt("key", jsonElem)); - Assert.Equal(expectedLte, gsa.Lte("key", jsonElem)); + var attributes = new GameServerAttributes(); + attributes.Set("UT_MISMATCH_i", "not an int"); + attributes.Set("UT_MISMATCH_s", 42); + attributes.Set("UT_MISMATCH_b", 7); + + // used to throw an invalid cast because the value type did not + // match the key suffix + JsonObject obj = attributes.ToJObject(); + + Assert.Equal("not an int", obj["UT_MISMATCH_i"]!.GetValue()); + Assert.Equal(42, obj["UT_MISMATCH_s"]!.GetValue()); + Assert.Equal(7, obj["UT_MISMATCH_b"]!.GetValue()); } - [Theory] - [MemberData(nameof(TestCasesNull))] - public void TestAttributesNull(object? compareValue, bool expectedEq, bool expectedLt, bool expectedLte) + [Fact] + public void ToJObject_MixedAttributes_SerializesToJson() { - JsonElement jsonElem = CreateJsonElement(compareValue); - var gsa = new GameServerAttributes(); - gsa.Set("key", null as string); - Assert.Equal(expectedEq, gsa.Eq("key", jsonElem)); - Assert.Equal(expectedLt, gsa.Lt("key", jsonElem)); - Assert.Equal(expectedLte, gsa.Lte("key", jsonElem)); - - gsa.Set("key", null as int?); - Assert.Equal(expectedEq, gsa.Eq("key", jsonElem)); - Assert.Equal(expectedLt, gsa.Lt("key", jsonElem)); - Assert.Equal(expectedLte, gsa.Lte("key", jsonElem)); - - gsa.Set("key", null as bool?); - Assert.Equal(expectedEq, gsa.Eq("key", jsonElem)); - Assert.Equal(expectedLt, gsa.Lt("key", jsonElem)); - Assert.Equal(expectedLte, gsa.Lte("key", jsonElem)); + var attributes = new GameServerAttributes(); + attributes.Set("UT_SERVERNAME_s", "Server"); + attributes.Set("UT_SERVERTRUSTLEVEL_i", 2); + attributes.Set("BADKEY", true); + attributes.Set("UT_WEIRD_i", "string stored under int key"); + + var json = attributes.ToJObject().ToJsonString(); + + Assert.False(string.IsNullOrEmpty(json)); } - private static JsonElement CreateJsonElement(object? obj) + [Fact] + public void ToJObject_NullValue_RemovesAttribute() { - StringBuilder sb = new(); - if (obj is null) - { - sb.Append("null"); - } - else if (obj is string objString) - { - sb.Append($"\"{objString}\""); - } - else if (obj is int objInt) - { - sb.Append(objInt.ToString()); - } - else if (obj is bool objBool) - { - sb.Append(objBool ? "true" : "false"); - } - else - { - Assert.Fail("undesired test case"); - } - - var utf8 = Encoding.UTF8.GetBytes(sb.ToString()); - - Utf8JsonReader jsonReader = new(utf8); - return JsonElement.ParseValue(ref jsonReader); + var attributes = new GameServerAttributes(); + attributes.Set("UT_SERVERNAME_s", "Server"); + attributes.Set("UT_SERVERNAME_s", (string?)null); + + JsonObject obj = attributes.ToJObject(); + + Assert.False(attributes.Contains("UT_SERVERNAME_s")); + Assert.Empty(obj); } } -*/ diff --git a/.Tests/XUnit.Tests/RatingsControllerTest.cs b/.Tests/XUnit.Tests/RatingsControllerTest.cs new file mode 100644 index 00000000..b41e65f6 --- /dev/null +++ b/.Tests/XUnit.Tests/RatingsControllerTest.cs @@ -0,0 +1,25 @@ +using UT4MasterServer.Controllers.UT; + +namespace XUnit.Tests; + +public class RatingsControllerTest +{ + [Theory] + [InlineData(0, 10, 0, 10)] // in range, unchanged + [InlineData(50, 100, 50, 100)] // upper bounds, unchanged + [InlineData(-5, 10, 0, 10)] // negative skip clamps to 0 + [InlineData(-1, 10, 0, 10)] + [InlineData(0, 0, 0, 100)] // limit below range falls back to default + [InlineData(0, -20, 0, 100)] + [InlineData(0, 101, 0, 100)] // limit above range falls back to default + [InlineData(0, 1000, 0, 100)] + [InlineData(-3, 5000, 0, 100)] // both out of range + [InlineData(0, 1, 0, 1)] // lower limit bound, unchanged + public void ClampPaging_KeepsValuesWithinSaneBounds(int skip, int limit, int expectedSkip, int expectedLimit) + { + var (clampedSkip, clampedLimit) = RatingsController.ClampPaging(skip, limit); + + Assert.Equal(expectedSkip, clampedSkip); + Assert.Equal(expectedLimit, clampedLimit); + } +} diff --git a/.Tests/XUnit.Tests/StatisticBaseInputFormatterTest.cs b/.Tests/XUnit.Tests/StatisticBaseInputFormatterTest.cs new file mode 100644 index 00000000..ef42001e --- /dev/null +++ b/.Tests/XUnit.Tests/StatisticBaseInputFormatterTest.cs @@ -0,0 +1,65 @@ +using UT4MasterServer.Formatters; +using UT4MasterServer.Models.Database; + +namespace XUnit.Tests; + +public class StatisticBaseInputFormatterTest +{ + [Theory] + [InlineData("")] + [InlineData("\0")] + [InlineData("\0\0\0")] + [InlineData(" ")] + [InlineData(" \t\r\n")] + [InlineData(" \0\0")] + [InlineData("null")] + [InlineData("null\0")] + [InlineData("{ not valid json")] + [InlineData("[]")] + [InlineData("\"just a string\"")] + public void TryParse_InvalidBody_ReturnsFalse(string rawValue) + { + var ok = StatisticBaseInputFormatter.TryParse(rawValue, out StatisticBase? result); + + Assert.False(ok); + Assert.Null(result); + } + + [Fact] + public void TryParse_ValidBodyWithTrailingNul_ReturnsParsedStatistic() + { + // the game terminates the json body with a trailing NUL character + const string rawValue = "{\"MatchesPlayed\":3,\"Kills\":15,\"Deaths\":7}\0"; + + var ok = StatisticBaseInputFormatter.TryParse(rawValue, out StatisticBase? result); + + Assert.True(ok); + Assert.NotNull(result); + Assert.Equal(3, result!.MatchesPlayed); + Assert.Equal(15, result.Kills); + Assert.Equal(7, result.Deaths); + } + + [Fact] + public void TryParse_ValidBodyWithoutTrailingNul_ReturnsParsedStatistic() + { + const string rawValue = "{\"Wins\":2,\"Losses\":1}"; + + var ok = StatisticBaseInputFormatter.TryParse(rawValue, out StatisticBase? result); + + Assert.True(ok); + Assert.NotNull(result); + Assert.Equal(2, result!.Wins); + Assert.Equal(1, result.Losses); + } + + [Fact] + public void TryParse_EmptyJsonObject_ReturnsEmptyStatistic() + { + var ok = StatisticBaseInputFormatter.TryParse("{}", out StatisticBase? result); + + Assert.True(ok); + Assert.NotNull(result); + Assert.Null(result!.MatchesPlayed); + } +} diff --git a/UT4MasterServer.Models/GameServerAttributes.cs b/UT4MasterServer.Models/GameServerAttributes.cs index a6be196e..02c69553 100644 --- a/UT4MasterServer.Models/GameServerAttributes.cs +++ b/UT4MasterServer.Models/GameServerAttributes.cs @@ -73,24 +73,26 @@ public string[] GetKeys() public JsonObject ToJObject() { - var attrs = new KeyValuePair[serverConfigs.Count]; + var attrs = new List>(serverConfigs.Count); - var i = 0; foreach (KeyValuePair kvp in serverConfigs) { - if (kvp.Key.EndsWith("_b")) + // emit based on the actual stored type. keys without a known type + // suffix or with a value that mismatches their suffix used to leave + // a null-key entry or throw an invalid cast, crashing serialization + // of the entire server list. + if (kvp.Value is bool valueBool) { - attrs[i] = new(kvp.Key, (bool)kvp.Value); + attrs.Add(new(kvp.Key, valueBool)); } - else if (kvp.Key.EndsWith("_i")) + else if (kvp.Value is int valueInt) { - attrs[i] = new(kvp.Key, (int)kvp.Value); + attrs.Add(new(kvp.Key, valueInt)); } - else if (kvp.Key.EndsWith("_s")) + else if (kvp.Value is string valueString) { - attrs[i] = new(kvp.Key, (string)kvp.Value); + attrs.Add(new(kvp.Key, valueString)); } - i++; } return new JsonObject(attrs); diff --git a/UT4MasterServer.Services/Hosted/ApplicationBackgroundService.cs b/UT4MasterServer.Services/Hosted/ApplicationBackgroundService.cs index 5018c65a..cee7749f 100644 --- a/UT4MasterServer.Services/Hosted/ApplicationBackgroundService.cs +++ b/UT4MasterServer.Services/Hosted/ApplicationBackgroundService.cs @@ -49,32 +49,45 @@ private void DoWork(object? state) { Task.Run(async () => { - using IServiceScope? scope = services.CreateScope(); - - SessionService? sessionService = scope.ServiceProvider.GetRequiredService(); - var deleteCount = await sessionService.RemoveAllExpiredSessionsAsync(); - if (deleteCount > 0) + try { - logger.LogInformation("Background task deleted {DeleteCount} expired sessions.", deleteCount); + await DoWorkAsync(); } - - CodeService? codeService = scope.ServiceProvider.GetRequiredService(); - deleteCount = await codeService.RemoveAllExpiredCodesAsync(); - if (deleteCount > 0) + catch (Exception ex) { - logger.LogInformation("Background task deleted {DeleteCount} expired codes.", deleteCount); + // without this, a failing cleanup pass faults the task and is silently discarded + logger.LogError(ex, "Background cleanup task failed."); } + }); + } - MatchmakingService? matchmakingService = scope.ServiceProvider.GetRequiredService(); - deleteCount = await matchmakingService.RemoveAllStaleAsync(); - if (deleteCount > 0) - { - logger.LogInformation("Background task deleted {DeleteCount} stale game servers.", deleteCount); - } + private async Task DoWorkAsync() + { + using IServiceScope? scope = services.CreateScope(); - await DeleteOldStatisticsAsync(scope); - await MergeOldStatisticsAsync(scope); - }); + SessionService? sessionService = scope.ServiceProvider.GetRequiredService(); + var deleteCount = await sessionService.RemoveAllExpiredSessionsAsync(); + if (deleteCount > 0) + { + logger.LogInformation("Background task deleted {DeleteCount} expired sessions.", deleteCount); + } + + CodeService? codeService = scope.ServiceProvider.GetRequiredService(); + deleteCount = await codeService.RemoveAllExpiredCodesAsync(); + if (deleteCount > 0) + { + logger.LogInformation("Background task deleted {DeleteCount} expired codes.", deleteCount); + } + + MatchmakingService? matchmakingService = scope.ServiceProvider.GetRequiredService(); + deleteCount = await matchmakingService.RemoveAllStaleAsync(); + if (deleteCount > 0) + { + logger.LogInformation("Background task deleted {DeleteCount} stale game servers.", deleteCount); + } + + await DeleteOldStatisticsAsync(scope); + await MergeOldStatisticsAsync(scope); } public void Dispose() diff --git a/UT4MasterServer.Services/Scoped/RatingsService.cs b/UT4MasterServer.Services/Scoped/RatingsService.cs index 1ada8733..a5a92579 100644 --- a/UT4MasterServer.Services/Scoped/RatingsService.cs +++ b/UT4MasterServer.Services/Scoped/RatingsService.cs @@ -144,9 +144,9 @@ public async Task GetAverageTeamRatingAsync(string ratingType, R return new RankingsResponse() { Rank = rank, - AccountID = account.ID, - Player = account.Username, - CountryFlag = account.CountryFlag, + AccountID = accountID, + Player = account?.Username ?? UnknownUser, + CountryFlag = account?.CountryFlag ?? DefaultCountryFlag, Rating = selectedRating.RatingValue / Rating.Precision, GamesPlayed = selectedRating.GamesPlayed }; diff --git a/UT4MasterServer.Services/Singleton/MatchmakingWaitTimeEstimateService.cs b/UT4MasterServer.Services/Singleton/MatchmakingWaitTimeEstimateService.cs index 16458e3c..33ac9aeb 100644 --- a/UT4MasterServer.Services/Singleton/MatchmakingWaitTimeEstimateService.cs +++ b/UT4MasterServer.Services/Singleton/MatchmakingWaitTimeEstimateService.cs @@ -14,29 +14,36 @@ public MatchmakingWaitTimeEstimateService() public void AddWaitTime(string mode, double seconds) { - if (!estimates.TryGetValue(mode, out List<(DateTime DeleteTime, double WaitTime)>? estimateValue)) + lock (estimates) { - estimateValue = new List<(DateTime, double)>(); - estimates.Add(mode, estimateValue); - } + if (!estimates.TryGetValue(mode, out List<(DateTime DeleteTime, double WaitTime)>? estimateValue)) + { + estimateValue = new List<(DateTime, double)>(); + estimates.Add(mode, estimateValue); + } - estimateValue.Add((DateTime.UtcNow + RelevantReportTimeDuration, seconds)); + estimateValue.Add((DateTime.UtcNow + RelevantReportTimeDuration, seconds)); + } } public List GetWaitTimes() { - Clean(); - var waitTimes = new List(); - foreach (KeyValuePair> estimate in estimates) + + lock (estimates) { - if (estimate.Value.Count <= 0) + Clean(); + + foreach (KeyValuePair> estimate in estimates) { - continue; - } + if (estimate.Value.Count <= 0) + { + continue; + } - var estimatedModeWait = estimate.Value.Average(x => x.WaitTime); - waitTimes.Add(new WaitTimeEstimateResponse(estimate.Key, estimatedModeWait, estimate.Value.Count)); + var estimatedModeWait = estimate.Value.Average(x => x.WaitTime); + waitTimes.Add(new WaitTimeEstimateResponse(estimate.Key, estimatedModeWait, estimate.Value.Count)); + } } return waitTimes; diff --git a/UT4MasterServer/Controllers/AdminPanelController.cs b/UT4MasterServer/Controllers/AdminPanelController.cs index f1171e41..87933545 100644 --- a/UT4MasterServer/Controllers/AdminPanelController.cs +++ b/UT4MasterServer/Controllers/AdminPanelController.cs @@ -236,6 +236,8 @@ public async Task ChangePassword(string id, [FromBody] AdminPanel // as well as prevent anyone else from using this account after successful password change. await sessionService.RemoveSessionsWithFilterAsync(EpicID.Empty, account.ID, EpicID.Empty); + logLevel = LogLevel.Information; + return Ok(); } finally @@ -375,7 +377,7 @@ public async Task UpdateClient(string id, [FromBody] Client clien if (IsSpecialClientID(eid)) { - return Forbid("Cannot modify reserved clients"); + return StatusCode(StatusCodes.Status403Forbidden, "Cannot modify reserved clients"); } Task? taskUpdateClient = clientService.UpdateAsync(client); @@ -396,7 +398,7 @@ public async Task DeleteClient(string id) if (IsSpecialClientID(eid)) { - return Forbid("Cannot delete reserved clients"); + return StatusCode(StatusCodes.Status403Forbidden, "Cannot delete reserved clients"); } var success = await clientService.RemoveAsync(eid); @@ -585,7 +587,7 @@ public async Task DeleteMCPFile(string filename) if (await cloudStorageService.DeleteFileAsync(EpicID.Empty, filename) != true) { - return Forbid("Cannot delete file. Either this file is not deletable or something went wrong."); + return StatusCode(StatusCodes.Status403Forbidden, "Cannot delete file. Either this file is not deletable or something went wrong."); } return Ok(); } diff --git a/UT4MasterServer/Controllers/Epic/AccountController.cs b/UT4MasterServer/Controllers/Epic/AccountController.cs index 98093a11..cd1f855d 100644 --- a/UT4MasterServer/Controllers/Epic/AccountController.cs +++ b/UT4MasterServer/Controllers/Epic/AccountController.cs @@ -26,6 +26,9 @@ public sealed class AccountController : JsonAPIController private readonly AccountService accountService; private readonly IOptions reCaptchaSettings; + // reuse a single HttpClient instead of creating (and leaking) one per request + private static readonly HttpClient httpClient = new(); + public AccountController(ILogger logger, AccountService accountService, SessionService sessionService, IOptions reCaptchaSettings) : base(logger) { this.accountService = accountService; @@ -201,8 +204,7 @@ public async Task RegisterAccount([FromForm] string username, [Fr return Conflict("Recaptcha token is missing"); } - var httpClient = new HttpClient(); - HttpResponseMessage httpResponse = await httpClient.GetAsync($"https://www.google.com/recaptcha/api/siteverify?secret={reCaptchaSecret}&response={recaptchaToken}"); + using HttpResponseMessage httpResponse = await httpClient.GetAsync($"https://www.google.com/recaptcha/api/siteverify?secret={Uri.EscapeDataString(reCaptchaSecret)}&response={Uri.EscapeDataString(recaptchaToken)}"); if (httpResponse.StatusCode != System.Net.HttpStatusCode.OK) { return Conflict("Recaptcha validation failed"); diff --git a/UT4MasterServer/Controllers/Epic/CloudStorageController.cs b/UT4MasterServer/Controllers/Epic/CloudStorageController.cs index a9534c79..71086e9c 100644 --- a/UT4MasterServer/Controllers/Epic/CloudStorageController.cs +++ b/UT4MasterServer/Controllers/Epic/CloudStorageController.cs @@ -2,6 +2,7 @@ using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.StaticFiles; +using Newtonsoft.Json.Linq; using UT4MasterServer.Authentication; using UT4MasterServer.Common; using UT4MasterServer.Models.Database; @@ -76,7 +77,14 @@ public async Task GetFile(string id, string filename) playerID = account.ID; } - file = new CloudFile() { RawContent = Encoding.UTF8.GetBytes($"{{\"PlayerName\":\"{playerName}\",\"StatsID\":\"{playerID}\",\"Version\":0}}") }; + // build via JObject so a username containing '"' or '\' cannot break the json + var fakeStats = new JObject + { + { "PlayerName", playerName }, + { "StatsID", playerID.ToString() }, + { "Version", 0 } + }; + file = new CloudFile() { RawContent = Encoding.UTF8.GetBytes(fakeStats.ToString(Newtonsoft.Json.Formatting.None)) }; } if (isStatsFile) diff --git a/UT4MasterServer/Controllers/UT/MatchmakingController.cs b/UT4MasterServer/Controllers/UT/MatchmakingController.cs index be524a2a..8966ce3e 100644 --- a/UT4MasterServer/Controllers/UT/MatchmakingController.cs +++ b/UT4MasterServer/Controllers/UT/MatchmakingController.cs @@ -144,7 +144,7 @@ public async Task UpdateGameServer(string id, [FromBody] GameServ if (server.OwningClientID != user.Session.ClientID) { - Unauthorized(); + return Unauthorized(); } server.Update(updatedServer); @@ -192,7 +192,7 @@ public async Task DeleteGameServer(string id) if (server.OwningClientID != user.Session.ClientID) { - Unauthorized(); + return Unauthorized(); } var wasDeleted = await matchmakingService.RemoveAsync(EpicID.FromString(id)); @@ -235,7 +235,7 @@ public async Task GameServerHeartbeat(string id) if (server.OwningClientID != user.Session.ClientID) { - Unauthorized(); + return Unauthorized(); } #if false @@ -289,31 +289,25 @@ public async Task UpdateGameServerPlayers(string id, [FromBody] G if (server.OwningClientID != user.Session.ClientID) { - Unauthorized(); + return Unauthorized(); } // handle player list update foreach (EpicID player in serverOnlyWithPlayers.PublicPlayers) { - if (!server.PublicPlayers.Where(x => x == player).Any()) + if (!server.PublicPlayers.Contains(player)) { server.PublicPlayers.Add(player); } - if (server.PrivatePlayers.Where(x => x == player).Any()) - { - server.PrivatePlayers.Remove(player); - } + server.PrivatePlayers.Remove(player); } foreach (EpicID player in serverOnlyWithPlayers.PrivatePlayers) { - if (!server.PrivatePlayers.Where(x => x == player).Any()) + if (!server.PrivatePlayers.Contains(player)) { server.PrivatePlayers.Add(player); } - if (server.PublicPlayers.Where(x => x == player).Any()) - { - server.PublicPlayers.Remove(player); - } + server.PublicPlayers.Remove(player); } await matchmakingService.UpdateAsync(server); @@ -337,7 +331,7 @@ public async Task RemovePlayer(string id, [FromBody] EpicID[] pla if (server.OwningClientID != user.Session.ClientID) { - Unauthorized(); + return Unauthorized(); } foreach (EpicID player in players) @@ -468,7 +462,7 @@ private async Task ChangeGameServerStarted(string id, bool starte if (server.OwningClientID != user.Session.ClientID) { - Unauthorized(); + return Unauthorized(); } server.Started = started; diff --git a/UT4MasterServer/Controllers/UT/ProfileController.cs b/UT4MasterServer/Controllers/UT/ProfileController.cs index 49b688ae..90b0ee6e 100644 --- a/UT4MasterServer/Controllers/UT/ProfileController.cs +++ b/UT4MasterServer/Controllers/UT/ProfileController.cs @@ -303,6 +303,13 @@ public async Task GrantXP(string id, string clientKind, [FromQuer const double maxXPPerHour = 500.0; var hoursSinceLastMatch = (DateTime.UtcNow - acc.LastMatchAt).TotalHours; + // XP can only be granted, never taken away + if (body.XPAmount < 0) + { + logger.LogWarning("{User} supposedly earned negative XP ({XP}) in a match.", acc.ToString(), body.XPAmount); + body.XPAmount = 0; + } + // this is just some hard limit on max xp allowed per request/match if (body.XPAmount > 300) { diff --git a/UT4MasterServer/Controllers/UT/RatingsController.cs b/UT4MasterServer/Controllers/UT/RatingsController.cs index 419e0e8a..96767682 100644 --- a/UT4MasterServer/Controllers/UT/RatingsController.cs +++ b/UT4MasterServer/Controllers/UT/RatingsController.cs @@ -154,11 +154,29 @@ public async Task GetRankings(string ratingType, int skip, int li return BadRequest($"'{ratingType}' is not supported rating type."); } + // this endpoint is anonymous, keep paging within sane bounds + (skip, limit) = ClampPaging(skip, limit); + PagedResponse? response = await ratingsService.GetRankingsAsync(ratingType, skip, limit); return Ok(response); } + internal static (int Skip, int Limit) ClampPaging(int skip, int limit) + { + if (skip < 0) + { + skip = 0; + } + + if (limit < 1 || limit > 100) + { + limit = 100; + } + + return (skip, limit); + } + [HttpGet("ranking/{accountId}")] public async Task GetRanking(string ratingType, string accountId) { diff --git a/UT4MasterServer/Formatters/StatisticBaseInputFormatter.cs b/UT4MasterServer/Formatters/StatisticBaseInputFormatter.cs index 7cfbaab9..c3365959 100644 --- a/UT4MasterServer/Formatters/StatisticBaseInputFormatter.cs +++ b/UT4MasterServer/Formatters/StatisticBaseInputFormatter.cs @@ -1,4 +1,5 @@ using Microsoft.AspNetCore.Mvc.Formatters; +using System.Diagnostics.CodeAnalysis; using System.Text.Json; using UT4MasterServer.Models.Database; @@ -17,11 +18,40 @@ public override async Task ReadRequestBodyAsync(InputForma var rawValue = await reader.ReadToEndAsync(); - StatisticBase? newObject = JsonSerializer.Deserialize(rawValue[..^1]); + if (!TryParse(rawValue, out StatisticBase? newObject)) + { + return InputFormatterResult.Failure(); + } return InputFormatterResult.Success(newObject); } + internal static bool TryParse(string rawValue, [NotNullWhen(true)] out StatisticBase? result) + { + result = null; + + // the game terminates this json body with a trailing NUL character. + // strip it only when present instead of blindly removing the last + // character, which corrupted well-formed bodies and threw on empty ones. + var json = rawValue.TrimEnd('\0'); + if (string.IsNullOrWhiteSpace(json)) + { + return false; + } + + try + { + result = JsonSerializer.Deserialize(json); + } + catch (JsonException) + { + return false; + } + + // result is null when the body was the json literal "null" + return result is not null; + } + protected override bool CanReadType(Type type) { return type == typeof(StatisticBase); diff --git a/UT4MasterServer/Program.cs b/UT4MasterServer/Program.cs index 4f8b9e69..357ed64f 100644 --- a/UT4MasterServer/Program.cs +++ b/UT4MasterServer/Program.cs @@ -221,8 +221,8 @@ public static void Main(string[] args) } //app.UseHttpsRedirection(); - app.UseAuthorization(); app.UseAuthentication(); + app.UseAuthorization(); app.MapControllers(); app.UseStaticFiles(); app.UseExceptionHandler("/api/errors"); diff --git a/UT4MasterServer/UT4MasterServer.csproj b/UT4MasterServer/UT4MasterServer.csproj index 89413a05..3bc80698 100644 --- a/UT4MasterServer/UT4MasterServer.csproj +++ b/UT4MasterServer/UT4MasterServer.csproj @@ -27,4 +27,8 @@ + + + +