diff --git a/.Tests/XUnit.Tests/GameServerAttributeTest.cs b/.Tests/XUnit.Tests/GameServerAttributeTest.cs index 45e2b2ae..758ce243 100644 --- a/.Tests/XUnit.Tests/GameServerAttributeTest.cs +++ b/.Tests/XUnit.Tests/GameServerAttributeTest.cs @@ -1,8 +1,4 @@ -using System.Text; -using System.Text.Json; -using UT4MasterServer.Models; - -namespace XUnit.Tests; +namespace XUnit.Tests; #if false public class GameServerAttributeTest diff --git a/.Tests/XUnit.Tests/StringHelperTest.cs b/.Tests/XUnit.Tests/StringHelperTest.cs index 4973df78..61d51197 100644 --- a/.Tests/XUnit.Tests/StringHelperTest.cs +++ b/.Tests/XUnit.Tests/StringHelperTest.cs @@ -1,5 +1,5 @@ using System.Text; -using UT4MasterServer.Helpers; +using UT4MasterServer.Common.Helpers; namespace XUnit.Tests; diff --git a/UT4MasterServer/Authentication/BasicAuthenticationAttribute.cs b/UT4MasterServer.Authentication/BasicAuthenticationAttribute.cs similarity index 100% rename from UT4MasterServer/Authentication/BasicAuthenticationAttribute.cs rename to UT4MasterServer.Authentication/BasicAuthenticationAttribute.cs diff --git a/UT4MasterServer/Authentication/BasicAuthenticationHandler.cs b/UT4MasterServer.Authentication/BasicAuthenticationHandler.cs similarity index 91% rename from UT4MasterServer/Authentication/BasicAuthenticationHandler.cs rename to UT4MasterServer.Authentication/BasicAuthenticationHandler.cs index 06d39926..99e50502 100644 --- a/UT4MasterServer/Authentication/BasicAuthenticationHandler.cs +++ b/UT4MasterServer.Authentication/BasicAuthenticationHandler.cs @@ -1,11 +1,10 @@ -// Copyright (c) Barry Dorrans. All rights reserved. -// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. - using Microsoft.AspNetCore.Authentication; +using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; using System.Security.Claims; using System.Text.Encodings.Web; -using UT4MasterServer.Services; +using UT4MasterServer.Models; +using UT4MasterServer.Services.Scoped; namespace UT4MasterServer.Authentication; diff --git a/UT4MasterServer/Authentication/BearerAuthenticationAttribute.cs b/UT4MasterServer.Authentication/BearerAuthenticationAttribute.cs similarity index 100% rename from UT4MasterServer/Authentication/BearerAuthenticationAttribute.cs rename to UT4MasterServer.Authentication/BearerAuthenticationAttribute.cs diff --git a/UT4MasterServer/Authentication/BearerAuthenticationHandler.cs b/UT4MasterServer.Authentication/BearerAuthenticationHandler.cs similarity index 95% rename from UT4MasterServer/Authentication/BearerAuthenticationHandler.cs rename to UT4MasterServer.Authentication/BearerAuthenticationHandler.cs index 6dd7e789..6be93429 100644 --- a/UT4MasterServer/Authentication/BearerAuthenticationHandler.cs +++ b/UT4MasterServer.Authentication/BearerAuthenticationHandler.cs @@ -2,10 +2,11 @@ // Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. using Microsoft.AspNetCore.Authentication; +using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; using System.Security.Claims; using System.Text.Encodings.Web; -using UT4MasterServer.Services; +using UT4MasterServer.Services.Scoped; namespace UT4MasterServer.Authentication; diff --git a/UT4MasterServer/Authentication/EpicClientIdentity.cs b/UT4MasterServer.Authentication/EpicClientIdentity.cs similarity index 91% rename from UT4MasterServer/Authentication/EpicClientIdentity.cs rename to UT4MasterServer.Authentication/EpicClientIdentity.cs index 82d566cd..39ff1d14 100644 --- a/UT4MasterServer/Authentication/EpicClientIdentity.cs +++ b/UT4MasterServer.Authentication/EpicClientIdentity.cs @@ -1,5 +1,5 @@ using System.Security.Claims; -using UT4MasterServer.Models; +using UT4MasterServer.Models.Database; namespace UT4MasterServer.Authentication; diff --git a/UT4MasterServer/Authentication/EpicUserIdentity.cs b/UT4MasterServer.Authentication/EpicUserIdentity.cs similarity index 93% rename from UT4MasterServer/Authentication/EpicUserIdentity.cs rename to UT4MasterServer.Authentication/EpicUserIdentity.cs index 8622f6be..18d71ae0 100644 --- a/UT4MasterServer/Authentication/EpicUserIdentity.cs +++ b/UT4MasterServer.Authentication/EpicUserIdentity.cs @@ -1,5 +1,5 @@ using System.Security.Claims; -using UT4MasterServer.Models; +using UT4MasterServer.Models.Database; namespace UT4MasterServer.Authentication; diff --git a/UT4MasterServer/Authentication/HttpAuthorization.cs b/UT4MasterServer.Authentication/HttpAuthorization.cs similarity index 100% rename from UT4MasterServer/Authentication/HttpAuthorization.cs rename to UT4MasterServer.Authentication/HttpAuthorization.cs diff --git a/UT4MasterServer.Authentication/UT4MasterServer.Authentication.csproj b/UT4MasterServer.Authentication/UT4MasterServer.Authentication.csproj new file mode 100644 index 00000000..925e2e51 --- /dev/null +++ b/UT4MasterServer.Authentication/UT4MasterServer.Authentication.csproj @@ -0,0 +1,19 @@ + + + + net6.0 + enable + enable + + + + + + + + + + + + + diff --git a/UT4MasterServer.Common/Enums/GameServerTrust.cs b/UT4MasterServer.Common/Enums/GameServerTrust.cs new file mode 100644 index 00000000..771db547 --- /dev/null +++ b/UT4MasterServer.Common/Enums/GameServerTrust.cs @@ -0,0 +1,8 @@ +namespace UT4MasterServer.Common.Enums; + +public enum GameServerTrust +{ + Epic = 0, + Trusted = 1, + Untrusted = 2 +} diff --git a/UT4MasterServer.Common/Enums/OwnerType.cs b/UT4MasterServer.Common/Enums/OwnerType.cs new file mode 100644 index 00000000..25f70cef --- /dev/null +++ b/UT4MasterServer.Common/Enums/OwnerType.cs @@ -0,0 +1,6 @@ +namespace UT4MasterServer.Common.Enums; + +public enum OwnerType +{ + Default = 1, +} diff --git a/UT4MasterServer/Enums/StatisticWindow.cs b/UT4MasterServer.Common/Enums/StatisticWindow.cs similarity index 70% rename from UT4MasterServer/Enums/StatisticWindow.cs rename to UT4MasterServer.Common/Enums/StatisticWindow.cs index 0c88e397..728b6490 100644 --- a/UT4MasterServer/Enums/StatisticWindow.cs +++ b/UT4MasterServer.Common/Enums/StatisticWindow.cs @@ -1,4 +1,4 @@ -namespace UT4MasterServer.Enums; +namespace UT4MasterServer.Common.Enums; public enum StatisticWindow { diff --git a/UT4MasterServer/Other/EpicID.cs b/UT4MasterServer.Common/EpicID.cs similarity index 97% rename from UT4MasterServer/Other/EpicID.cs rename to UT4MasterServer.Common/EpicID.cs index 46304da3..9edb53ab 100644 --- a/UT4MasterServer/Other/EpicID.cs +++ b/UT4MasterServer.Common/EpicID.cs @@ -1,10 +1,10 @@ using MongoDB.Bson.Serialization; using MongoDB.Bson.Serialization.Attributes; using System.Security.Cryptography; -using UT4MasterServer.Exceptions; -using UT4MasterServer.Helpers; +using UT4MasterServer.Common.Helpers; +using UT4MasterServer.Common.Exceptions; -namespace UT4MasterServer.Other; +namespace UT4MasterServer.Common; [Serializable] public struct EpicID : IComparable, IEquatable, IConvertible, IBsonClassMapAttribute diff --git a/UT4MasterServer/Exceptions/InvalidEpicIDException.cs b/UT4MasterServer.Common/Exceptions/InvalidEpicIDException.cs similarity index 96% rename from UT4MasterServer/Exceptions/InvalidEpicIDException.cs rename to UT4MasterServer.Common/Exceptions/InvalidEpicIDException.cs index 1d2a1863..7ce9e26c 100644 --- a/UT4MasterServer/Exceptions/InvalidEpicIDException.cs +++ b/UT4MasterServer.Common/Exceptions/InvalidEpicIDException.cs @@ -1,6 +1,6 @@ using System.Runtime.Serialization; -namespace UT4MasterServer.Exceptions; +namespace UT4MasterServer.Common.Exceptions; [Serializable] public class InvalidEpicIDException : Exception diff --git a/UT4MasterServer/Other/DateTimeExtension.cs b/UT4MasterServer.Common/Helpers/DateTimeExtension.cs similarity index 92% rename from UT4MasterServer/Other/DateTimeExtension.cs rename to UT4MasterServer.Common/Helpers/DateTimeExtension.cs index e2db4c53..3eed0d59 100644 --- a/UT4MasterServer/Other/DateTimeExtension.cs +++ b/UT4MasterServer.Common/Helpers/DateTimeExtension.cs @@ -1,4 +1,4 @@ -namespace UT4MasterServer.Other; +namespace UT4MasterServer.Common.Helpers; public static class DateTimeExtension { diff --git a/UT4MasterServer/Helpers/PasswordHelper.cs b/UT4MasterServer.Common/Helpers/PasswordHelper.cs similarity index 90% rename from UT4MasterServer/Helpers/PasswordHelper.cs rename to UT4MasterServer.Common/Helpers/PasswordHelper.cs index 4e09ae68..a88ab124 100644 --- a/UT4MasterServer/Helpers/PasswordHelper.cs +++ b/UT4MasterServer.Common/Helpers/PasswordHelper.cs @@ -1,8 +1,7 @@ using System.Security.Cryptography; using System.Text; -using UT4MasterServer.Other; -namespace UT4MasterServer.Helpers; +namespace UT4MasterServer.Common.Helpers; public static class PasswordHelper { diff --git a/UT4MasterServer.Common/Helpers/StreamExtensions.cs b/UT4MasterServer.Common/Helpers/StreamExtensions.cs new file mode 100644 index 00000000..869f1d11 --- /dev/null +++ b/UT4MasterServer.Common/Helpers/StreamExtensions.cs @@ -0,0 +1,37 @@ +using System.Buffers; +using System.Text; + +namespace UT4MasterServer.Common.Helpers; + +public static class StreamExtensions +{ + public static async Task ReadAsStringAsync(this Stream stream, int maxByteLength) + { + return Encoding.UTF8.GetString(await stream.ReadAsBytesAsync(maxByteLength)); + } + + public static async Task ReadAsBytesAsync(this Stream stream, int maxByteLength) + { + var bytes = ArrayPool.Shared.Rent(maxByteLength); + int fillCount = 0; + + while (true) + { + int readCount = await stream.ReadAsync(bytes.AsMemory(fillCount, bytes.Length - fillCount)); + + if (readCount <= 0) + { + // EOF reached + break; + } + + fillCount += readCount; + } + + byte[] ret = new byte[fillCount]; + Array.Copy(bytes, ret, fillCount); + ArrayPool.Shared.Return(bytes); + + return ret; + } +} diff --git a/UT4MasterServer/Helpers/StringExtensions.cs b/UT4MasterServer.Common/Helpers/StringExtensions.cs similarity index 88% rename from UT4MasterServer/Helpers/StringExtensions.cs rename to UT4MasterServer.Common/Helpers/StringExtensions.cs index 28206bfc..d86f77ed 100644 --- a/UT4MasterServer/Helpers/StringExtensions.cs +++ b/UT4MasterServer.Common/Helpers/StringExtensions.cs @@ -1,4 +1,4 @@ -namespace UT4MasterServer.Helpers; +namespace UT4MasterServer.Common.Helpers; public static class StringExtensions { @@ -11,7 +11,7 @@ public static bool IsHexString(this string input) foreach (var c in input) { - if (!((c >= 'a' && c <= 'f') || (c >= 'A' && c <= 'F') || (c >= '0' && c <= '9'))) + if (!(c >= 'a' && c <= 'f' || c >= 'A' && c <= 'F' || c >= '0' && c <= '9')) { return false; } diff --git a/UT4MasterServer/Helpers/ValidationHelper.cs b/UT4MasterServer.Common/Helpers/ValidationHelper.cs similarity index 96% rename from UT4MasterServer/Helpers/ValidationHelper.cs rename to UT4MasterServer.Common/Helpers/ValidationHelper.cs index 62c8483f..f45f357d 100644 --- a/UT4MasterServer/Helpers/ValidationHelper.cs +++ b/UT4MasterServer.Common/Helpers/ValidationHelper.cs @@ -1,7 +1,6 @@ -using Microsoft.AspNetCore.Mvc; using System.Text.RegularExpressions; -namespace UT4MasterServer.Helpers; +namespace UT4MasterServer.Common.Helpers; public static class ValidationHelper { diff --git a/UT4MasterServer.Common/UT4MasterServer.Common.csproj b/UT4MasterServer.Common/UT4MasterServer.Common.csproj new file mode 100644 index 00000000..adfe6ae2 --- /dev/null +++ b/UT4MasterServer.Common/UT4MasterServer.Common.csproj @@ -0,0 +1,13 @@ + + + + net6.0 + enable + enable + + + + + + + diff --git a/UT4MasterServer/Authentication/ClientIdentification.cs b/UT4MasterServer.Models/ClientIdentification.cs similarity index 81% rename from UT4MasterServer/Authentication/ClientIdentification.cs rename to UT4MasterServer.Models/ClientIdentification.cs index c0213105..746bb8c7 100644 --- a/UT4MasterServer/Authentication/ClientIdentification.cs +++ b/UT4MasterServer.Models/ClientIdentification.cs @@ -1,20 +1,20 @@ using System.Text; -using UT4MasterServer.Helpers; -using UT4MasterServer.Other; +using UT4MasterServer.Common.Helpers; +using UT4MasterServer.Common; -namespace UT4MasterServer.Authentication; +namespace UT4MasterServer.Models; public class ClientIdentification { - public static ClientIdentification Launcher = new ClientIdentification( + public readonly static ClientIdentification Launcher = new( EpicID.FromString("34a02cf8f4414e29b15921876da36f9a"), "daafbccc737745039dffe53d94fc76cf" ); - public static ClientIdentification Game = new ClientIdentification( + public readonly static ClientIdentification Game = new( EpicID.FromString("1252412dc7704a9690f6ea4611bc81ee"), "2ca0c925b4674852bff92b26f8322434" ); - public static ClientIdentification ServerInstance = new ClientIdentification( + public readonly static ClientIdentification ServerInstance = new( EpicID.FromString("6ff43e743edc4d1dbac3594877b4bed9"), "54619d6f84d443e195200b54ab649a53" ); diff --git a/UT4MasterServer/Models/Requests/AccountSearchRequest.cs b/UT4MasterServer.Models/DTO/Request/AccountSearchRequest.cs similarity index 100% rename from UT4MasterServer/Models/Requests/AccountSearchRequest.cs rename to UT4MasterServer.Models/DTO/Request/AccountSearchRequest.cs diff --git a/UT4MasterServer/Models/Requests/AdminPanelChangePasswordRequest.cs b/UT4MasterServer.Models/DTO/Request/AdminPanelChangePasswordRequest.cs similarity index 84% rename from UT4MasterServer/Models/Requests/AdminPanelChangePasswordRequest.cs rename to UT4MasterServer.Models/DTO/Request/AdminPanelChangePasswordRequest.cs index 38bda7ab..6332d0ee 100644 --- a/UT4MasterServer/Models/Requests/AdminPanelChangePasswordRequest.cs +++ b/UT4MasterServer.Models/DTO/Request/AdminPanelChangePasswordRequest.cs @@ -1,6 +1,6 @@ using System.Text.Json.Serialization; -namespace UT4MasterServer.Models.Requests; +namespace UT4MasterServer.Models.DTO.Request; public class AdminPanelChangePasswordRequest { diff --git a/UT4MasterServer/Models/GameServerFilter.cs b/UT4MasterServer.Models/DTO/Request/GameServerFilterRequest.cs similarity index 64% rename from UT4MasterServer/Models/GameServerFilter.cs rename to UT4MasterServer.Models/DTO/Request/GameServerFilterRequest.cs index 9f2c2f02..12a3071a 100644 --- a/UT4MasterServer/Models/GameServerFilter.cs +++ b/UT4MasterServer.Models/DTO/Request/GameServerFilterRequest.cs @@ -1,25 +1,6 @@ -/* - { - "criteria": [ - { - "type": "NOT_EQUAL", - "key": "UT_GAMEINSTANCE_i", - "value": 1 - }, - { - "type": "NOT_EQUAL", - "key": "UT_RANKED_i", - "value": 1 - } - ], - "buildUniqueId": "256652735", - "maxResults": 10000 - } -*/ - using System.Text.Json; -namespace UT4MasterServer.Models; +namespace UT4MasterServer.Models.DTO.Request; public class GameServerAttributeCriteria { @@ -29,7 +10,7 @@ public class GameServerAttributeCriteria public JsonElement Value { get; set; } = default; } -public class GameServerFilter +public class GameServerFilterRequest { public List Criteria { get; set; } = new(); public string? BuildUniqueId { get; set; } = null; diff --git a/UT4MasterServer/Models/Requests/GrantXP.cs b/UT4MasterServer.Models/DTO/Request/GrantXPRequest.cs similarity index 57% rename from UT4MasterServer/Models/Requests/GrantXP.cs rename to UT4MasterServer.Models/DTO/Request/GrantXPRequest.cs index fb7f7612..1c1e80b9 100644 --- a/UT4MasterServer/Models/Requests/GrantXP.cs +++ b/UT4MasterServer.Models/DTO/Request/GrantXPRequest.cs @@ -1,8 +1,8 @@ using System.Text.Json.Serialization; -namespace UT4MasterServer.Models.Requests; +namespace UT4MasterServer.Models.DTO.Request; -public sealed class GrantXP +public sealed class GrantXPRequest { [JsonPropertyName("xpAmount")] public int XPAmount { get; set; } diff --git a/UT4MasterServer/Models/Requests/SetStars.cs b/UT4MasterServer.Models/DTO/Request/SetStarsRequest.cs similarity index 70% rename from UT4MasterServer/Models/Requests/SetStars.cs rename to UT4MasterServer.Models/DTO/Request/SetStarsRequest.cs index 1c18f2cd..05570bd1 100644 --- a/UT4MasterServer/Models/Requests/SetStars.cs +++ b/UT4MasterServer.Models/DTO/Request/SetStarsRequest.cs @@ -1,8 +1,8 @@ using System.Text.Json.Serialization; -namespace UT4MasterServer.Models.Requests; +namespace UT4MasterServer.Models.DTO.Request; -public sealed class SetStars +public sealed class SetStarsRequest { [JsonPropertyName("newGoldStars")] public int NewGoldStars { get; set; } diff --git a/UT4MasterServer/Models/Requests/Entitlement.cs b/UT4MasterServer.Models/DTO/Response/EntitlementResponse.cs similarity index 93% rename from UT4MasterServer/Models/Requests/Entitlement.cs rename to UT4MasterServer.Models/DTO/Response/EntitlementResponse.cs index d99141de..15db68ad 100644 --- a/UT4MasterServer/Models/Requests/Entitlement.cs +++ b/UT4MasterServer.Models/DTO/Response/EntitlementResponse.cs @@ -1,9 +1,9 @@ using System.Text.Json.Serialization; -using UT4MasterServer.Other; +using UT4MasterServer.Common; -namespace UT4MasterServer.Models.Requests; +namespace UT4MasterServer.Models.DTO.Responses; -public class Entitlement +public class EntitlementResponse { /* @@ -79,7 +79,7 @@ public class Entitlement [JsonPropertyName("country")] public string Country { get; set; } = "US"; - public Entitlement(string name, string id, EpicID accountID) + public EntitlementResponse(string name, string id, EpicID accountID) { var commonDate = new DateTime(2023, 1, 1, 0, 0, 0, DateTimeKind.Utc); diff --git a/UT4MasterServer/Models/Error.cs b/UT4MasterServer.Models/DTO/Response/ErrorResponse.cs similarity index 88% rename from UT4MasterServer/Models/Error.cs rename to UT4MasterServer.Models/DTO/Response/ErrorResponse.cs index 946da321..f8ffbafd 100644 --- a/UT4MasterServer/Models/Error.cs +++ b/UT4MasterServer.Models/DTO/Response/ErrorResponse.cs @@ -1,35 +1,32 @@ -using Newtonsoft.Json; using System.Text.Json.Serialization; -namespace UT4MasterServer.Models; +namespace UT4MasterServer.Models.DTO.Responses; public class ErrorResponse { - [JsonProperty("errorCode")] + [JsonPropertyName("errorCode")] public string? ErrorCode { get; set; } - [JsonProperty("errorMessage")] + [JsonPropertyName("errorMessage")] public string? ErrorMessage { get; set; } - [JsonProperty("messageVars")] + [JsonPropertyName("messageVars")] public string[] MessageVars { get; set; } = Array.Empty(); // any value inside errorMessage is listed in this array - [JsonProperty("numericErrorCode")] + [JsonPropertyName("numericErrorCode")] public int NumericErrorCode { get; set; } - [JsonProperty("originatingService")] + [JsonPropertyName("originatingService")] public string? OriginatingService { get; set; } - [JsonProperty("intent")] + [JsonPropertyName("intent")] public string? Intent { get; set; } - // TODO: Use one JSON DLL in solution. See https://github.com/timiimit/UT4MasterServer/issues/33 - [JsonPropertyName("error_description")] // Fix for API response - [JsonProperty("error_description")] + [JsonPropertyName("error_description")] public string? ErrorDescription { get; set; } - [JsonProperty("error")] - public string? Error { get; set; } + [JsonPropertyName("error")] + public string? ErrorName { get; set; } public Task ExecuteAsync(CancellationToken cancellationToken) { diff --git a/UT4MasterServer/Models/League.cs b/UT4MasterServer.Models/DTO/Response/LeagueResponse.cs similarity index 94% rename from UT4MasterServer/Models/League.cs rename to UT4MasterServer.Models/DTO/Response/LeagueResponse.cs index d18f8c4b..14cb5233 100644 --- a/UT4MasterServer/Models/League.cs +++ b/UT4MasterServer.Models/DTO/Response/LeagueResponse.cs @@ -1,7 +1,6 @@ -using Newtonsoft.Json; using System.Text.Json.Serialization; -namespace UT4MasterServer.Models; +namespace UT4MasterServer.Models.DTO.Responses; /// /// Names taken from LeagueTierToText @@ -16,7 +15,7 @@ public enum LeagueTier GrandMasterLeague = 5, } -public class League +public class LeagueResponse { // i think this structure is in UT source, so it can be explored further there diff --git a/UT4MasterServer/Models/MMRBulk.cs b/UT4MasterServer.Models/DTO/Response/MMRBulkResponse.cs similarity index 68% rename from UT4MasterServer/Models/MMRBulk.cs rename to UT4MasterServer.Models/DTO/Response/MMRBulkResponse.cs index 1eaae558..9bb3b5be 100644 --- a/UT4MasterServer/Models/MMRBulk.cs +++ b/UT4MasterServer.Models/DTO/Response/MMRBulkResponse.cs @@ -1,16 +1,16 @@ -using Newtonsoft.Json; +using System.Text.Json.Serialization; -namespace UT4MasterServer.Models; +namespace UT4MasterServer.Models.DTO.Responses; -public class MMRBulk +public class MMRBulkResponse { - [JsonProperty("ratingTypes")] + [JsonPropertyName("ratingTypes")] public List RatingTypes { get; set; } = new List(); - [JsonProperty("ratings")] + [JsonPropertyName("ratings")] public List Ratings { get; set; } = new List(); - [JsonProperty("numGamesPlayed")] + [JsonPropertyName("numGamesPlayed")] public List NumGamesPlayed { get; set; } = new List(); } diff --git a/UT4MasterServer/Models/Requests/RatingResponse.cs b/UT4MasterServer.Models/DTO/Response/RatingResponse.cs similarity index 55% rename from UT4MasterServer/Models/Requests/RatingResponse.cs rename to UT4MasterServer.Models/DTO/Response/RatingResponse.cs index 15fad484..6ab605d5 100644 --- a/UT4MasterServer/Models/Requests/RatingResponse.cs +++ b/UT4MasterServer.Models/DTO/Response/RatingResponse.cs @@ -1,9 +1,9 @@ using System.Text.Json.Serialization; -namespace UT4MasterServer.Models.Requests; +namespace UT4MasterServer.Models.DTO.Responses; public sealed class RatingResponse { [JsonPropertyName("rating")] - public int Rating { get; set; } + public int RatingValue { get; set; } } diff --git a/UT4MasterServer/Models/Requests/RatingTeam.cs b/UT4MasterServer.Models/DTO/Response/RatingTeamResponse.cs similarity index 94% rename from UT4MasterServer/Models/Requests/RatingTeam.cs rename to UT4MasterServer.Models/DTO/Response/RatingTeamResponse.cs index bceeb3ae..5ba8e777 100644 --- a/UT4MasterServer/Models/Requests/RatingTeam.cs +++ b/UT4MasterServer.Models/DTO/Response/RatingTeamResponse.cs @@ -1,7 +1,7 @@ using System.Text.Json.Serialization; -using UT4MasterServer.Other; +using UT4MasterServer.Common; -namespace UT4MasterServer.Models.Requests; +namespace UT4MasterServer.Models.DTO.Responses; public sealed class RatingMatch { diff --git a/UT4MasterServer/DTOs/StatisticDTO.cs b/UT4MasterServer.Models/DTO/Response/StatisticResponse.cs similarity index 71% rename from UT4MasterServer/DTOs/StatisticDTO.cs rename to UT4MasterServer.Models/DTO/Response/StatisticResponse.cs index dbf17bf1..4f566706 100644 --- a/UT4MasterServer/DTOs/StatisticDTO.cs +++ b/UT4MasterServer.Models/DTO/Response/StatisticResponse.cs @@ -1,6 +1,6 @@ -using UT4MasterServer.Enums; +using UT4MasterServer.Common.Enums; -namespace UT4MasterServer.DTOs; +namespace UT4MasterServer.Models.DTO.Responses; public sealed class StatisticDTO { diff --git a/UT4MasterServer.Models/DTO/Response/TrustedGameServerResponse.cs b/UT4MasterServer.Models/DTO/Response/TrustedGameServerResponse.cs new file mode 100644 index 00000000..3cfb5f04 --- /dev/null +++ b/UT4MasterServer.Models/DTO/Response/TrustedGameServerResponse.cs @@ -0,0 +1,9 @@ +using UT4MasterServer.Models.Database; + +namespace UT4MasterServer.Models.Responses; + +public sealed class TrustedGameServerResponse : TrustedGameServer +{ + public Client? Client { get; set; } = null; + public Account? Owner { get; set; } = null; +} diff --git a/UT4MasterServer/Models/Requests/WaitTimeEstimateResponse.cs b/UT4MasterServer.Models/DTO/Response/WaitTimeEstimateResponse.cs similarity index 91% rename from UT4MasterServer/Models/Requests/WaitTimeEstimateResponse.cs rename to UT4MasterServer.Models/DTO/Response/WaitTimeEstimateResponse.cs index bbc87a91..fa0a2f81 100644 --- a/UT4MasterServer/Models/Requests/WaitTimeEstimateResponse.cs +++ b/UT4MasterServer.Models/DTO/Response/WaitTimeEstimateResponse.cs @@ -1,6 +1,6 @@ using System.Text.Json.Serialization; -namespace UT4MasterServer.Models.Requests; +namespace UT4MasterServer.Models.DTO.Responses; public sealed class WaitTimeEstimateResponse { diff --git a/UT4MasterServer/Models/Account.cs b/UT4MasterServer.Models/Database/Account.cs similarity index 83% rename from UT4MasterServer/Models/Account.cs rename to UT4MasterServer.Models/Database/Account.cs index 6f8b0030..01021be8 100644 --- a/UT4MasterServer/Models/Account.cs +++ b/UT4MasterServer.Models/Database/Account.cs @@ -1,9 +1,9 @@ using MongoDB.Bson.Serialization.Attributes; -using UT4MasterServer.Other; +using UT4MasterServer.Common; -namespace UT4MasterServer.Models; +namespace UT4MasterServer.Models.Database; using System.Text.Json.Serialization; -using UT4MasterServer.Helpers; +using UT4MasterServer.Common.Helpers; [Flags] public enum AccountFlags @@ -19,33 +19,6 @@ public enum AccountFlags [BsonIgnoreExtraElements] public class Account { - // { - // "id": "fd83abe496ca401497b5adf4e412bf2c", - // "displayName": "dc!", - // "name": "dc", - // "email": "email@email.email", - // "affiliationType": "Programmer", - // "failedLoginAttempts": 0, - // "lastLogin": "2022-12-14T23:39:48.417Z", - // "numberOfDisplayNameChanges": 0, - // "ageGroup": "UNKNOWN", - // "headless": false, - // "country": "CA", - // "lastName": "dc", - // "phoneNumber": "123", - // "preferredLanguage": "en", - // "canUpdateDisplayName": true, - // "tfaEnabled": true, - // "emailVerified": true, - // "minorVerified": false, - // "minorExpected": false, - // "minorStatus": "NOT_MINOR", - // "cabinedMode": false, - // "hasHashedEmail": false - //} - - // TODO: Figure out what fields ^^^^ are actually needed in this model. - [BsonId] public EpicID ID { get; set; } = EpicID.Empty; @@ -150,22 +123,22 @@ public float Level public int LevelStockLimited => Math.Min(50, (int)Level); [BsonIgnore] - public string[]? Roles - { - get - { - var flags = new List(); - var flagNamesAll = Enum.GetNames(); - var flagValuesAll = Enum.GetValues(); - for (int i = 1; i < flagNamesAll.Length; i++) - { - if (Flags.HasFlag(flagValuesAll[i])) - { - flags.Add(flagNamesAll[i]); - } - } - return flags.ToArray(); - } + public string[]? Roles + { + get + { + var flags = new List(); + var flagNamesAll = Enum.GetNames(); + var flagValuesAll = Enum.GetValues(); + for (int i = 1; i < flagNamesAll.Length; i++) + { + if (Flags.HasFlag(flagValuesAll[i])) + { + flags.Add(flagNamesAll[i]); + } + } + return flags.ToArray(); + } } public bool CheckPassword(string password, bool allowPasswordGrant) diff --git a/UT4MasterServer/Models/Client.cs b/UT4MasterServer.Models/Database/Client.cs similarity index 79% rename from UT4MasterServer/Models/Client.cs rename to UT4MasterServer.Models/Database/Client.cs index b9554db8..1e4c52a2 100644 --- a/UT4MasterServer/Models/Client.cs +++ b/UT4MasterServer.Models/Database/Client.cs @@ -1,8 +1,7 @@ using MongoDB.Bson.Serialization.Attributes; -using UT4MasterServer.Authentication; -using UT4MasterServer.Other; +using UT4MasterServer.Common; -namespace UT4MasterServer.Models; +namespace UT4MasterServer.Models.Database; public class Client { diff --git a/UT4MasterServer/Models/CloudFile.cs b/UT4MasterServer.Models/Database/CloudFile.cs similarity index 92% rename from UT4MasterServer/Models/CloudFile.cs rename to UT4MasterServer.Models/Database/CloudFile.cs index 69079485..ca037a5e 100644 --- a/UT4MasterServer/Models/CloudFile.cs +++ b/UT4MasterServer.Models/Database/CloudFile.cs @@ -1,7 +1,7 @@ using MongoDB.Bson.Serialization.Attributes; -using UT4MasterServer.Other; +using UT4MasterServer.Common; -namespace UT4MasterServer.Models; +namespace UT4MasterServer.Models.Database; [BsonIgnoreExtraElements] public class CloudFile diff --git a/UT4MasterServer/Models/Code.cs b/UT4MasterServer.Models/Database/Code.cs similarity index 86% rename from UT4MasterServer/Models/Code.cs rename to UT4MasterServer.Models/Database/Code.cs index 4a54c675..80d411cd 100644 --- a/UT4MasterServer/Models/Code.cs +++ b/UT4MasterServer.Models/Database/Code.cs @@ -1,8 +1,7 @@ using MongoDB.Bson.Serialization.Attributes; -using UT4MasterServer.Authentication; -using UT4MasterServer.Other; +using UT4MasterServer.Common; -namespace UT4MasterServer.Models; +namespace UT4MasterServer.Models.Database; public enum CodeKind { diff --git a/UT4MasterServer/Models/FriendRequest.cs b/UT4MasterServer.Models/Database/FriendRequest.cs similarity index 85% rename from UT4MasterServer/Models/FriendRequest.cs rename to UT4MasterServer.Models/Database/FriendRequest.cs index e405e45f..c49c4934 100644 --- a/UT4MasterServer/Models/FriendRequest.cs +++ b/UT4MasterServer.Models/Database/FriendRequest.cs @@ -1,7 +1,7 @@ using MongoDB.Bson.Serialization.Attributes; -using UT4MasterServer.Other; +using UT4MasterServer.Common; -namespace UT4MasterServer.Models; +namespace UT4MasterServer.Models.Database; public enum FriendStatus { diff --git a/UT4MasterServer/Models/GameServer.cs b/UT4MasterServer.Models/Database/GameServer.cs similarity index 64% rename from UT4MasterServer/Models/GameServer.cs rename to UT4MasterServer.Models/Database/GameServer.cs index de052dc3..002baa9c 100644 --- a/UT4MasterServer/Models/GameServer.cs +++ b/UT4MasterServer.Models/Database/GameServer.cs @@ -1,103 +1,13 @@ using MongoDB.Bson.Serialization.Attributes; -using Newtonsoft.Json.Linq; -using System.Text.Json; +using System.Text.Json.Nodes; using System.Text.Json.Serialization; -using UT4MasterServer.Other; +using UT4MasterServer.Common; +using UT4MasterServer.Common.Helpers; -namespace UT4MasterServer.Models; +namespace UT4MasterServer.Models.Database; -public enum GameServerTrust -{ - Epic = 0, - Trusted = 1, - Untrusted = 2 -} - -public class GameServerAttributes -{ - private readonly Dictionary serverConfigs; - - public GameServerAttributes() - { - serverConfigs = new Dictionary(); - } - - public void Set(string key, string? value) - { - SetDirect(key, value); - } - - public void Set(string key, int? value) - { - SetDirect(key, value); - } - - public void Set(string key, bool? value) - { - SetDirect(key, value); - } - - public void Update(GameServerAttributes other) - { - foreach (var attribute in other.serverConfigs) - { - if (attribute.Key == "UT_SERVERTRUSTLEVEL_i") - continue; // do not allow server to modify this attribute - - SetDirect(attribute.Key, attribute.Value); - } - } - - public bool Contains(string key) - { - return serverConfigs.ContainsKey(key); - } - - public object? Get(string key) - { - if (!Contains(key)) - return null; - return serverConfigs[key]; - } - public string[] GetKeys() - { - return serverConfigs.Keys.ToArray(); - } - public JObject ToJObject() - { - var obj = new JObject(); - - foreach (var kvp in serverConfigs) - { - if (kvp.Key.EndsWith("_b")) - obj.Add(kvp.Key, (bool)kvp.Value); - else if (kvp.Key.EndsWith("_i")) - obj.Add(kvp.Key, (int)kvp.Value); - else if (kvp.Key.EndsWith("_s")) - obj.Add(kvp.Key, (string)kvp.Value); - } - - return obj; - } - - private void SetDirect(string key, object? value) - { - if (value != null) - { - if (serverConfigs.ContainsKey(key)) - serverConfigs[key] = value; - else - serverConfigs.Add(key, value); - } - else - { - if (serverConfigs.ContainsKey(key)) - serverConfigs.Remove(key); - } - } -} public class GameServer { @@ -111,6 +21,9 @@ public class GameServer /// /// Each session is allowed to have only a single server /// +#if DEBUG + [JsonPropertyName("UT4MS__SESSION_ID__DEBUG_ONLY_VALUE")] +#endif [BsonElement("SessionID")] public EpicID SessionID { get; set; } = EpicID.Empty; @@ -120,14 +33,21 @@ public class GameServer /// /// All game servers owned by a single entity (hub owner) have the same client ID /// +#if DEBUG + [JsonPropertyName("UT4MS__OWNING_CLIENT_ID__DEBUG_ONLY_VALUE")] +#endif [BsonElement("OwningClientID")] public EpicID OwningClientID { get; set; } = EpicID.Empty; +#if DEBUG + [JsonPropertyName("UT4MS__LAST_KNOWN_MATCH_START_TIME__DEBUG_ONLY_VALUE")] +#endif [BsonElement("LastKnownMatchStartTime")] public DateTime LastKnownMatchStartTime { get; set; } = DateTime.UtcNow; #if DEBUG [BsonElement("SessionAccessToken")] + [JsonPropertyName("UT4MS__SESSION_TOKEN__DEBUG_ONLY_VALUE")] public string SessionAccessToken { get; set; } = string.Empty; #endif @@ -155,17 +75,17 @@ public class GameServer [JsonPropertyName("maxPublicPlayers")] public int MaxPublicPlayers { get; set; } = 10000; - //[BsonElement("OpenPublicPlayers")] - //[JsonPropertyName("openPublicPlayers")] - //public int OpenPublicPlayers { get; set; } = 10000 + [BsonIgnore] + [JsonPropertyName("openPublicPlayers")] + public int OpenPublicPlayers => MaxPublicPlayers - PublicPlayers.Count; [BsonElement("MaxPrivatePlayers")] [JsonPropertyName("maxPrivatePlayers")] public int MaxPrivatePlayers { get; set; } = 0; - //[BsonElement("OpenPrivatePlayers")] - //[JsonPropertyName("openPrivatePlayers")] - //public int OpenPrivatePlayers { get; set; } = 0; + [BsonIgnore] + [JsonPropertyName("openPrivatePlayers")] + public int OpenPrivatePlayers => MaxPrivatePlayers - PrivatePlayers.Count; [BsonElement("Attributes")] [JsonPropertyName("attributes")] @@ -308,65 +228,66 @@ public void Update(GameServer update) } } - public JObject ToJson(bool isResponseToClient) + public JsonObject ToJson(bool isResponseToClient) { + // TODO: Get rid of dynamic json + // Do some preprocessing on attributes var attrs = Attributes.ToJObject(); - if (attrs["UT_MATCHSTATE_s"]?.ToObject() == "InProgress" && attrs.ContainsKey("UT_MATCHDURATION_i")) + if (attrs["UT_MATCHSTATE_s"]?.ToString() == "InProgress" && attrs.ContainsKey("UT_MATCHDURATION_i")) { attrs["UT_MATCHDURATION_i"] = (int)(DateTime.UtcNow - LastKnownMatchStartTime).TotalSeconds; } - // build json - var obj = new JObject(); + var obj = new List>(); - obj.Add("id", ID.ToString()); + obj.Add(new("id", ID.ToString())); #if DEBUG - obj.Add("UT4MS__SESSION_ID__DEBUG_ONLY_VALUE", SessionID.ToString()); - obj.Add("UT4MS__SESSION_TOKEN__DEBUG_ONLY_VALUE", SessionAccessToken); - obj.Add("UT4MS__OWNING_CLIENT_ID__DEBUG_ONLY_VALUE", OwningClientID.ToString()); - obj.Add("UT4MS__LAST_KNOWN_MATCH_START_TIME__DEBUG_ONLY_VALUE", LastKnownMatchStartTime.ToStringISO()); + obj.Add(new("UT4MS__SESSION_ID__DEBUG_ONLY_VALUE", SessionID.ToString())); + obj.Add(new("UT4MS__SESSION_TOKEN__DEBUG_ONLY_VALUE", SessionAccessToken)); + obj.Add(new("UT4MS__OWNING_CLIENT_ID__DEBUG_ONLY_VALUE", OwningClientID.ToString())); + obj.Add(new("UT4MS__LAST_KNOWN_MATCH_START_TIME__DEBUG_ONLY_VALUE", LastKnownMatchStartTime.ToStringISO())); #endif - obj.Add("ownerId", OwnerID.ToString().ToUpper()); - obj.Add("ownerName", OwnerName); - obj.Add("serverName", ServerName); - obj.Add("serverAddress", ServerAddress); - obj.Add("serverPort", ServerPort); - obj.Add("maxPublicPlayers", MaxPublicPlayers); - obj.Add("openPublicPlayers", MaxPublicPlayers - PublicPlayers.Count); - obj.Add("maxPrivatePlayers", MaxPrivatePlayers); - obj.Add("openPrivatePlayers", MaxPrivatePlayers - PrivatePlayers.Count); - obj.Add("attributes", attrs); - JArray arr = new JArray(); + obj.Add(new("ownerId", OwnerID.ToString().ToUpper())); + obj.Add(new("ownerName", OwnerName)); + obj.Add(new("serverName", ServerName)); + obj.Add(new("serverAddress", ServerAddress)); + obj.Add(new("serverPort", ServerPort)); + obj.Add(new("maxPublicPlayers", MaxPublicPlayers)); + obj.Add(new("openPublicPlayers", MaxPublicPlayers - PublicPlayers.Count)); + obj.Add(new("maxPrivatePlayers", MaxPrivatePlayers)); + obj.Add(new("openPrivatePlayers", MaxPrivatePlayers - PrivatePlayers.Count)); + obj.Add(new("attributes", attrs)); + var arr = new JsonArray(); foreach (var player in PublicPlayers) { - arr.Add(player.ToString()); + arr.Add(JsonValue.Create(player.ToString())); } - obj.Add("publicPlayers", arr); - arr = new JArray(); + obj.Add(new("publicPlayers", arr)); + arr = new JsonArray(); foreach (var player in PrivatePlayers) { - arr.Add(player.ToString()); + arr.Add(JsonValue.Create(player.ToString())); } - obj.Add("privatePlayers", arr); - obj.Add("totalPlayers", PublicPlayers.Count + PrivatePlayers.Count); - obj.Add("allowJoinInProgress", AllowJoinInProgress); - obj.Add("shouldAdvertise", ShouldAdvertise); - obj.Add("isDedicated", IsDedicated); - obj.Add("usesStats", UsesStats); - obj.Add("allowInvites", AllowInvites); - obj.Add("usesPresence", UsesPresence); - obj.Add("allowJoinViaPresence", AllowJoinViaPresence); - obj.Add("allowJoinViaPresenceFriendsOnly", AllowJoinViaPresenceFriendsOnly); - obj.Add("buildUniqueId", BuildUniqueID); - obj.Add("lastUpdated", LastUpdated.ToStringISO()); - obj.Add("started", Started); + obj.Add(new("privatePlayers", arr)); + obj.Add(new("totalPlayers", PublicPlayers.Count + PrivatePlayers.Count)); + obj.Add(new("allowJoinInProgress", AllowJoinInProgress)); + obj.Add(new("shouldAdvertise", ShouldAdvertise)); + obj.Add(new("isDedicated", IsDedicated)); + obj.Add(new("usesStats", UsesStats)); + obj.Add(new("allowInvites", AllowInvites)); + obj.Add(new("usesPresence", UsesPresence)); + obj.Add(new("allowJoinViaPresence", AllowJoinViaPresence)); + obj.Add(new("allowJoinViaPresenceFriendsOnly", AllowJoinViaPresenceFriendsOnly)); + obj.Add(new("buildUniqueId", BuildUniqueID)); + obj.Add(new("lastUpdated", LastUpdated.ToStringISO())); + obj.Add(new("started", Started)); if (!isResponseToClient) { - obj.Add("sortWeights", SortWeight); + obj.Add(new("sortWeights", SortWeight)); } - return obj; + return new JsonObject(obj); } } diff --git a/UT4MasterServer/Models/Session.cs b/UT4MasterServer.Models/Database/Session.cs similarity index 92% rename from UT4MasterServer/Models/Session.cs rename to UT4MasterServer.Models/Database/Session.cs index 192a3fd2..23644985 100644 --- a/UT4MasterServer/Models/Session.cs +++ b/UT4MasterServer.Models/Database/Session.cs @@ -1,8 +1,7 @@ using MongoDB.Bson.Serialization.Attributes; -using UT4MasterServer.Authentication; -using UT4MasterServer.Other; +using UT4MasterServer.Common; -namespace UT4MasterServer.Models; +namespace UT4MasterServer.Models.Database; public enum SessionCreationMethod { @@ -42,6 +41,7 @@ public Session(EpicID session, EpicID account, EpicID clientID, SessionCreationM ClientID = clientID; CreationMethod = creationMethod; + AccessToken = null!; // NOTE: This is only for warning suppression. Refresh() will assign an actual value. Refresh(); } diff --git a/UT4MasterServer/Models/Statistic.cs b/UT4MasterServer.Models/Database/Statistic.cs similarity index 85% rename from UT4MasterServer/Models/Statistic.cs rename to UT4MasterServer.Models/Database/Statistic.cs index d620c7d6..14ed5274 100644 --- a/UT4MasterServer/Models/Statistic.cs +++ b/UT4MasterServer.Models/Database/Statistic.cs @@ -1,9 +1,9 @@ using MongoDB.Bson; using MongoDB.Bson.Serialization.Attributes; -using UT4MasterServer.Enums; -using UT4MasterServer.Other; +using UT4MasterServer.Common.Enums; +using UT4MasterServer.Common; -namespace UT4MasterServer.Models; +namespace UT4MasterServer.Models.Database; public sealed class Statistic : StatisticBase { diff --git a/UT4MasterServer/Models/StatisticBase.cs b/UT4MasterServer.Models/Database/StatisticBase.cs similarity index 98% rename from UT4MasterServer/Models/StatisticBase.cs rename to UT4MasterServer.Models/Database/StatisticBase.cs index ed85c970..3937637b 100644 --- a/UT4MasterServer/Models/StatisticBase.cs +++ b/UT4MasterServer.Models/Database/StatisticBase.cs @@ -1,6 +1,6 @@ using MongoDB.Bson.Serialization.Attributes; -namespace UT4MasterServer.Models; +namespace UT4MasterServer.Models.Database; public class StatisticBase { @@ -772,7 +772,7 @@ public List Validate() ValidateIfBothStatisticsExist(Wins, Losses, nameof(Wins), nameof(Losses), flaggedFields); // Maximum time for custom game is 60 minutes (1 minute is added just to be sure) - if (TimePlayed.HasValue && TimePlayed.Value > (60 * 60) + 60) + if (TimePlayed.HasValue && TimePlayed.Value > 60 * 60 + 60) { flaggedFields.Add(nameof(TimePlayed)); } @@ -861,7 +861,7 @@ private static void ValidateIfBothStatisticsExist(int? firstValue, int? secondVa private void ValidateStatisticsAgainstTimePlayed(int? value, double maxValuePerSecond, string statisticName, List flaggedFields) { - if (value.HasValue && TimePlayed.HasValue && value.Value > (TimePlayed.Value / maxValuePerSecond)) + if (value.HasValue && TimePlayed.HasValue && value.Value > TimePlayed.Value / maxValuePerSecond) { flaggedFields.Add(statisticName); flaggedFields.Add(nameof(TimePlayed)); @@ -870,7 +870,7 @@ private void ValidateStatisticsAgainstTimePlayed(int? value, double maxValuePerS private void ValidateMultiAndSpreeKillStatistic(int? value, int multiplier, string statisticName, List flaggedFields) { - if (value.HasValue && (!Kills.HasValue || (Kills.HasValue && value.Value * multiplier > Kills.Value))) + if (value.HasValue && (!Kills.HasValue || Kills.HasValue && value.Value * multiplier > Kills.Value)) { flaggedFields.Add(statisticName); flaggedFields.Add(nameof(Kills)); diff --git a/UT4MasterServer/Authentication/Token.cs b/UT4MasterServer.Models/Database/Token.cs similarity index 96% rename from UT4MasterServer/Authentication/Token.cs rename to UT4MasterServer.Models/Database/Token.cs index 9167a92e..6deff688 100644 --- a/UT4MasterServer/Authentication/Token.cs +++ b/UT4MasterServer.Models/Database/Token.cs @@ -1,7 +1,7 @@ using MongoDB.Bson.Serialization.Attributes; using System.Security.Cryptography; -namespace UT4MasterServer.Authentication; +namespace UT4MasterServer.Models.Database; public class Token { diff --git a/UT4MasterServer/Models/TrustedGameServer.cs b/UT4MasterServer.Models/Database/TrustedGameServer.cs similarity index 77% rename from UT4MasterServer/Models/TrustedGameServer.cs rename to UT4MasterServer.Models/Database/TrustedGameServer.cs index e7900a1b..5e34a788 100644 --- a/UT4MasterServer/Models/TrustedGameServer.cs +++ b/UT4MasterServer.Models/Database/TrustedGameServer.cs @@ -1,8 +1,8 @@ using MongoDB.Bson.Serialization.Attributes; -using UT4MasterServer.Authentication; -using UT4MasterServer.Other; +using UT4MasterServer.Common.Enums; +using UT4MasterServer.Common; -namespace UT4MasterServer.Models; +namespace UT4MasterServer.Models.Database; public class TrustedGameServer { diff --git a/UT4MasterServer.Models/GameServerAttributes.cs b/UT4MasterServer.Models/GameServerAttributes.cs new file mode 100644 index 00000000..78b162f3 --- /dev/null +++ b/UT4MasterServer.Models/GameServerAttributes.cs @@ -0,0 +1,97 @@ +using System.Text.Json.Nodes; + +namespace UT4MasterServer.Models; + +public class GameServerAttributes +{ + private readonly Dictionary serverConfigs; + + public GameServerAttributes() + { + serverConfigs = new Dictionary(); + } + + public void Set(string key, string? value) + { + SetDirect(key, value); + } + + public void Set(string key, int? value) + { + SetDirect(key, value); + } + + public void Set(string key, bool? value) + { + SetDirect(key, value); + } + + public void Update(GameServerAttributes other) + { + foreach (var attribute in other.serverConfigs) + { + if (attribute.Key == "UT_SERVERTRUSTLEVEL_i") + continue; // do not allow server to modify this attribute + + SetDirect(attribute.Key, attribute.Value); + } + } + + public bool Contains(string key) + { + return serverConfigs.ContainsKey(key); + } + + public object? Get(string key) + { + if (!Contains(key)) + return null; + return serverConfigs[key]; + } + + public string[] GetKeys() + { + return serverConfigs.Keys.ToArray(); + } + + public JsonObject ToJObject() + { + var attrs = new KeyValuePair[serverConfigs.Count]; + + int i = 0; + foreach (var kvp in serverConfigs) + { + if (kvp.Key.EndsWith("_b")) + { + attrs[i] = new(kvp.Key, (bool)kvp.Value); + } + else if (kvp.Key.EndsWith("_i")) + { + attrs[i] = new(kvp.Key, (int)kvp.Value); + } + else if (kvp.Key.EndsWith("_s")) + { + attrs[i] = new(kvp.Key, (string)kvp.Value); + } + i++; + } + + return new JsonObject(attrs); + } + + private void SetDirect(string key, object? value) + { + if (value != null) + { + if (serverConfigs.ContainsKey(key)) + serverConfigs[key] = value; + else + serverConfigs.Add(key, value); + } + else + { + if (serverConfigs.ContainsKey(key)) + serverConfigs.Remove(key); + } + } +} diff --git a/UT4MasterServer/Models/ApplicationSettings.cs b/UT4MasterServer.Models/Settings/ApplicationSettings.cs similarity index 96% rename from UT4MasterServer/Models/ApplicationSettings.cs rename to UT4MasterServer.Models/Settings/ApplicationSettings.cs index c2112c89..d28b7143 100644 --- a/UT4MasterServer/Models/ApplicationSettings.cs +++ b/UT4MasterServer.Models/Settings/ApplicationSettings.cs @@ -1,4 +1,4 @@ -namespace UT4MasterServer.Models; +namespace UT4MasterServer.Models.Settings; public sealed class ApplicationSettings { diff --git a/UT4MasterServer/Settings/ReCaptchaSettings.cs b/UT4MasterServer.Models/Settings/ReCaptchaSettings.cs similarity index 75% rename from UT4MasterServer/Settings/ReCaptchaSettings.cs rename to UT4MasterServer.Models/Settings/ReCaptchaSettings.cs index 1e8e5d42..ce6a9748 100644 --- a/UT4MasterServer/Settings/ReCaptchaSettings.cs +++ b/UT4MasterServer.Models/Settings/ReCaptchaSettings.cs @@ -1,4 +1,4 @@ -namespace UT4MasterServer.Settings; +namespace UT4MasterServer.Models.Settings; public class ReCaptchaSettings { diff --git a/UT4MasterServer/Settings/StatisticsSettings.cs b/UT4MasterServer.Models/Settings/StatisticsSettings.cs similarity index 94% rename from UT4MasterServer/Settings/StatisticsSettings.cs rename to UT4MasterServer.Models/Settings/StatisticsSettings.cs index bac71d0d..adbddf12 100644 --- a/UT4MasterServer/Settings/StatisticsSettings.cs +++ b/UT4MasterServer.Models/Settings/StatisticsSettings.cs @@ -1,4 +1,4 @@ -namespace UT4MasterServer.Settings; +namespace UT4MasterServer.Models.Settings; public sealed class StatisticsSettings { diff --git a/UT4MasterServer.Models/UT4MasterServer.Models.csproj b/UT4MasterServer.Models/UT4MasterServer.Models.csproj new file mode 100644 index 00000000..8a2153f6 --- /dev/null +++ b/UT4MasterServer.Models/UT4MasterServer.Models.csproj @@ -0,0 +1,17 @@ + + + + net6.0 + enable + enable + + + + + + + + + + + diff --git a/UT4MasterServer.Serializers/Bson/BsonSerializationProvider.cs b/UT4MasterServer.Serializers/Bson/BsonSerializationProvider.cs new file mode 100644 index 00000000..224ae419 --- /dev/null +++ b/UT4MasterServer.Serializers/Bson/BsonSerializationProvider.cs @@ -0,0 +1,20 @@ +using MongoDB.Bson.Serialization; +using UT4MasterServer.Common; +using UT4MasterServer.Models; + +namespace UT4MasterServer.Serializers.Bson; + +public class BsonSerializationProvider : IBsonSerializationProvider +{ + public IBsonSerializer GetSerializer(Type type) + { + if (type == typeof(EpicID)) + return new EpicIDSerializer(); + if (type == typeof(GameServerAttributes)) + return new GameServerAttributesBsonSerializer(); + + // returning null here seems to be fine. + // it probably signals to the caller that we don't have serializer for specified type. + return null!; + } +} diff --git a/UT4MasterServer.Serializers/Bson/EpicIDSerializer.cs b/UT4MasterServer.Serializers/Bson/EpicIDSerializer.cs new file mode 100644 index 00000000..d9c35a0b --- /dev/null +++ b/UT4MasterServer.Serializers/Bson/EpicIDSerializer.cs @@ -0,0 +1,152 @@ +using MongoDB.Bson; +using MongoDB.Bson.Serialization; +using MongoDB.Bson.Serialization.Serializers; +using UT4MasterServer.Common; + +namespace UT4MasterServer.Serializers.Bson; + +/// +/// Represents a serializer for Strings. +/// +public class EpicIDSerializer : StructSerializerBase, IRepresentationConfigurable +{ + #region static + private static readonly StringSerializer __instance = new StringSerializer(); + + // public static properties + /// + /// Gets a cached instance of a default string serializer. + /// + public static StringSerializer Instance => __instance; + #endregion + + // private fields + private readonly BsonType _representation; + + // constructors + /// + /// Initializes a new instance of the class. + /// + public EpicIDSerializer() : this(BsonType.String) + { + } + + /// + /// Initializes a new instance of the class. + /// + /// The representation. + public EpicIDSerializer(BsonType representation) + { + switch (representation) + { + case BsonType.ObjectId: + case BsonType.String: + case BsonType.Symbol: + break; + + default: + var message = string.Format("{0} is not a valid representation for a EpicIDSerializer.", representation); + throw new ArgumentException(message); + } + + _representation = representation; + } + + // public properties + /// + /// Gets the representation. + /// + /// + /// The representation. + /// + public BsonType Representation + { + get { return _representation; } + } + + // public methods + /// + /// Deserializes a value. + /// + /// The deserialization context. + /// The deserialization args. + /// A deserialized value. + public override EpicID Deserialize(BsonDeserializationContext context, BsonDeserializationArgs args) + { + var bsonReader = context.Reader; + + var bsonType = bsonReader.GetCurrentBsonType(); + switch (bsonType) + { + case BsonType.ObjectId: + if (_representation == BsonType.ObjectId) + { + return EpicID.FromString(bsonReader.ReadObjectId().ToString()); + } + + goto default; + + case BsonType.String: + return EpicID.FromString(bsonReader.ReadString()); + + case BsonType.Symbol: + return EpicID.FromString(bsonReader.ReadSymbol()); + + default: + throw CreateCannotDeserializeFromBsonTypeException(bsonType); + } + } + + /// + /// Serializes a value. + /// + /// The serialization context. + /// The serialization args. + /// The object. + public override void Serialize(BsonSerializationContext context, BsonSerializationArgs args, EpicID value) + { + var bsonWriter = context.Writer; + + switch (_representation) + { + case BsonType.ObjectId: + bsonWriter.WriteObjectId(ObjectId.Parse(value.ID)); + break; + + case BsonType.String: + bsonWriter.WriteString(value.ID); + break; + + case BsonType.Symbol: + bsonWriter.WriteSymbol(value.ID); + break; + + default: + var message = string.Format("'{0}' is not a valid String representation.", _representation); + throw new BsonSerializationException(message); + } + } + + /// + /// Returns a serializer that has been reconfigured with the specified representation. + /// + /// The representation. + /// The reconfigured serializer. + public EpicIDSerializer WithRepresentation(BsonType representation) + { + if (representation == _representation) + { + return this; + } + else + { + return new EpicIDSerializer(representation); + } + } + + // explicit interface implementations + IBsonSerializer IRepresentationConfigurable.WithRepresentation(BsonType representation) + { + return WithRepresentation(representation); + } +} diff --git a/UT4MasterServer.Serializers/Bson/GameServerAttributesBsonSerializer.cs b/UT4MasterServer.Serializers/Bson/GameServerAttributesBsonSerializer.cs new file mode 100644 index 00000000..5b8401e7 --- /dev/null +++ b/UT4MasterServer.Serializers/Bson/GameServerAttributesBsonSerializer.cs @@ -0,0 +1,67 @@ +using MongoDB.Bson.IO; +using MongoDB.Bson.Serialization; +using MongoDB.Bson.Serialization.Serializers; +using UT4MasterServer.Models; + +namespace UT4MasterServer.Serializers.Bson; + +public class GameServerAttributesBsonSerializer : SerializerBase +{ + public override void Serialize(BsonSerializationContext context, BsonSerializationArgs args, GameServerAttributes value) + { + context.Writer.WriteStartDocument(); + + foreach (var key in value.GetKeys()) + { + var val = value.Get(key); + + if (val is string valString) + context.Writer.WriteString(key, valString); + else if (val is int valInt) + context.Writer.WriteInt32(key, valInt); + else if (val is bool valBool) + context.Writer.WriteBoolean(key, valBool); + else + { + // Other kv-pairs are ignored because they are invalid + // TODO: Is throw more appropriate? + continue; + } + } + + context.Writer.WriteEndDocument(); + } + + public override GameServerAttributes Deserialize(BsonDeserializationContext context, BsonDeserializationArgs args) + { + var result = new GameServerAttributes(); + + if (context.Reader.CurrentBsonType != MongoDB.Bson.BsonType.Document) + throw new FormatException("Cannot deserialize into GameServerAttributes"); + + context.Reader.ReadStartDocument(); + + while (true) + { + var t = context.Reader.ReadBsonType(); + if (t == MongoDB.Bson.BsonType.EndOfDocument) + break; + + string key = context.Reader.ReadName(); + + if (t == MongoDB.Bson.BsonType.String) + result.Set(key, context.Reader.ReadString()); + else if (t == MongoDB.Bson.BsonType.Int32) + result.Set(key, context.Reader.ReadInt32()); + else if (t == MongoDB.Bson.BsonType.Boolean) + result.Set(key, context.Reader.ReadBoolean()); + else + throw new FormatException("Cannot deserialize into GameServerAttributes"); + } + + + context.Reader.ReadEndDocument(); + + return result; + } +} diff --git a/UT4MasterServer.Serializers/Json/DateTimeISOJsonConverter.cs b/UT4MasterServer.Serializers/Json/DateTimeISOJsonConverter.cs new file mode 100644 index 00000000..c8b5d864 --- /dev/null +++ b/UT4MasterServer.Serializers/Json/DateTimeISOJsonConverter.cs @@ -0,0 +1,30 @@ +using System.Text.Json; +using System.Text.Json.Serialization; +using UT4MasterServer.Common.Helpers; + +namespace UT4MasterServer.Serializers.Json; + +public class DateTimeISOJsonConverter : JsonConverter +{ + public override DateTime Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + if (typeToConvert != typeof(string) && typeToConvert != typeof(DateTime)) + return DateTime.MinValue; + + var str = reader.GetString(); + if (str == null) + return DateTime.MinValue; + + DateTime ret; + if (!DateTime.TryParse("yyyy-MM-dd'T'HH:mm:ss.fffK", out ret)) + return DateTime.MinValue; + + return ret; + } + + public override void Write(Utf8JsonWriter writer, DateTime value, JsonSerializerOptions options) + { + string val = value.ToStringISO(); + writer.WriteStringValue(val); + } +} diff --git a/UT4MasterServer.Serializers/Json/EpicIDJsonConverter.cs b/UT4MasterServer.Serializers/Json/EpicIDJsonConverter.cs new file mode 100644 index 00000000..ea1e4bcf --- /dev/null +++ b/UT4MasterServer.Serializers/Json/EpicIDJsonConverter.cs @@ -0,0 +1,25 @@ +using System.Text.Json; +using System.Text.Json.Serialization; +using UT4MasterServer.Common; + +namespace UT4MasterServer.Serializers.Json; + +public class EpicIDJsonConverter : JsonConverter +{ + public override EpicID Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + if (typeToConvert != typeof(string) && typeToConvert != typeof(EpicID)) + return EpicID.Empty; + + var str = reader.GetString(); + if (str == null) + return EpicID.Empty; + + return EpicID.FromString(str); + } + + public override void Write(Utf8JsonWriter writer, EpicID value, JsonSerializerOptions options) + { + writer.WriteStringValue(value.ToString()); + } +} diff --git a/UT4MasterServer.Serializers/Json/GameServerAttributesJsonConverter.cs b/UT4MasterServer.Serializers/Json/GameServerAttributesJsonConverter.cs new file mode 100644 index 00000000..5841aed1 --- /dev/null +++ b/UT4MasterServer.Serializers/Json/GameServerAttributesJsonConverter.cs @@ -0,0 +1,73 @@ +using System.Text.Json; +using System.Text.Json.Serialization; +using UT4MasterServer.Models; + +namespace UT4MasterServer.Serializers.Json; + +public class GameServerAttributesJsonConverter : JsonConverter +{ + public override GameServerAttributes? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + if (reader.TokenType != JsonTokenType.StartObject) + return null; + + GameServerAttributes attribs = new GameServerAttributes(); + + while (true) + { + reader.Read(); + + if (reader.TokenType == JsonTokenType.EndObject) + return attribs; + + if (reader.TokenType != JsonTokenType.PropertyName) + return null; + + var attributeName = reader.GetString(); + if (attributeName == null) + return null; + + if (!reader.Read()) + return null; + + if (reader.TokenType == JsonTokenType.String) + { + attribs.Set(attributeName, reader.GetString()); + } + else if (reader.TokenType == JsonTokenType.Number) + { + attribs.Set(attributeName, reader.GetInt32()); + } + else if (reader.TokenType == JsonTokenType.True || reader.TokenType == JsonTokenType.False) + { + attribs.Set(attributeName, reader.GetBoolean()); + } + else if (reader.TokenType == JsonTokenType.Null) + { + attribs.Set(attributeName, null as string); // need to specify some type + } + else + { + return null; + } + } + } + + public override void Write(Utf8JsonWriter writer, GameServerAttributes value, JsonSerializerOptions options) + { + writer.WriteStartObject(); + foreach (var key in value.GetKeys()) + { + var val = value.Get(key); + + writer.WritePropertyName(key); + if (val is string valString) + writer.WriteStringValue(valString); + else if (val is int valInt) + writer.WriteNumberValue(valInt); + else if (val is bool valBool) + writer.WriteBooleanValue(valBool); + } + writer.WriteEndObject(); + } +} diff --git a/UT4MasterServer.Serializers/UT4MasterServer.Serializers.csproj b/UT4MasterServer.Serializers/UT4MasterServer.Serializers.csproj new file mode 100644 index 00000000..f3be8c53 --- /dev/null +++ b/UT4MasterServer.Serializers/UT4MasterServer.Serializers.csproj @@ -0,0 +1,13 @@ + + + + net6.0 + enable + enable + + + + + + + diff --git a/UT4MasterServer/Services/DatabaseContext.cs b/UT4MasterServer.Services/DatabaseContext.cs similarity index 91% rename from UT4MasterServer/Services/DatabaseContext.cs rename to UT4MasterServer.Services/DatabaseContext.cs index 9c6f0c85..6bbbb80e 100644 --- a/UT4MasterServer/Services/DatabaseContext.cs +++ b/UT4MasterServer.Services/DatabaseContext.cs @@ -1,6 +1,6 @@ using Microsoft.Extensions.Options; using MongoDB.Driver; -using UT4MasterServer.Models; +using UT4MasterServer.Models.Settings; namespace UT4MasterServer.Services; diff --git a/UT4MasterServer/Services/ApplicationBackgroundService.cs b/UT4MasterServer.Services/Hosted/ApplicationBackgroundService.cs similarity index 93% rename from UT4MasterServer/Services/ApplicationBackgroundService.cs rename to UT4MasterServer.Services/Hosted/ApplicationBackgroundService.cs index c8da3829..5b99ab3d 100644 --- a/UT4MasterServer/Services/ApplicationBackgroundService.cs +++ b/UT4MasterServer.Services/Hosted/ApplicationBackgroundService.cs @@ -1,7 +1,12 @@ +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; -using UT4MasterServer.Settings; +using UT4MasterServer.Models.Settings; +using UT4MasterServer.Services.Scoped; +using UT4MasterServer.Services.Singleton; -namespace UT4MasterServer.Services; +namespace UT4MasterServer.Services.Hosted; public sealed class ApplicationBackgroundCleanupService : IHostedService, IDisposable { diff --git a/UT4MasterServer/Services/ApplicationStartupService.cs b/UT4MasterServer.Services/Hosted/ApplicationStartupService.cs similarity index 84% rename from UT4MasterServer/Services/ApplicationStartupService.cs rename to UT4MasterServer.Services/Hosted/ApplicationStartupService.cs index 9253c929..1a7a0980 100644 --- a/UT4MasterServer/Services/ApplicationStartupService.cs +++ b/UT4MasterServer.Services/Hosted/ApplicationStartupService.cs @@ -1,7 +1,10 @@ -using Microsoft.Extensions.Options; -using UT4MasterServer.Models; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using UT4MasterServer.Models.Settings; +using UT4MasterServer.Services.Scoped; -namespace UT4MasterServer.Services +namespace UT4MasterServer.Services.Hosted { public sealed class ApplicationStartupService : IHostedService { diff --git a/UT4MasterServer/Services/AccountService.cs b/UT4MasterServer.Services/Scoped/AccountService.cs similarity index 84% rename from UT4MasterServer/Services/AccountService.cs rename to UT4MasterServer.Services/Scoped/AccountService.cs index cf5fa4b2..8c8a0981 100644 --- a/UT4MasterServer/Services/AccountService.cs +++ b/UT4MasterServer.Services/Scoped/AccountService.cs @@ -1,97 +1,98 @@ -using Microsoft.Extensions.Options; -using MongoDB.Driver; -using UT4MasterServer.Models; -using System.Text; -using System.Security.Cryptography; -using UT4MasterServer.Other; -using UT4MasterServer.Helpers; - -namespace UT4MasterServer.Services; - -public sealed class AccountService -{ - private readonly IMongoCollection accountCollection; - - public AccountService(DatabaseContext dbContext, IOptions settings) - { - accountCollection = dbContext.Database.GetCollection("accounts"); - } - - public async Task CreateAccountAsync(string username, string email, string password) - { - var newAccount = new Account(); - newAccount.ID = EpicID.GenerateNew(); - newAccount.Username = username; - newAccount.Email = email; - newAccount.Password = PasswordHelper.GetPasswordHash(newAccount.ID, password); - - await accountCollection.InsertOneAsync(newAccount); - } - - public async Task GetAccountByEmailAsync(string email) - { - var cursor = await accountCollection.FindAsync(account => account.Email == email); - return await cursor.SingleOrDefaultAsync(); - } - - public async Task GetAccountAsync(EpicID id) - { - var cursor = await accountCollection.FindAsync(account => account.ID == id); - return await cursor.SingleOrDefaultAsync(); - } - - public async Task GetAccountAsync(string username) - { - var cursor = await accountCollection.FindAsync(account => account.Username == username); - return await cursor.SingleOrDefaultAsync(); - } - - public async Task> SearchAccountsAsync(string query) - { - var cursor = await accountCollection.FindAsync(account => account.Username.ToLower().Contains(query.ToLower())); - return await cursor.ToListAsync(); - } - - public async Task GetAccountUsernameOrEmailAsync(string username) - { - var account = await GetAccountAsync(username); - if (account == null) - { - account = await GetAccountByEmailAsync(username); - if (account == null) - return null; - } - - return account; - } - - public async Task> GetAccountsAsync(IEnumerable ids) - { - var result = await accountCollection.FindAsync(account => ids.Contains(account.ID)); - return await result.ToListAsync(); - } - - public async Task> GetAllAccountsAsync() - { - var result = await accountCollection.FindAsync(account => true); - return await result.ToListAsync(); - } - - public async Task UpdateAccountAsync(Account updatedAccount) - { - // we never want to change the ID, so ID can be implied from 'updatedAccount' - await accountCollection.ReplaceOneAsync(user => user.ID == updatedAccount.ID, updatedAccount); - } - - public async Task UpdateAccountPasswordAsync(Account updatedAccount, string password) - { - updatedAccount.Password = PasswordHelper.GetPasswordHash(updatedAccount.ID, password); - await accountCollection.ReplaceOneAsync(user => user.ID == updatedAccount.ID, updatedAccount); - } - - public async Task RemoveAccountAsync(EpicID id) - { - await accountCollection.DeleteOneAsync(user => user.ID == id); - } -} - +using Microsoft.Extensions.Options; +using MongoDB.Driver; +using UT4MasterServer.Common; +using UT4MasterServer.Common.Helpers; +using UT4MasterServer.Models.Database; +using UT4MasterServer.Models.Settings; + +namespace UT4MasterServer.Services.Scoped; + +public sealed class AccountService +{ + private readonly IMongoCollection accountCollection; + + public AccountService(DatabaseContext dbContext, IOptions settings) + { + accountCollection = dbContext.Database.GetCollection("accounts"); + } + + public async Task CreateAccountAsync(string username, string email, string password) + { + var newAccount = new Account + { + ID = EpicID.GenerateNew(), + Username = username, + Email = email + }; + newAccount.Password = PasswordHelper.GetPasswordHash(newAccount.ID, password); + + await accountCollection.InsertOneAsync(newAccount); + } + + public async Task GetAccountByEmailAsync(string email) + { + var cursor = await accountCollection.FindAsync(account => account.Email == email); + return await cursor.SingleOrDefaultAsync(); + } + + public async Task GetAccountAsync(EpicID id) + { + var cursor = await accountCollection.FindAsync(account => account.ID == id); + return await cursor.SingleOrDefaultAsync(); + } + + public async Task GetAccountAsync(string username) + { + var cursor = await accountCollection.FindAsync(account => account.Username == username); + return await cursor.SingleOrDefaultAsync(); + } + + public async Task> SearchAccountsAsync(string usernameQuery) + { + var cursor = await accountCollection.FindAsync(account => account.Username.ToLower().Contains(usernameQuery.ToLower())); + return await cursor.ToListAsync(); + } + + public async Task GetAccountUsernameOrEmailAsync(string username) + { + var account = await GetAccountAsync(username); + if (account == null) + { + account = await GetAccountByEmailAsync(username); + if (account == null) + return null; + } + + return account; + } + + public async Task> GetAccountsAsync(IEnumerable ids) + { + var result = await accountCollection.FindAsync(account => ids.Contains(account.ID)); + return await result.ToListAsync(); + } + + public async Task> GetAllAccountsAsync() + { + var result = await accountCollection.FindAsync(account => true); + return await result.ToListAsync(); + } + + public async Task UpdateAccountAsync(Account updatedAccount) + { + // we never want to change the ID, so ID can be implied from 'updatedAccount' + await accountCollection.ReplaceOneAsync(user => user.ID == updatedAccount.ID, updatedAccount); + } + + public async Task UpdateAccountPasswordAsync(Account updatedAccount, string password) + { + updatedAccount.Password = PasswordHelper.GetPasswordHash(updatedAccount.ID, password); + await accountCollection.ReplaceOneAsync(user => user.ID == updatedAccount.ID, updatedAccount); + } + + public async Task RemoveAccountAsync(EpicID id) + { + await accountCollection.DeleteOneAsync(user => user.ID == id); + } +} + diff --git a/UT4MasterServer/Services/ClientService.cs b/UT4MasterServer.Services/Scoped/ClientService.cs similarity index 90% rename from UT4MasterServer/Services/ClientService.cs rename to UT4MasterServer.Services/Scoped/ClientService.cs index a374a507..f2c9b393 100644 --- a/UT4MasterServer/Services/ClientService.cs +++ b/UT4MasterServer.Services/Scoped/ClientService.cs @@ -1,11 +1,9 @@ -using Microsoft.AspNetCore.DataProtection; using MongoDB.Driver; -using System.Net.Sockets; -using UT4MasterServer.Authentication; using UT4MasterServer.Models; -using UT4MasterServer.Other; +using UT4MasterServer.Models.Database; +using UT4MasterServer.Common; -namespace UT4MasterServer.Services; +namespace UT4MasterServer.Services.Scoped; public sealed class ClientService { diff --git a/UT4MasterServer/Services/CloudstorageService.cs b/UT4MasterServer.Services/Scoped/CloudstorageService.cs similarity index 94% rename from UT4MasterServer/Services/CloudstorageService.cs rename to UT4MasterServer.Services/Scoped/CloudstorageService.cs index 55bfedde..c662098a 100644 --- a/UT4MasterServer/Services/CloudstorageService.cs +++ b/UT4MasterServer.Services/Scoped/CloudstorageService.cs @@ -1,10 +1,11 @@ using MongoDB.Driver; -using System.IO.Pipelines; using System.Security.Cryptography; -using UT4MasterServer.Models; -using UT4MasterServer.Other; +using UT4MasterServer.Common; +using UT4MasterServer.Models.Database; +using UT4MasterServer.Common.Helpers; +using Microsoft.Extensions.Logging; -namespace UT4MasterServer.Services; +namespace UT4MasterServer.Services.Scoped; /// /// Some general information about cloudstorage files

@@ -51,12 +52,11 @@ public async Task EnsureSystemFilesExistAsync() // file is not in db, save it logger.LogInformation("Adding cloud storage system file to database: {filename}", filename); using var stream = File.OpenRead(file); - var reader = PipeReader.Create(stream); - await UpdateFileAsync(EpicID.Empty, filename, reader); + await UpdateFileAsync(EpicID.Empty, filename, stream); } } - public async Task UpdateFileAsync(EpicID accountID, string filename, PipeReader dataStream) + public async Task UpdateFileAsync(EpicID accountID, string filename, Stream dataStream) { if (!accountID.IsEmpty) { diff --git a/UT4MasterServer/Services/FriendService.cs b/UT4MasterServer.Services/Scoped/FriendService.cs similarity index 89% rename from UT4MasterServer/Services/FriendService.cs rename to UT4MasterServer.Services/Scoped/FriendService.cs index 90d1ab71..9ace8638 100644 --- a/UT4MasterServer/Services/FriendService.cs +++ b/UT4MasterServer.Services/Scoped/FriendService.cs @@ -1,8 +1,8 @@ using MongoDB.Driver; -using UT4MasterServer.Models; -using UT4MasterServer.Other; +using UT4MasterServer.Models.Database; +using UT4MasterServer.Common; -namespace UT4MasterServer.Services; +namespace UT4MasterServer.Services.Scoped; public sealed class FriendService { @@ -51,8 +51,8 @@ await friendCollection.DeleteOneAsync( public async Task CancelFriendRequestAsync(EpicID accountID, EpicID acceptsFrom) { var result = await friendCollection.DeleteOneAsync(x => - ((x.Sender == accountID && x.Receiver == acceptsFrom) || - (x.Sender == acceptsFrom && x.Receiver == accountID)) && + (x.Sender == accountID && x.Receiver == acceptsFrom || + x.Sender == acceptsFrom && x.Receiver == accountID) && x.Status != FriendStatus.Blocked); return result.IsAcknowledged; // TODO: is this correct return to confirm whether friend was removed? @@ -62,8 +62,8 @@ public async Task BlockAccountAsync(EpicID accountID, EpicID blockedAccoun { // remove any kind of connection await friendCollection.DeleteOneAsync(x => - (x.Sender == accountID && x.Receiver == blockedAccount) || - (x.Sender == blockedAccount && x.Receiver == accountID) + x.Sender == accountID && x.Receiver == blockedAccount || + x.Sender == blockedAccount && x.Receiver == accountID ); // create block request diff --git a/UT4MasterServer/Services/MatchmakingService.cs b/UT4MasterServer.Services/Scoped/MatchmakingService.cs similarity index 84% rename from UT4MasterServer/Services/MatchmakingService.cs rename to UT4MasterServer.Services/Scoped/MatchmakingService.cs index 0b08e5c6..8483cb51 100644 --- a/UT4MasterServer/Services/MatchmakingService.cs +++ b/UT4MasterServer.Services/Scoped/MatchmakingService.cs @@ -1,21 +1,27 @@ using MongoDB.Bson; using MongoDB.Driver; using System.Text.Json; -using UT4MasterServer.Models; -using UT4MasterServer.Other; +using UT4MasterServer.Models.Database; +using UT4MasterServer.Models.DTO.Request; +using UT4MasterServer.Common; +using Microsoft.Extensions.Logging; +using UT4MasterServer.Services.Singleton; -namespace UT4MasterServer.Services; +namespace UT4MasterServer.Services.Scoped; public sealed class MatchmakingService { private readonly ILogger logger; private readonly IMongoCollection serverCollection; - private TimeSpan StaleAfter = TimeSpan.FromMinutes(1); + private readonly TimeSpan StaleAfter = TimeSpan.FromMinutes(1); - public MatchmakingService(DatabaseContext dbContext, ILogger logger) + private readonly RuntimeInfoService runtimeInfoService; + + public MatchmakingService(DatabaseContext dbContext, ILogger logger, RuntimeInfoService runtimeInfoService) { this.logger = logger; serverCollection = dbContext.Database.GetCollection("servers"); + this.runtimeInfoService = runtimeInfoService; } public async Task DoesExistWithSessionAsync(EpicID sessionID) @@ -62,7 +68,7 @@ public async Task RemoveAsync(EpicID serverID) return await cursor.FirstOrDefaultAsync(); } - public async Task> ListAsync(GameServerFilter inputFilter) + public async Task> ListAsync(GameServerFilterRequest inputFilter) { // Begin removing stale GameServers var taskStaleRemoval = RemoveAllStaleAsync(); @@ -73,7 +79,7 @@ public async Task> ListAsync(GameServerFilter inputFilter) //// include GameServers that have started //doc.Add(new BsonElement(nameof(GameServer.Started), true)); - if (DateTime.UtcNow - Program.StartupTime > StaleAfter) + if (DateTime.UtcNow - runtimeInfoService.StartupTime > StaleAfter) { // exclude stale GameServers that haven't been removed from db yet doc.Add(new BsonElement(nameof(GameServer.LastUpdated), new BsonDocument("$gt", DateTime.UtcNow - StaleAfter))); @@ -114,7 +120,7 @@ public async Task> ListAsync(GameServerFilter inputFilter) if (comparisonKeyword == null) { - logger.LogWarning($"Matchmaking search criteria contains unknown condition type '{condition.Type}' with key '{condition.Key}' and value '{condition.Value}'"); + logger.LogWarning("Matchmaking search criteria contains unknown condition type '{Condition}' with key '{Key}' and value '{Value}'", condition.Type, condition.Key, condition.Value); continue; } @@ -133,6 +139,11 @@ public async Task> ListAsync(GameServerFilter inputFilter) } } + // Limit number of results. if request retrieves more results, + // then caller should make filter more strict. + if (inputFilter.MaxResults > 100) + inputFilter.MaxResults = 100; + var options = new FindOptions() { Limit = inputFilter.MaxResults, @@ -164,8 +175,8 @@ public async Task RemoveAllStaleAsync() var now = DateTime.UtcNow; // Use the same value for all checks in this call // Start removing stale servers only after some time has passed. - // This allows game servers from before reboot to send a heartbeat again and continue operating normally. - if (Program.StartupTime > now - StaleAfter * 2) + // This allows game servers from before our reboot to send a heartbeat again and continue operating normally. + if (runtimeInfoService.StartupTime > now - StaleAfter * 2) return 0; var result = await serverCollection.DeleteManyAsync( diff --git a/UT4MasterServer/Services/SessionService.cs b/UT4MasterServer.Services/Scoped/SessionService.cs similarity index 97% rename from UT4MasterServer/Services/SessionService.cs rename to UT4MasterServer.Services/Scoped/SessionService.cs index 54431b6e..c9bc2fc7 100644 --- a/UT4MasterServer/Services/SessionService.cs +++ b/UT4MasterServer.Services/Scoped/SessionService.cs @@ -1,8 +1,8 @@ using MongoDB.Driver; -using UT4MasterServer.Models; -using UT4MasterServer.Other; +using UT4MasterServer.Models.Database; +using UT4MasterServer.Common; -namespace UT4MasterServer.Services; +namespace UT4MasterServer.Services.Scoped; public sealed class SessionService { diff --git a/UT4MasterServer/Services/StatisticsService.cs b/UT4MasterServer.Services/Scoped/StatisticsService.cs similarity index 99% rename from UT4MasterServer/Services/StatisticsService.cs rename to UT4MasterServer.Services/Scoped/StatisticsService.cs index 87cbb50d..e4d25423 100644 --- a/UT4MasterServer/Services/StatisticsService.cs +++ b/UT4MasterServer.Services/Scoped/StatisticsService.cs @@ -1,12 +1,13 @@ using MongoDB.Bson; using MongoDB.Driver; using MongoDB.Driver.Linq; -using UT4MasterServer.DTOs; -using UT4MasterServer.Enums; -using UT4MasterServer.Models; -using UT4MasterServer.Other; +using UT4MasterServer.Common.Enums; +using UT4MasterServer.Models.Database; +using UT4MasterServer.Models.DTO.Responses; +using UT4MasterServer.Common; +using Microsoft.Extensions.Logging; -namespace UT4MasterServer.Services; +namespace UT4MasterServer.Services.Scoped; public sealed class StatisticsService { diff --git a/UT4MasterServer/Services/TrustedGameServerService.cs b/UT4MasterServer.Services/Scoped/TrustedGameServerService.cs similarity index 90% rename from UT4MasterServer/Services/TrustedGameServerService.cs rename to UT4MasterServer.Services/Scoped/TrustedGameServerService.cs index bfc2a539..26c47d67 100644 --- a/UT4MasterServer/Services/TrustedGameServerService.cs +++ b/UT4MasterServer.Services/Scoped/TrustedGameServerService.cs @@ -1,8 +1,8 @@ using MongoDB.Driver; -using UT4MasterServer.Models; -using UT4MasterServer.Other; +using UT4MasterServer.Models.Database; +using UT4MasterServer.Common; -namespace UT4MasterServer.Services; +namespace UT4MasterServer.Services.Scoped; public sealed class TrustedGameServerService { diff --git a/UT4MasterServer/Services/CodeService.cs b/UT4MasterServer.Services/Singleton/CodeService.cs similarity index 92% rename from UT4MasterServer/Services/CodeService.cs rename to UT4MasterServer.Services/Singleton/CodeService.cs index 04223c3b..954a821f 100644 --- a/UT4MasterServer/Services/CodeService.cs +++ b/UT4MasterServer.Services/Singleton/CodeService.cs @@ -1,9 +1,9 @@ using Microsoft.Extensions.Options; -using UT4MasterServer.Authentication; -using UT4MasterServer.Models; -using UT4MasterServer.Other; +using UT4MasterServer.Models.Database; +using UT4MasterServer.Common; +using UT4MasterServer.Models.Settings; -namespace UT4MasterServer.Services; +namespace UT4MasterServer.Services.Singleton; /// /// Service capable of creating and retrieving codes used for authentication. diff --git a/UT4MasterServer/Services/MatchmakingWaitTimeEstimateService.cs b/UT4MasterServer.Services/Singleton/MatchmakingWaitTimeEstimateService.cs similarity index 93% rename from UT4MasterServer/Services/MatchmakingWaitTimeEstimateService.cs rename to UT4MasterServer.Services/Singleton/MatchmakingWaitTimeEstimateService.cs index 911a8b8c..99bc22d4 100644 --- a/UT4MasterServer/Services/MatchmakingWaitTimeEstimateService.cs +++ b/UT4MasterServer.Services/Singleton/MatchmakingWaitTimeEstimateService.cs @@ -1,6 +1,6 @@ -using UT4MasterServer.Models.Requests; +using UT4MasterServer.Models.DTO.Responses; -namespace UT4MasterServer.Services; +namespace UT4MasterServer.Services.Singleton; public sealed class MatchmakingWaitTimeEstimateService { diff --git a/UT4MasterServer.Services/Singleton/RuntimeInfoService.cs b/UT4MasterServer.Services/Singleton/RuntimeInfoService.cs new file mode 100644 index 00000000..220684f0 --- /dev/null +++ b/UT4MasterServer.Services/Singleton/RuntimeInfoService.cs @@ -0,0 +1,11 @@ +namespace UT4MasterServer.Services.Singleton; + +public class RuntimeInfoService +{ + public DateTime StartupTime { get; set; } + + public RuntimeInfoService() + { + StartupTime = DateTime.UtcNow; + } +} diff --git a/UT4MasterServer.Services/UT4MasterServer.Services.csproj b/UT4MasterServer.Services/UT4MasterServer.Services.csproj new file mode 100644 index 00000000..2da1b0a3 --- /dev/null +++ b/UT4MasterServer.Services/UT4MasterServer.Services.csproj @@ -0,0 +1,19 @@ + + + + net6.0 + enable + enable + + + + + + + + + + + + + diff --git a/UT4MasterServer.sln b/UT4MasterServer.sln index 5612284c..ef146b4c 100644 --- a/UT4MasterServer.sln +++ b/UT4MasterServer.sln @@ -37,6 +37,16 @@ Project("{E24C65DC-7377-472B-9ABA-BC803B73C61A}") = "UT4MasterServer.Web", "UT4M SlnRelativePath = "UT4MasterServer.Web\" EndProjectSection EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "UT4MasterServer.Models", "UT4MasterServer.Models\UT4MasterServer.Models.csproj", "{A83E7353-EA95-4BD6-BE7A-61B8D8A1BEFA}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "UT4MasterServer.Common", "UT4MasterServer.Common\UT4MasterServer.Common.csproj", "{468351B6-1222-46B2-B026-6B78585BAEDE}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "UT4MasterServer.Serializers", "UT4MasterServer.Serializers\UT4MasterServer.Serializers.csproj", "{9EC2021D-6D10-48B8-8604-4563757F6E82}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "UT4MasterServer.Services", "UT4MasterServer.Services\UT4MasterServer.Services.csproj", "{6DDDF02C-C06F-4148-AE39-29A4B771F8FB}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "UT4MasterServer.Authentication", "UT4MasterServer.Authentication\UT4MasterServer.Authentication.csproj", "{9D946358-61C8-4AA3-8151-805393049F8A}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -77,6 +87,46 @@ Global {351E5A62-5562-4E30-A70C-93D5236F6C7A}.Release|Any CPU.Build.0 = Debug|Any CPU {351E5A62-5562-4E30-A70C-93D5236F6C7A}.Release|x64.ActiveCfg = Debug|Any CPU {351E5A62-5562-4E30-A70C-93D5236F6C7A}.Release|x64.Build.0 = Debug|Any CPU + {A83E7353-EA95-4BD6-BE7A-61B8D8A1BEFA}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {A83E7353-EA95-4BD6-BE7A-61B8D8A1BEFA}.Debug|Any CPU.Build.0 = Debug|Any CPU + {A83E7353-EA95-4BD6-BE7A-61B8D8A1BEFA}.Debug|x64.ActiveCfg = Debug|Any CPU + {A83E7353-EA95-4BD6-BE7A-61B8D8A1BEFA}.Debug|x64.Build.0 = Debug|Any CPU + {A83E7353-EA95-4BD6-BE7A-61B8D8A1BEFA}.Release|Any CPU.ActiveCfg = Release|Any CPU + {A83E7353-EA95-4BD6-BE7A-61B8D8A1BEFA}.Release|Any CPU.Build.0 = Release|Any CPU + {A83E7353-EA95-4BD6-BE7A-61B8D8A1BEFA}.Release|x64.ActiveCfg = Release|Any CPU + {A83E7353-EA95-4BD6-BE7A-61B8D8A1BEFA}.Release|x64.Build.0 = Release|Any CPU + {468351B6-1222-46B2-B026-6B78585BAEDE}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {468351B6-1222-46B2-B026-6B78585BAEDE}.Debug|Any CPU.Build.0 = Debug|Any CPU + {468351B6-1222-46B2-B026-6B78585BAEDE}.Debug|x64.ActiveCfg = Debug|Any CPU + {468351B6-1222-46B2-B026-6B78585BAEDE}.Debug|x64.Build.0 = Debug|Any CPU + {468351B6-1222-46B2-B026-6B78585BAEDE}.Release|Any CPU.ActiveCfg = Release|Any CPU + {468351B6-1222-46B2-B026-6B78585BAEDE}.Release|Any CPU.Build.0 = Release|Any CPU + {468351B6-1222-46B2-B026-6B78585BAEDE}.Release|x64.ActiveCfg = Release|Any CPU + {468351B6-1222-46B2-B026-6B78585BAEDE}.Release|x64.Build.0 = Release|Any CPU + {9EC2021D-6D10-48B8-8604-4563757F6E82}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {9EC2021D-6D10-48B8-8604-4563757F6E82}.Debug|Any CPU.Build.0 = Debug|Any CPU + {9EC2021D-6D10-48B8-8604-4563757F6E82}.Debug|x64.ActiveCfg = Debug|Any CPU + {9EC2021D-6D10-48B8-8604-4563757F6E82}.Debug|x64.Build.0 = Debug|Any CPU + {9EC2021D-6D10-48B8-8604-4563757F6E82}.Release|Any CPU.ActiveCfg = Release|Any CPU + {9EC2021D-6D10-48B8-8604-4563757F6E82}.Release|Any CPU.Build.0 = Release|Any CPU + {9EC2021D-6D10-48B8-8604-4563757F6E82}.Release|x64.ActiveCfg = Release|Any CPU + {9EC2021D-6D10-48B8-8604-4563757F6E82}.Release|x64.Build.0 = Release|Any CPU + {6DDDF02C-C06F-4148-AE39-29A4B771F8FB}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {6DDDF02C-C06F-4148-AE39-29A4B771F8FB}.Debug|Any CPU.Build.0 = Debug|Any CPU + {6DDDF02C-C06F-4148-AE39-29A4B771F8FB}.Debug|x64.ActiveCfg = Debug|Any CPU + {6DDDF02C-C06F-4148-AE39-29A4B771F8FB}.Debug|x64.Build.0 = Debug|Any CPU + {6DDDF02C-C06F-4148-AE39-29A4B771F8FB}.Release|Any CPU.ActiveCfg = Release|Any CPU + {6DDDF02C-C06F-4148-AE39-29A4B771F8FB}.Release|Any CPU.Build.0 = Release|Any CPU + {6DDDF02C-C06F-4148-AE39-29A4B771F8FB}.Release|x64.ActiveCfg = Release|Any CPU + {6DDDF02C-C06F-4148-AE39-29A4B771F8FB}.Release|x64.Build.0 = Release|Any CPU + {9D946358-61C8-4AA3-8151-805393049F8A}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {9D946358-61C8-4AA3-8151-805393049F8A}.Debug|Any CPU.Build.0 = Debug|Any CPU + {9D946358-61C8-4AA3-8151-805393049F8A}.Debug|x64.ActiveCfg = Debug|Any CPU + {9D946358-61C8-4AA3-8151-805393049F8A}.Debug|x64.Build.0 = Debug|Any CPU + {9D946358-61C8-4AA3-8151-805393049F8A}.Release|Any CPU.ActiveCfg = Release|Any CPU + {9D946358-61C8-4AA3-8151-805393049F8A}.Release|Any CPU.Build.0 = Release|Any CPU + {9D946358-61C8-4AA3-8151-805393049F8A}.Release|x64.ActiveCfg = Release|Any CPU + {9D946358-61C8-4AA3-8151-805393049F8A}.Release|x64.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE diff --git a/UT4MasterServer/CloudstorageSystemfiles/UTMCPPlaylists.json b/UT4MasterServer/CloudstorageSystemfiles/UTMCPPlaylists.json index 29e55934..cd3c10c2 100644 --- a/UT4MasterServer/CloudstorageSystemfiles/UTMCPPlaylists.json +++ b/UT4MasterServer/CloudstorageSystemfiles/UTMCPPlaylists.json @@ -4,7 +4,7 @@ "PlaylistId": 2, "bRanked": false, "bSkipEloChecks": true, - "TeamEloRating": "iDMSkillRating", + "TeamEloRating": "DMSkillRating", "bAllowBots": true, "BotDifficulty": 5, "SlateBadgeName": "UT.HomePanel.DMBadge", diff --git a/UT4MasterServer/Controllers/AccountController.cs b/UT4MasterServer/Controllers/AccountController.cs deleted file mode 100644 index d97a6d23..00000000 --- a/UT4MasterServer/Controllers/AccountController.cs +++ /dev/null @@ -1,365 +0,0 @@ -using Microsoft.AspNetCore.Authorization; -using Microsoft.AspNetCore.Mvc; -using Microsoft.Extensions.Options; -using Newtonsoft.Json.Linq; -using System.Text.RegularExpressions; -using UT4MasterServer.Authentication; -using UT4MasterServer.Helpers; -using UT4MasterServer.Models; -using UT4MasterServer.Other; -using UT4MasterServer.Services; -using UT4MasterServer.Settings; - -namespace UT4MasterServer.Controllers; - -/// -/// account-public-service-prod03.ol.epicgames.com -/// -[ApiController] -[Route("account/api")] -[AuthorizeBearer] -[Produces("application/json")] -public sealed class AccountController : JsonAPIController -{ - private readonly SessionService sessionService; - private readonly AccountService accountService; - private readonly IOptions reCaptchaSettings; - - public AccountController(ILogger logger, AccountService accountService, SessionService sessionService, IOptions reCaptchaSettings) : base(logger) - { - this.accountService = accountService; - this.sessionService = sessionService; - this.reCaptchaSettings = reCaptchaSettings; - } - - #region ACCOUNT LISTING API - - [HttpGet("public/account/{id}")] - public async Task GetAccount(string id) - { - if (User.Identity is not EpicUserIdentity authenticatedUser) - return Unauthorized(); - - // TODO: EPIC doesn't throw here if id is invalid (like 'abc'). Return this same ErrorResponse like for account_not_found - EpicID eid = EpicID.FromString(id); - - if (eid != authenticatedUser.Session.AccountID) - return Unauthorized(); - - logger.LogInformation($"{authenticatedUser.Session.AccountID} is looking for account {id}"); - - var account = await accountService.GetAccountAsync(eid); - if (account == null) - return NotFound(new ErrorResponse - { - ErrorCode = "errors.com.epicgames.account.account_not_found", - ErrorMessage = $"Sorry, we couldn't find an account for {id}", - MessageVars = new[] { id }, - NumericErrorCode = 18007, - OriginatingService = "com.epicgames.account.public", - Intent = "prod", - }); - - var obj = new JObject(); - obj.Add("id", account.ID.ToString()); - obj.Add("displayName", account.Username); - obj.Add("name", $"{account.Username}"); // fake a random one - obj.Add("email", account.Email);//$"{account.ID}@{Request.Host}"); // fake a random one - obj.Add("failedLoginAttempts", 0); - obj.Add("lastLogin", account.LastLoginAt.ToStringISO()); - obj.Add("numberOfDisplayNameChanges", 0); - obj.Add("ageGroup", "UNKNOWN"); - obj.Add("headless", false); - obj.Add("country", "US"); // two letter country code - obj.Add("lastName", $"{account.Username}"); // fake a random one - obj.Add("preferredLanguage", "en"); // two letter language code - obj.Add("canUpdateDisplayName", true); - obj.Add("tfaEnabled", true); - obj.Add("emailVerified", false);//true); - obj.Add("minorVerified", false); - obj.Add("minorExpected", false); - obj.Add("minorStatus", "UNKNOWN"); - obj.Add("cabinedMode", false); - obj.Add("hasHashedEmail", false); - - return Json(obj.ToString(Newtonsoft.Json.Formatting.None)); - } - - [HttpGet("public/account")] - public async Task GetAccounts([FromQuery(Name = "accountId")] List accountIDs) - { - if (User.Identity is not EpicUserIdentity authenticatedUser) - return Unauthorized(); - - if (accountIDs.Count == 0 || accountIDs.Count > 100) - { - return NotFound(new ErrorResponse - { - ErrorCode = "errors.com.epicgames.account.invalid_account_id_count", - ErrorMessage = "Sorry, the number of account id should be at least one and not more than 100.", - MessageVars = new[] { "100" }, - NumericErrorCode = 18066, - OriginatingService = "com.epicgames.account.public", - Intent = "prod", - }); - } - - var ids = accountIDs.Distinct().Select(x => EpicID.FromString(x)); - var accounts = await accountService.GetAccountsAsync(ids.ToList()); - - var retrievedAccountIDs = accounts.Select(x => x.ID.ToString()); - logger.LogInformation($"{authenticatedUser.Session.AccountID} is looking for {string.Join(", ", retrievedAccountIDs)}"); - - // create json response - var arr = new JArray(); - foreach (var account in accounts) - { - var obj = new JObject(); - obj.Add("id", account.ID.ToString()); - obj.Add("displayName", account.Username); - if (account.ID == authenticatedUser.Session.AccountID) - { - // this is returned only when you ask about yourself - obj.Add("minorVerified", false); - obj.Add("minorStatus", "UNKNOWN"); - obj.Add("cabinedMode", false); - } - - obj.Add("externalAuths", new JObject()); - arr.Add(obj); - } - - return Json(arr); - } - - #endregion - - #region UNIMPORTANT API - - [HttpGet("accounts/{id}/metadata")] - public IActionResult GetMetadata(string id) - { - EpicID eid = EpicID.FromString(id); - - logger.LogInformation($"Get metadata of {eid}"); - - // unknown structure, but epic always seems to respond with this - return Json("{}"); - } - - [HttpGet("public/account/{id}/externalAuths")] - public IActionResult GetExternalAuths(string id) - { - EpicID eid = EpicID.FromString(id); - - logger.LogInformation($"Get external auths of {eid}"); - // we don't really care about these, but structure for my github externalAuth is the following: - /* - [{ - "accountId": "0b0f09b400854b9b98932dd9e5abe7c5", "type": "github", - "externalAuthId": "timiimit", "externalDisplayName": "timiimit", - "authIds": [ { "id": "timiimit", "type": "github_login" } ], - "dateAdded": "2018-01-17T18:58:39.831Z" - }] - */ - return Json("[]"); - } - - [HttpGet("epicdomains/ssodomains")] - [AllowAnonymous] - public IActionResult GetSSODomains() - { - logger.LogInformation(@"Get SSO domains"); - - // epic responds with this: ["unrealengine.com","unrealtournament.com","fortnite.com","epicgames.com"] - - return Json("[]"); - } - - #endregion - - #region NON-EPIC API - - [HttpPost("create/account")] - [AllowAnonymous] - public async Task RegisterAccount([FromForm] string username, [FromForm] string email, [FromForm] string password, [FromForm] string recaptchaToken) - { - var reCaptchaSecret = reCaptchaSettings.Value.SecretKey; - var httpClient = new HttpClient(); - var httpResponse = await httpClient.GetAsync($"https://www.google.com/recaptcha/api/siteverify?secret={reCaptchaSecret}&response={recaptchaToken}"); - if (httpResponse.StatusCode != System.Net.HttpStatusCode.OK) - { - return Conflict("Recaptcha validation failed"); - } - - var jsonResponse = await httpResponse.Content.ReadAsStringAsync(); - var jsonData = JObject.Parse(jsonResponse); - if (jsonData["success"]?.ToObject() != true) - { - return Conflict("Recaptcha validation failed"); - } - - var account = await accountService.GetAccountAsync(username); - if (account != null) - { - logger.LogInformation($"Could not register duplicate account: {username}"); - return Conflict("Username already exists"); - } - - if (!ValidationHelper.ValidateUsername(username)) - { - logger.LogInformation($"Entered an invalid username: {username}"); - return Conflict("You have entered an invalid username"); - } - - email = email.ToLower(); - account = await accountService.GetAccountByEmailAsync(email); - if (account != null) - { - logger.LogInformation($"Could not register duplicate email: {email}"); - return Conflict("Email already exists"); - } - - if (!ValidationHelper.ValidateEmail(email)) - { - logger.LogInformation($"Entered an invalid email format: {email}"); - return Conflict("You have entered an invalid email address"); - } - - if (!ValidationHelper.ValidatePassword(password)) - { - logger.LogInformation($"Entered password was in invalid format"); - return Conflict("Unexpected password format"); - } - - await accountService.CreateAccountAsync(username, email, password); // TODO: this cannot fail? - - - logger.LogInformation($"Registered new user: {username}"); - - return Ok("Account created successfully"); - } - - [HttpPatch("update/username")] - public async Task UpdateUsername([FromForm] string newUsername) - { - if (User.Identity is not EpicUserIdentity user) - { - return Unauthorized(); - } - - if (!ValidationHelper.ValidateUsername(newUsername)) - { - return ValidationProblem(); - } - - var matchingAccount = await accountService.GetAccountAsync(newUsername); - if (matchingAccount != null) - { - logger.LogInformation($"Change Username failed, already taken: {newUsername}"); - return Conflict(new ErrorResponse() - { - ErrorMessage = $"Username already taken" - }); - } - - var account = await accountService.GetAccountAsync(user.Session.AccountID); - if (account == null) - { - return NotFound(new ErrorResponse() - { - ErrorMessage = $"Failed to retrieve your account" - }); - } - - account.Username = newUsername; - await accountService.UpdateAccountAsync(account); - - logger.LogInformation($"Updated username for {user.Session.AccountID} to: {newUsername}"); - - return Ok("Changed username successfully"); - } - - [HttpPatch("update/email")] - public async Task UpdateEmail([FromForm] string newEmail) - { - if (User.Identity is not EpicUserIdentity user) - { - return Unauthorized(); - } - - newEmail = newEmail.ToLower(); - if (!ValidationHelper.ValidateEmail(newEmail)) - { - return ValidationProblem(); - } - - var account = await accountService.GetAccountAsync(user.Session.AccountID); - if (account == null) - { - return NotFound(new ErrorResponse() - { - ErrorMessage = $"Failed to retrieve your account" - }); - } - - account.Email = newEmail; - await accountService.UpdateAccountAsync(account); - - logger.LogInformation($"Updated email for {user.Session.AccountID} to: {newEmail}"); - - return Ok("Changed email successfully"); - } - - [HttpPatch("update/password")] - public async Task UpdatePassword([FromForm] string currentPassword, [FromForm] string newPassword) - { - if (User.Identity is not EpicUserIdentity user) - { - throw new UnauthorizedAccessException(); - } - - if (user.Session.ClientID != ClientIdentification.Launcher.ID) - { - throw new UnauthorizedAccessException("Password can only be changed from the website"); - } - - // passwords should already be hashed, but check its length just in case - if (!ValidationHelper.ValidatePassword(newPassword)) - { - return BadRequest(new ErrorResponse() - { - ErrorMessage = $"newPassword is not a SHA512 hash" - }); - } - - var account = await accountService.GetAccountAsync(user.Session.AccountID); - if (account == null) - { - return NotFound(new ErrorResponse() - { - ErrorMessage = $"Failed to retrieve your account" - }); - } - - if (!account.CheckPassword(currentPassword, false)) - { - return BadRequest(new ErrorResponse() - { - ErrorMessage = $"Current Password is invalid" - }); - } - - await accountService.UpdateAccountPasswordAsync(account, newPassword); - - // logout user to make sure they remember they changed password by being forced to log in again, - // as well as prevent anyone else from using this account after successful password change. - await sessionService.RemoveSessionsWithFilterAsync(EpicID.Empty, user.Session.AccountID, EpicID.Empty); - - logger.LogInformation($"Updated password for {user.Session.AccountID}"); - - return Ok("Changed password successfully"); - } - - #endregion -} diff --git a/UT4MasterServer/Controllers/AdminPanelController.cs b/UT4MasterServer/Controllers/AdminPanelController.cs index b7d18e9b..31e6ba1d 100644 --- a/UT4MasterServer/Controllers/AdminPanelController.cs +++ b/UT4MasterServer/Controllers/AdminPanelController.cs @@ -1,11 +1,14 @@ using Microsoft.AspNetCore.Mvc; using UT4MasterServer.Authentication; -using UT4MasterServer.Helpers; +using UT4MasterServer.Common.Helpers; +using UT4MasterServer.Models.Database; +using UT4MasterServer.Models.DTO.Request; +using UT4MasterServer.Common; +using UT4MasterServer.Services.Scoped; +using UT4MasterServer.Services.Singleton; +using UT4MasterServer.Models.DTO.Responses; using UT4MasterServer.Models; -using UT4MasterServer.Models.Requests; -using UT4MasterServer.Models.Responses; -using UT4MasterServer.Other; -using UT4MasterServer.Services; +using UT4MasterServer.Models.Responses; namespace UT4MasterServer.Controllers; @@ -175,9 +178,9 @@ public async Task DeleteClient(string id) var success = await clientService.RemoveAsync(eid); if (success == null || success == false) - return BadRequest(); - - // in case this client is a trusted server remove it as well + return BadRequest(); + + // in case this client is a trusted server remove it as well await trustedGameServerService.RemoveAsync(eid); return Ok(); @@ -195,13 +198,13 @@ public async Task GetAllTrustedServers() var clients = getClients.Result; var eIds = trustedServers.Select((t) => t.OwnerID).Distinct(); var accounts = await accountService.GetAccountsAsync(eIds); - var response = trustedServers.Select((t) => new TrustedGameServerResponse + var response = trustedServers.Select((t) => new TrustedGameServerResponse { - ID = t.ID, - OwnerID = t.OwnerID, - TrustLevel = t.TrustLevel, - Client = clients.SingleOrDefault((c) => c.ID == t.ID), - Owner = accounts.SingleOrDefault((a) => a.ID == t.OwnerID) + ID = t.ID, + OwnerID = t.OwnerID, + TrustLevel = t.TrustLevel, + Client = clients.SingleOrDefault((c) => c.ID == t.ID), + Owner = accounts.SingleOrDefault((a) => a.ID == t.OwnerID) }); return Ok(response); } @@ -213,14 +216,14 @@ public async Task GetTrustedServer(string id) var ret = await trustedGameServerService.GetAsync(EpicID.FromString(id)); return Ok(ret); - } - + } + [HttpPost("trusted_servers")] public async Task CreateTrustedServer([FromBody] TrustedGameServer server) { - await VerifyAdmin(); - // TODO: validate server.ID is valid Client ID and not already in use and owner ID is a valid Account ID and has HubOwner flag - + await VerifyAdmin(); + // TODO: validate server.ID is valid Client ID and not already in use and owner ID is a valid Account ID and has HubOwner flag + await trustedGameServerService.UpdateAsync(server); return Ok(); } @@ -262,9 +265,9 @@ public async Task ChangePassword(string id, [FromBody] AdminPanel if (account.Flags.HasFlag(AccountFlags.Moderator) || account.Flags.HasFlag(AccountFlags.Admin)) { throw new UnauthorizedAccessException("Cannot change password of other admins or moderators"); - } - - // passwords should already be hashed, but check its length just in case + } + + // passwords should already be hashed, but check its length just in case if (!ValidationHelper.ValidatePassword(body.NewPassword)) { return BadRequest(new ErrorResponse() @@ -281,10 +284,10 @@ public async Task ChangePassword(string id, [FromBody] AdminPanel }); } - await accountService.UpdateAccountPasswordAsync(account, body.NewPassword); - - // logout user to make sure they remember they changed password by being forced to log in again, - // as well as prevent anyone else from using this account after successful password change. + await accountService.UpdateAccountPasswordAsync(account, body.NewPassword); + + // logout user to make sure they remember they changed password by being forced to log in again, + // as well as prevent anyone else from using this account after successful password change. await sessionService.RemoveSessionsWithFilterAsync(EpicID.Empty, account.ID, EpicID.Empty); logger.LogInformation("Updated password for {AccountID}", account.ID); @@ -304,7 +307,7 @@ public async Task GetMCPFiles() public async Task UpdateMCPFile(string filename) { await VerifyAdmin(); - await cloudStorageService.UpdateFileAsync(EpicID.Empty, filename, HttpContext.Request.BodyReader); + await cloudStorageService.UpdateFileAsync(EpicID.Empty, filename, HttpContext.Request.Body); return Ok(); } @@ -350,16 +353,16 @@ public async Task DeleteAccountInfo(string id, [FromBody] bool? f } await accountService.RemoveAccountAsync(account.ID); - } - - // remove all associated data + } + + // remove all associated data await sessionService.RemoveSessionsWithFilterAsync(EpicID.Empty, accountID, EpicID.Empty); await codeService.RemoveCodesByAccountAsync(accountID); await cloudStorageService.RemoveFilesByAccountAsync(accountID); await statisticsService.RemoveStatisticsByAccountAsync(accountID); return Ok(); - } + } [NonAction] diff --git a/UT4MasterServer/Controllers/CloudStorageController.cs b/UT4MasterServer/Controllers/CloudStorageController.cs deleted file mode 100644 index c219de25..00000000 --- a/UT4MasterServer/Controllers/CloudStorageController.cs +++ /dev/null @@ -1,147 +0,0 @@ -using Microsoft.AspNetCore.Mvc; -using Newtonsoft.Json.Linq; -using System.Text; -using UT4MasterServer.Authentication; -using UT4MasterServer.Models; -using UT4MasterServer.Other; -using UT4MasterServer.Services; - -namespace UT4MasterServer.Controllers; - -[ApiController] -[Route("ut/api/cloudstorage")] -[AuthorizeBearer] -[Produces("application/octet-stream")] -public sealed class CloudStorageController : JsonAPIController -{ - private readonly CloudStorageService cloudStorageService; - private readonly MatchmakingService matchmakingService; - private readonly AccountService accountService; - - public CloudStorageController(ILogger logger, - CloudStorageService cloudStorageService, - MatchmakingService matchmakingService, - AccountService accountService) : base(logger) - { - this.cloudStorageService = cloudStorageService; - this.matchmakingService = matchmakingService; - this.accountService = accountService; - } - - [HttpGet("user/{id}")] - public async Task ListUserFiles(string id) - { - // list all files this user has in storage - any user can see files from another user - - var eid = EpicID.FromString(id); - - var files = await cloudStorageService.ListFilesAsync(eid); - - var arr = new JArray(); - foreach (var file in files) - { - var obj = new JObject(); - obj.Add("uniqueFilename", file.Filename); - obj.Add("filename", file.Filename); - obj.Add("hash", file.Hash); - obj.Add("hash256", file.Hash256); - obj.Add("length", file.Length); - obj.Add("contentType", "text/plain"); // this seems to be constant - obj.Add("uploaded", file.UploadedAt.ToStringISO()); - obj.Add("storageType", "S3"); - obj.Add("accountId", id); - if (eid.IsEmpty) - { - obj.Add("doNotCache", false); - } - arr.Add(obj); - } - - return Json(arr); - } - - [HttpGet("user/{id}/{filename}")] - public async Task GetUserFile(string id, string filename) - { - // get the user file from cloudstorage - any user can see files from another user - - bool isStatsFile = filename == "stats.json"; - - var accountID = EpicID.FromString(id); - var file = await cloudStorageService.GetFileAsync(accountID, filename); - if (file == null) - { - if (!isStatsFile) - { - return Json(new ErrorResponse() - { - ErrorCode = "errors.com.epicgames.cloudstorage.file_not_found", - ErrorMessage = $"Sorry, we couldn't find a file {filename} for account {id}", - MessageVars = new[] { filename, id }, - NumericErrorCode = 12007, - OriginatingService = "utservice", - Intent = "prod10" - }, StatusCodes.Status404NotFound); - } - - // Send a fake response in order to fix #109 (which is a game bug) - var playerName = "New Player"; - var playerID = EpicID.Empty; - var account = await accountService.GetAccountAsync(accountID); - if (account != null) - { - playerName = account.Username; - playerID = account.ID; - } - - file = new CloudFile() { RawContent = Encoding.UTF8.GetBytes($"{{\"PlayerName\":\"{playerName}\",StatsID:\"{playerID}\",Version:0}}") }; - } - - if (isStatsFile) - { - // HACK: Fix game bug where stats.json is expected to always have nul character at the end - // Bug is at UnrealTournament\Source\UnrealTournament\Private\Slate\Panels\SUTStatsViewerPanel.cpp:415 - var tmp = new byte[file.RawContent.Length + 1]; - Array.Copy(file.RawContent, tmp, file.RawContent.Length); - tmp[tmp.Length - 1] = 0; - - file.RawContent = tmp; - } - - return new FileContentResult(file.RawContent, "application/octet-stream"); - } - - [HttpPut("user/{id}/{filename}")] - public async Task UpdateUserFile(string id, string filename) - { - if (User.Identity is not EpicUserIdentity user) - return Unauthorized(); - var accountID = EpicID.FromString(id); - if (user.Session.AccountID != accountID) - { - // cannot modify other's files - - // unless you are a game server with this player and are modifying this player's stats file - var isServerWithPlayer = await matchmakingService.DoesClientOwnGameServerWithPlayerAsync(user.Session.ClientID, accountID); - if (!isServerWithPlayer || filename != "stats.json") - { - return Unauthorized(); - } - } - - await cloudStorageService.UpdateFileAsync(accountID, filename, Request.BodyReader); - return Ok(); - } - - [HttpGet("system")] - public Task ListSystemFiles() - { - return ListUserFiles(EpicID.Empty.ToString()); - } - - [HttpGet("system/{filename}")] - public async Task GetSystemFile(string filename) - { - return await GetUserFile(EpicID.Empty.ToString(), filename); - } -} diff --git a/UT4MasterServer/Controllers/CustomAPIController.cs b/UT4MasterServer/Controllers/CustomAPIController.cs index 67918ece..25d8ba47 100644 --- a/UT4MasterServer/Controllers/CustomAPIController.cs +++ b/UT4MasterServer/Controllers/CustomAPIController.cs @@ -2,10 +2,10 @@ using Microsoft.AspNetCore.Http.Extensions; using Microsoft.AspNetCore.Mvc; using Microsoft.Extensions.Options; -using System.Text; -using UT4MasterServer.Models; -using UT4MasterServer.Other; -using UT4MasterServer.Services; +using System.Text; +using UT4MasterServer.Controllers.UT; +using UT4MasterServer.Models.Settings; +using UT4MasterServer.Services.Scoped; namespace UT4MasterServer.Controllers; @@ -14,7 +14,7 @@ public sealed class CustomAPIController : JsonAPIController private readonly IOptions configuration; private readonly AccountService accountService; - public CustomAPIController(ILogger logger, IOptions configuration, AccountService accountService) : base(logger) + public CustomAPIController(ILogger logger, IOptions configuration, AccountService accountService) : base(logger) { this.configuration = configuration; this.accountService = accountService; @@ -52,9 +52,9 @@ public IActionResult ShowMyInfo() } return Content(sb.ToString()); - } - - // www.epicgames.com/id/login?noHostRedirect=true + } + + // www.epicgames.com/id/login?noHostRedirect=true [HttpGet("id/login")] public IActionResult EpicLoginPage() { diff --git a/UT4MasterServer/Controllers/EntitlementController.cs b/UT4MasterServer/Controllers/EntitlementController.cs deleted file mode 100644 index c61ca2f9..00000000 --- a/UT4MasterServer/Controllers/EntitlementController.cs +++ /dev/null @@ -1,74 +0,0 @@ -using Microsoft.AspNetCore.Mvc; -using Newtonsoft.Json.Linq; -using UT4MasterServer.Authentication; -using UT4MasterServer.Models.Requests; -using UT4MasterServer.Other; - -namespace UT4MasterServer.Controllers; - -/// -/// entitlement-public-service-prod08.ol.epicgames.com -/// -[ApiController] -[Route("entitlement/api/account/{id}/entitlements")] -//[AuthorizeBearer] -[Produces("application/json")] -public sealed class EntitlementController : JsonAPIController -{ - private static readonly List mapEntitlementIDs; - private static readonly List cosmeticEntitlementIDs; - private const string UTEntitlementID = "b8538c739273426aa35a98220e258d55"; - - static EntitlementController() - { - // the following ids are found in UT4's source code in file UnrealTournament.cpp - mapEntitlementIDs = new List(); - mapEntitlementIDs.Add("0d5e275ca99d4cf0b03c518a6b279e26"); // DM-Lea - mapEntitlementIDs.Add("48d281f487154bb29dd75bd7bb95ac8e"); // CTF-Pistola - mapEntitlementIDs.Add("d8ac8a7ce06d44ab8e6b7284184e556e"); // DM-Batrankus - mapEntitlementIDs.Add("08af4962353443058766998d6b881707"); // DM-Backspace - mapEntitlementIDs.Add("27f36270a1ec44509e72687c4ba6845a"); // DM-Salt - mapEntitlementIDs.Add("a99f379bfb9b41c69ddf0bfbc4a48860"); // CTF-Polaris - mapEntitlementIDs.Add("65fb5029cddb4de7b5fa155b6992e6a3"); // DM-Unsaved - - // list of these is also partially in UnrealTournament.cpp, - // but this was retrieved by looping over all cosmetics and calling - // GetRequiredEntitlementFromAsset. - cosmeticEntitlementIDs = new List(); - cosmeticEntitlementIDs.Add("a18ab3a3eb6644b7842750fc7613ec01"); // TC_ArmorNewV - cosmeticEntitlementIDs.Add("606862e8a0ec4f5190f67c6df9d4ea81"); // BP_SkullHornsMask & BP_SkullMask - cosmeticEntitlementIDs.Add("91afa66fbf744726af33dba391657296"); // BP_Round_HelmetLeader & BP_Round_HelmetGoggles - cosmeticEntitlementIDs.Add("9a1ad6c3c10e438f9602c14ad1b67bfa"); // BP_CardboardHat_Leader & BP_CardboardHat - cosmeticEntitlementIDs.Add("8747335f79dd4bec8ddc03214c307950"); // BP_BaseballHat_Leader & BP_BaseballHat - //cosmeticEntitlementIDS.Add("527E7E209F4142F8835BA696919E2BEC"); // BP_Char_Oct2015, broken character that we don't want people to have - - // TODO: find a way to include halloween cosmetics, they seem to be handled differently in the game - } - - public EntitlementController(ILogger logger) : base(logger) - { - - } - - [HttpGet] - public IActionResult ListEntitlements(string id) - { - // TODO: Permission: "Sorry your login does not posses the permissions 'entitlement:account:{id_from_param}:entitlements READ' needed to perform the requested operation" - - EpicID eid = EpicID.FromString(id); - - List entitlements = new List(); - entitlements.Add(new Entitlement("UnrealTournament", UTEntitlementID, eid)); - foreach (var entitlementID in mapEntitlementIDs) - { - entitlements.Add(new Entitlement(entitlementID, entitlementID, eid)); - } - // TODO: decide how players unlock these special cosmetics - //foreach (var entitlementID in cosmeticEntitlementIDs) - //{ - // entitlements.Add(new Entitlement(entitlementID, entitlementID, eid)); - //} - - return new JsonResult(entitlements); - } -} diff --git a/UT4MasterServer/Controllers/Epic/AccountController.cs b/UT4MasterServer/Controllers/Epic/AccountController.cs new file mode 100644 index 00000000..09348aa4 --- /dev/null +++ b/UT4MasterServer/Controllers/Epic/AccountController.cs @@ -0,0 +1,365 @@ +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.Options; +using Newtonsoft.Json.Linq; +using UT4MasterServer.Authentication; +using UT4MasterServer.Common.Helpers; +using UT4MasterServer.Common; +using UT4MasterServer.Models; +using UT4MasterServer.Models.DTO.Responses; +using UT4MasterServer.Models.Settings; +using UT4MasterServer.Services.Scoped; + +namespace UT4MasterServer.Controllers.Epic; + +/// +/// account-public-service-prod03.ol.epicgames.com +/// +[ApiController] +[Route("account/api")] +[AuthorizeBearer] +[Produces("application/json")] +public sealed class AccountController : JsonAPIController +{ + private readonly SessionService sessionService; + private readonly AccountService accountService; + private readonly IOptions reCaptchaSettings; + + public AccountController(ILogger logger, AccountService accountService, SessionService sessionService, IOptions reCaptchaSettings) : base(logger) + { + this.accountService = accountService; + this.sessionService = sessionService; + this.reCaptchaSettings = reCaptchaSettings; + } + + #region ACCOUNT LISTING API + + [HttpGet("public/account/{id}")] + public async Task GetAccount(string id) + { + if (User.Identity is not EpicUserIdentity authenticatedUser) + return Unauthorized(); + + // TODO: EPIC doesn't throw here if id is invalid (like 'abc'). Return this same ErrorResponse like for account_not_found + EpicID eid = EpicID.FromString(id); + + if (eid != authenticatedUser.Session.AccountID) + return Unauthorized(); + + logger.LogInformation($"{authenticatedUser.Session.AccountID} is looking for account {id}"); + + var account = await accountService.GetAccountAsync(eid); + if (account == null) + return NotFound(new ErrorResponse + { + ErrorCode = "errors.com.epicgames.account.account_not_found", + ErrorMessage = $"Sorry, we couldn't find an account for {id}", + MessageVars = new[] { id }, + NumericErrorCode = 18007, + OriginatingService = "com.epicgames.account.public", + Intent = "prod", + }); + + var obj = new JObject(); + obj.Add("id", account.ID.ToString()); + obj.Add("displayName", account.Username); + obj.Add("name", $"{account.Username}"); // fake a random one + obj.Add("email", account.Email);//$"{account.ID}@{Request.Host}"); // fake a random one + obj.Add("failedLoginAttempts", 0); + obj.Add("lastLogin", account.LastLoginAt.ToStringISO()); + obj.Add("numberOfDisplayNameChanges", 0); + obj.Add("ageGroup", "UNKNOWN"); + obj.Add("headless", false); + obj.Add("country", "US"); // two letter country code + obj.Add("lastName", $"{account.Username}"); // fake a random one + obj.Add("preferredLanguage", "en"); // two letter language code + obj.Add("canUpdateDisplayName", true); + obj.Add("tfaEnabled", true); + obj.Add("emailVerified", false);//true); + obj.Add("minorVerified", false); + obj.Add("minorExpected", false); + obj.Add("minorStatus", "UNKNOWN"); + obj.Add("cabinedMode", false); + obj.Add("hasHashedEmail", false); + + return Json(obj.ToString(Newtonsoft.Json.Formatting.None)); + } + + [HttpGet("public/account")] + public async Task GetAccounts([FromQuery(Name = "accountId")] List accountIDs) + { + if (User.Identity is not EpicUserIdentity authenticatedUser) + return Unauthorized(); + + if (accountIDs.Count == 0 || accountIDs.Count > 100) + { + return NotFound(new ErrorResponse + { + ErrorCode = "errors.com.epicgames.account.invalid_account_id_count", + ErrorMessage = "Sorry, the number of account id should be at least one and not more than 100.", + MessageVars = new[] { "100" }, + NumericErrorCode = 18066, + OriginatingService = "com.epicgames.account.public", + Intent = "prod", + }); + } + + var ids = accountIDs.Distinct().Select(x => EpicID.FromString(x)); + var accounts = await accountService.GetAccountsAsync(ids.ToList()); + + var retrievedAccountIDs = accounts.Select(x => x.ID.ToString()); + logger.LogInformation($"{authenticatedUser.Session.AccountID} is looking for {string.Join(", ", retrievedAccountIDs)}"); + + // create json response + var arr = new JArray(); + foreach (var account in accounts) + { + var obj = new JObject(); + obj.Add("id", account.ID.ToString()); + obj.Add("displayName", account.Username); + if (account.ID == authenticatedUser.Session.AccountID) + { + // this is returned only when you ask about yourself + obj.Add("minorVerified", false); + obj.Add("minorStatus", "UNKNOWN"); + obj.Add("cabinedMode", false); + } + + obj.Add("externalAuths", new JObject()); + arr.Add(obj); + } + + return Json(arr); + } + + #endregion + + #region UNIMPORTANT API + + [HttpGet("accounts/{id}/metadata")] + public IActionResult GetMetadata(string id) + { + EpicID eid = EpicID.FromString(id); + + logger.LogInformation($"Get metadata of {eid}"); + + // unknown structure, but epic always seems to respond with this + return Json("{}"); + } + + [HttpGet("public/account/{id}/externalAuths")] + public IActionResult GetExternalAuths(string id) + { + EpicID eid = EpicID.FromString(id); + + logger.LogInformation($"Get external auths of {eid}"); + // we don't really care about these, but structure for my github externalAuth is the following: + /* + [{ + "accountId": "0b0f09b400854b9b98932dd9e5abe7c5", "type": "github", + "externalAuthId": "timiimit", "externalDisplayName": "timiimit", + "authIds": [ { "id": "timiimit", "type": "github_login" } ], + "dateAdded": "2018-01-17T18:58:39.831Z" + }] + */ + return Json("[]"); + } + + [HttpGet("epicdomains/ssodomains")] + [AllowAnonymous] + public IActionResult GetSSODomains() + { + logger.LogInformation(@"Get SSO domains"); + + // epic responds with this: ["unrealengine.com","unrealtournament.com","fortnite.com","epicgames.com"] + + return Json("[]"); + } + + #endregion + + #region NON-EPIC API + + [HttpPost("create/account")] + [AllowAnonymous] + public async Task RegisterAccount([FromForm] string username, [FromForm] string email, [FromForm] string password, [FromForm] string recaptchaToken) + { + var reCaptchaSecret = reCaptchaSettings.Value.SecretKey; + var httpClient = new HttpClient(); + var httpResponse = await httpClient.GetAsync($"https://www.google.com/recaptcha/api/siteverify?secret={reCaptchaSecret}&response={recaptchaToken}"); + if (httpResponse.StatusCode != System.Net.HttpStatusCode.OK) + { + return Conflict("Recaptcha validation failed"); + } + + var jsonResponse = await httpResponse.Content.ReadAsStringAsync(); + var jsonData = JObject.Parse(jsonResponse); + if (jsonData["success"]?.ToObject() != true) + { + return Conflict("Recaptcha validation failed"); + } + + var account = await accountService.GetAccountAsync(username); + if (account != null) + { + logger.LogInformation($"Could not register duplicate account: {username}"); + return Conflict("Username already exists"); + } + + if (!ValidationHelper.ValidateUsername(username)) + { + logger.LogInformation($"Entered an invalid username: {username}"); + return Conflict("You have entered an invalid username"); + } + + email = email.ToLower(); + account = await accountService.GetAccountByEmailAsync(email); + if (account != null) + { + logger.LogInformation($"Could not register duplicate email: {email}"); + return Conflict("Email already exists"); + } + + if (!ValidationHelper.ValidateEmail(email)) + { + logger.LogInformation($"Entered an invalid email format: {email}"); + return Conflict("You have entered an invalid email address"); + } + + if (!ValidationHelper.ValidatePassword(password)) + { + logger.LogInformation($"Entered password was in invalid format"); + return Conflict("Unexpected password format"); + } + + await accountService.CreateAccountAsync(username, email, password); // TODO: this cannot fail? + + + logger.LogInformation($"Registered new user: {username}"); + + return Ok("Account created successfully"); + } + + [HttpPatch("update/username")] + public async Task UpdateUsername([FromForm] string newUsername) + { + if (User.Identity is not EpicUserIdentity user) + { + return Unauthorized(); + } + + if (!ValidationHelper.ValidateUsername(newUsername)) + { + return ValidationProblem(); + } + + var matchingAccount = await accountService.GetAccountAsync(newUsername); + if (matchingAccount != null) + { + logger.LogInformation($"Change Username failed, already taken: {newUsername}"); + return Conflict(new ErrorResponse() + { + ErrorMessage = $"Username already taken" + }); + } + + var account = await accountService.GetAccountAsync(user.Session.AccountID); + if (account == null) + { + return NotFound(new ErrorResponse() + { + ErrorMessage = $"Failed to retrieve your account" + }); + } + + account.Username = newUsername; + await accountService.UpdateAccountAsync(account); + + logger.LogInformation($"Updated username for {user.Session.AccountID} to: {newUsername}"); + + return Ok("Changed username successfully"); + } + + [HttpPatch("update/email")] + public async Task UpdateEmail([FromForm] string newEmail) + { + if (User.Identity is not EpicUserIdentity user) + { + return Unauthorized(); + } + + newEmail = newEmail.ToLower(); + if (!ValidationHelper.ValidateEmail(newEmail)) + { + return ValidationProblem(); + } + + var account = await accountService.GetAccountAsync(user.Session.AccountID); + if (account == null) + { + return NotFound(new ErrorResponse() + { + ErrorMessage = $"Failed to retrieve your account" + }); + } + + account.Email = newEmail; + await accountService.UpdateAccountAsync(account); + + logger.LogInformation($"Updated email for {user.Session.AccountID} to: {newEmail}"); + + return Ok("Changed email successfully"); + } + + [HttpPatch("update/password")] + public async Task UpdatePassword([FromForm] string currentPassword, [FromForm] string newPassword) + { + if (User.Identity is not EpicUserIdentity user) + { + throw new UnauthorizedAccessException(); + } + + if (user.Session.ClientID != ClientIdentification.Launcher.ID) + { + throw new UnauthorizedAccessException("Password can only be changed from the website"); + } + + // passwords should already be hashed, but check its length just in case + if (!ValidationHelper.ValidatePassword(newPassword)) + { + return BadRequest(new ErrorResponse() + { + ErrorMessage = $"newPassword is not a SHA512 hash" + }); + } + + var account = await accountService.GetAccountAsync(user.Session.AccountID); + if (account == null) + { + return NotFound(new ErrorResponse() + { + ErrorMessage = $"Failed to retrieve your account" + }); + } + + if (!account.CheckPassword(currentPassword, false)) + { + return BadRequest(new ErrorResponse() + { + ErrorMessage = $"Current Password is invalid" + }); + } + + await accountService.UpdateAccountPasswordAsync(account, newPassword); + + // logout user to make sure they remember they changed password by being forced to log in again, + // as well as prevent anyone else from using this account after successful password change. + await sessionService.RemoveSessionsWithFilterAsync(EpicID.Empty, user.Session.AccountID, EpicID.Empty); + + logger.LogInformation($"Updated password for {user.Session.AccountID}"); + + return Ok("Changed password successfully"); + } + + #endregion +} diff --git a/UT4MasterServer/Controllers/Epic/CloudStorageController.cs b/UT4MasterServer/Controllers/Epic/CloudStorageController.cs new file mode 100644 index 00000000..75cf6709 --- /dev/null +++ b/UT4MasterServer/Controllers/Epic/CloudStorageController.cs @@ -0,0 +1,149 @@ +using Microsoft.AspNetCore.Mvc; +using Newtonsoft.Json.Linq; +using System.Text; +using UT4MasterServer.Authentication; +using UT4MasterServer.Common; +using UT4MasterServer.Services.Scoped; +using UT4MasterServer.Common.Helpers; +using UT4MasterServer.Models.DTO.Responses; +using UT4MasterServer.Models.Database; + +namespace UT4MasterServer.Controllers.Epic; + +[ApiController] +[Route("ut/api/cloudstorage")] +[AuthorizeBearer] +[Produces("application/octet-stream")] +public sealed class CloudStorageController : JsonAPIController +{ + private readonly CloudStorageService cloudStorageService; + private readonly MatchmakingService matchmakingService; + private readonly AccountService accountService; + + public CloudStorageController(ILogger logger, + CloudStorageService cloudStorageService, + MatchmakingService matchmakingService, + AccountService accountService) : base(logger) + { + this.cloudStorageService = cloudStorageService; + this.matchmakingService = matchmakingService; + this.accountService = accountService; + } + + [HttpGet("user/{id}")] + public async Task ListUserFiles(string id) + { + // list all files this user has in storage - any user can see files from another user + + var eid = EpicID.FromString(id); + + var files = await cloudStorageService.ListFilesAsync(eid); + + var arr = new JArray(); + foreach (var file in files) + { + var obj = new JObject(); + obj.Add("uniqueFilename", file.Filename); + obj.Add("filename", file.Filename); + obj.Add("hash", file.Hash); + obj.Add("hash256", file.Hash256); + obj.Add("length", file.Length); + obj.Add("contentType", "text/plain"); // this seems to be constant + obj.Add("uploaded", file.UploadedAt.ToStringISO()); + obj.Add("storageType", "S3"); + obj.Add("accountId", id); + if (eid.IsEmpty) + { + obj.Add("doNotCache", false); + } + arr.Add(obj); + } + + return Json(arr); + } + + [HttpGet("user/{id}/{filename}")] + public async Task GetUserFile(string id, string filename) + { + // get the user file from cloudstorage - any user can see files from another user + + bool isStatsFile = filename == "stats.json"; + + var accountID = EpicID.FromString(id); + var file = await cloudStorageService.GetFileAsync(accountID, filename); + if (file == null) + { + if (!isStatsFile) + { + return NotFound(new ErrorResponse() + { + ErrorCode = "errors.com.epicgames.cloudstorage.file_not_found", + ErrorMessage = $"Sorry, we couldn't find a file {filename} for account {id}", + MessageVars = new[] { filename, id }, + NumericErrorCode = 12007, + OriginatingService = "utservice", + Intent = "prod10" + }); + } + + // Send a fake response in order to fix #109 (which is a game bug) + var playerName = "New Player"; + var playerID = EpicID.Empty; + var account = await accountService.GetAccountAsync(accountID); + if (account != null) + { + playerName = account.Username; + playerID = account.ID; + } + + file = new CloudFile() { RawContent = Encoding.UTF8.GetBytes($"{{\"PlayerName\":\"{playerName}\",StatsID:\"{playerID}\",Version:0}}") }; + } + + if (isStatsFile) + { + // HACK: Fix game bug where stats.json is expected to always have nul character at the end + // Bug is at UnrealTournament\Source\UnrealTournament\Private\Slate\Panels\SUTStatsViewerPanel.cpp:415 + var tmp = new byte[file.RawContent.Length + 1]; + Array.Copy(file.RawContent, tmp, file.RawContent.Length); + tmp[tmp.Length - 1] = 0; + + file.RawContent = tmp; + } + + return new FileContentResult(file.RawContent, "application/octet-stream"); + } + + [HttpPut("user/{id}/{filename}")] + public async Task UpdateUserFile(string id, string filename) + { + if (User.Identity is not EpicUserIdentity user) + return Unauthorized(); + var accountID = EpicID.FromString(id); + if (user.Session.AccountID != accountID) + { + // cannot modify other's files + + // unless you are a game server with this player and are modifying this player's stats file + var isServerWithPlayer = await matchmakingService.DoesClientOwnGameServerWithPlayerAsync(user.Session.ClientID, accountID); + if (!isServerWithPlayer || filename != "stats.json") + { + return Unauthorized(); + } + } + + await cloudStorageService.UpdateFileAsync(accountID, filename, Request.Body); + return Ok(); + } + + [HttpGet("system")] + public Task ListSystemFiles() + { + return ListUserFiles(EpicID.Empty.ToString()); + } + + [HttpGet("system/{filename}")] + public async Task GetSystemFile(string filename) + { + return await GetUserFile(EpicID.Empty.ToString(), filename); + } +} diff --git a/UT4MasterServer/Controllers/Epic/EntitlementController.cs b/UT4MasterServer/Controllers/Epic/EntitlementController.cs new file mode 100644 index 00000000..618f74e2 --- /dev/null +++ b/UT4MasterServer/Controllers/Epic/EntitlementController.cs @@ -0,0 +1,72 @@ +using Microsoft.AspNetCore.Mvc; +using UT4MasterServer.Models.DTO.Responses; +using UT4MasterServer.Common; + +namespace UT4MasterServer.Controllers.Epic; + +/// +/// entitlement-public-service-prod08.ol.epicgames.com +/// +[ApiController] +[Route("entitlement/api/account/{id}/entitlements")] +//[AuthorizeBearer] +[Produces("application/json")] +public sealed class EntitlementController : JsonAPIController +{ + private static readonly List mapEntitlementIDs; + private static readonly List cosmeticEntitlementIDs; + private const string UTEntitlementID = "b8538c739273426aa35a98220e258d55"; + + static EntitlementController() + { + // the following ids are found in UT4's source code in file UnrealTournament.cpp + mapEntitlementIDs = new List(); + mapEntitlementIDs.Add("0d5e275ca99d4cf0b03c518a6b279e26"); // DM-Lea + mapEntitlementIDs.Add("48d281f487154bb29dd75bd7bb95ac8e"); // CTF-Pistola + mapEntitlementIDs.Add("d8ac8a7ce06d44ab8e6b7284184e556e"); // DM-Batrankus + mapEntitlementIDs.Add("08af4962353443058766998d6b881707"); // DM-Backspace + mapEntitlementIDs.Add("27f36270a1ec44509e72687c4ba6845a"); // DM-Salt + mapEntitlementIDs.Add("a99f379bfb9b41c69ddf0bfbc4a48860"); // CTF-Polaris + mapEntitlementIDs.Add("65fb5029cddb4de7b5fa155b6992e6a3"); // DM-Unsaved + + // list of these is also partially in UnrealTournament.cpp, + // but this was retrieved by looping over all cosmetics and calling + // GetRequiredEntitlementFromAsset. + cosmeticEntitlementIDs = new List(); + cosmeticEntitlementIDs.Add("a18ab3a3eb6644b7842750fc7613ec01"); // TC_ArmorNewV + cosmeticEntitlementIDs.Add("606862e8a0ec4f5190f67c6df9d4ea81"); // BP_SkullHornsMask & BP_SkullMask + cosmeticEntitlementIDs.Add("91afa66fbf744726af33dba391657296"); // BP_Round_HelmetLeader & BP_Round_HelmetGoggles + cosmeticEntitlementIDs.Add("9a1ad6c3c10e438f9602c14ad1b67bfa"); // BP_CardboardHat_Leader & BP_CardboardHat + cosmeticEntitlementIDs.Add("8747335f79dd4bec8ddc03214c307950"); // BP_BaseballHat_Leader & BP_BaseballHat + //cosmeticEntitlementIDS.Add("527E7E209F4142F8835BA696919E2BEC"); // BP_Char_Oct2015, broken character that we don't want people to have + + // TODO: find a way to include halloween cosmetics, they seem to be handled differently in the game + } + + public EntitlementController(ILogger logger) : base(logger) + { + + } + + [HttpGet] + public IActionResult ListEntitlements(string id) + { + // TODO: Permission: "Sorry your login does not posses the permissions 'entitlement:account:{id_from_param}:entitlements READ' needed to perform the requested operation" + + EpicID eid = EpicID.FromString(id); + + List entitlements = new List(); + entitlements.Add(new EntitlementResponse("UnrealTournament", UTEntitlementID, eid)); + foreach (var entitlementID in mapEntitlementIDs) + { + entitlements.Add(new EntitlementResponse(entitlementID, entitlementID, eid)); + } + // TODO: decide how players unlock these special cosmetics + //foreach (var entitlementID in cosmeticEntitlementIDs) + //{ + // entitlements.Add(new Entitlement(entitlementID, entitlementID, eid)); + //} + + return Ok(entitlements); + } +} diff --git a/UT4MasterServer/Controllers/Epic/FriendsController.cs b/UT4MasterServer/Controllers/Epic/FriendsController.cs new file mode 100644 index 00000000..a8d5b412 --- /dev/null +++ b/UT4MasterServer/Controllers/Epic/FriendsController.cs @@ -0,0 +1,155 @@ +using Microsoft.AspNetCore.Mvc; +using Newtonsoft.Json.Linq; +using UT4MasterServer.Authentication; +using UT4MasterServer.Models.Database; +using UT4MasterServer.Common; +using UT4MasterServer.Services.Scoped; +using UT4MasterServer.Common.Helpers; + +namespace UT4MasterServer.Controllers.Epic; + +/// +/// friends-public-service-prod06.ol.epicgames.com +/// +[ApiController] +[Route("friends/api/public")] +[AuthorizeBearer] +[Produces("application/json")] +public sealed class FriendsController : JsonAPIController +{ + private readonly FriendService friendService; + + public FriendsController(ILogger logger, FriendService friendService) : base(logger) + { + this.friendService = friendService; + } + + #region friends + + [HttpGet("friends/{id}")] + public async Task GetFriends(string id, [FromQuery] bool? includePending) + { + if (User.Identity is not EpicUserIdentity authenticatedUser) + return Unauthorized(); + + var eid = EpicID.FromString(id); + + if (eid != authenticatedUser.Session.AccountID) + return Json("[]", StatusCodes.Status401Unauthorized); + + // TODO: Check permission: "Sorry your login does not posses the permission 'friends:{id_from_parameter} READ' needed to perform the requested operation" + + var friends = await friendService.GetFriendsAsync(eid); + + JArray arr = new JArray(); + foreach (var friend in friends) + { + var other = friend.Sender == eid ? friend.Receiver : friend.Sender; + var status = friend.Status == FriendStatus.Accepted ? "ACCEPTED" : "PENDING"; + var direction = friend.Sender == eid ? "OUTBOUND" : "INBOUND"; + + JObject obj = new JObject(); + obj.Add("accountId", other.ToString()); + obj.Add("status", status); + obj.Add("direction", direction); + obj.Add("created", DateTime.UtcNow.ToStringISO()); // should we care? + obj.Add("favourite", false); // TODO: figure out if it's possible to set to true normally + arr.Add(obj); + } + + return Json(arr); + } + + [HttpPost("friends/{id}/{friendID}")] + public async Task SendFriendRequest(string id, string friendID) + { + if (User.Identity is not EpicUserIdentity authenticatedUser) + return Unauthorized(); + + var eid = EpicID.FromString(id); + + if (eid != authenticatedUser.Session.AccountID) + return Json("[]", StatusCodes.Status401Unauthorized); + + await friendService.SendFriendRequestAsync(eid, EpicID.FromString(friendID)); + + return NoContent(); + } + + [HttpDelete("friends/{id}/{friendID}")] + public async Task RemoveFriend(string id, string friendID) + { + if (User.Identity is not EpicUserIdentity authenticatedUser) + return Unauthorized(); + + var eid = EpicID.FromString(id); + + if (eid != authenticatedUser.Session.AccountID) + return Json("[]", StatusCodes.Status401Unauthorized); + + await friendService.CancelFriendRequestAsync(eid, EpicID.FromString(friendID)); + + return NoContent(); + } + + #endregion + + #region blocklist + + [HttpGet("blocklist/{id}")] + public async Task GetBlockedAccounts(string id) + { + if (User.Identity is not EpicUserIdentity authenticatedUser) + return Json("[]", StatusCodes.Status401Unauthorized); + + var eid = EpicID.FromString(id); + + if (eid != authenticatedUser.Session.AccountID) + return Json("[]", StatusCodes.Status401Unauthorized); + + // TODO: Check permission: "Sorry your login does not posses the permission 'blockList:{id_from_parameter} READ' needed to perform the requested operation" + + var blockedUsers = await friendService.GetBlockedUsersAsync(eid); + + JArray arr = new JArray(); + foreach (var blockedUser in blockedUsers) + { + arr.Add(blockedUser.Receiver.ToString()); + } + return Json(arr); + } + + [HttpPost("blocklist/{id}/{friendID}")] + public async Task BlockAccount(string id, string friendID) + { + if (User.Identity is not EpicUserIdentity authenticatedUser) + return Unauthorized(); + + var eid = EpicID.FromString(id); + + if (eid != authenticatedUser.Session.AccountID) + return Json("[]", StatusCodes.Status401Unauthorized); + + await friendService.BlockAccountAsync(eid, EpicID.FromString(friendID)); + + return NoContent(); + } + + [HttpDelete("blocklist/{id}/{friendID}")] + public async Task UnblockAccount(string id, string friendID) + { + if (User.Identity is not EpicUserIdentity authenticatedUser) + return Unauthorized(); + + var eid = EpicID.FromString(id); + + if (eid != authenticatedUser.Session.AccountID) + return Json("[]", StatusCodes.Status401Unauthorized); + + await friendService.UnblockAccountAsync(eid, EpicID.FromString(friendID)); + + return NoContent(); + } + + #endregion +} diff --git a/UT4MasterServer/Controllers/PersonaController.cs b/UT4MasterServer/Controllers/Epic/PersonaController.cs similarity index 97% rename from UT4MasterServer/Controllers/PersonaController.cs rename to UT4MasterServer/Controllers/Epic/PersonaController.cs index 571f718a..4445cc4a 100644 --- a/UT4MasterServer/Controllers/PersonaController.cs +++ b/UT4MasterServer/Controllers/Epic/PersonaController.cs @@ -1,9 +1,9 @@ using Microsoft.AspNetCore.Mvc; using Newtonsoft.Json.Linq; using UT4MasterServer.Authentication; +using UT4MasterServer.Common; using UT4MasterServer.Models.Requests; -using UT4MasterServer.Other; -using UT4MasterServer.Services; +using UT4MasterServer.Services.Scoped; namespace UT4MasterServer.Controllers; diff --git a/UT4MasterServer/Controllers/Epic/SessionController.cs b/UT4MasterServer/Controllers/Epic/SessionController.cs new file mode 100644 index 00000000..c91bf1f7 --- /dev/null +++ b/UT4MasterServer/Controllers/Epic/SessionController.cs @@ -0,0 +1,445 @@ +using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.Options; +using Newtonsoft.Json.Linq; +using UT4MasterServer.Authentication; +using UT4MasterServer.Models.Database; +using UT4MasterServer.Common; +using UT4MasterServer.Services.Scoped; +using UT4MasterServer.Services.Singleton; +using UT4MasterServer.Models.DTO.Responses; +using UT4MasterServer.Common.Helpers; +using UT4MasterServer.Models.Settings; + +namespace UT4MasterServer.Controllers.Epic; + +/// +/// account-public-service-prod03.ol.epicgames.com +/// +[ApiController] +[AuthorizeBearer] +[Route("account/api/oauth")] +[Produces("application/json")] +public sealed class SessionController : JsonAPIController +{ + private readonly AccountService accountService; + private readonly SessionService sessionService; + private readonly CodeService codeService; + private readonly bool allowPasswordGrant; + + public SessionController( + SessionService sessionService, CodeService codeService, AccountService accountService, + IOptions settings, ILogger logger) : base(logger) + { + this.codeService = codeService; + this.sessionService = sessionService; + this.accountService = accountService; + allowPasswordGrant = settings.Value.AllowPasswordGrantType; + } + + [AuthorizeBasic] + [HttpPost("token")] + public async Task Authenticate( + [FromForm(Name = "grant_type")] string? grantType, + [FromForm(Name = "includePerms")] bool? includePerms, + [FromForm(Name = "code")] string? code, + [FromForm(Name = "exchange_code")] string? exchangeCode, + [FromForm(Name = "refresh_token")] string? refreshToken, + [FromForm(Name = "username")] string? username, + [FromForm(Name = "password")] string? password) + { + if (User.Identity is not EpicClientIdentity user) + return Unauthorized(); + + EpicID clientID = user.Client.ID; + Session? session = null; + Account? account = null; + switch (grantType) + { + case "authorization_code": + { + if (code != null) + { + var codeAuth = await codeService.TakeCodeAsync(CodeKind.Authorization, code); + if (codeAuth != null) + session = await sessionService.CreateSessionAsync(codeAuth.AccountID, clientID, SessionCreationMethod.AuthorizationCode); + } + else + { + return ErrorInvalidRequest("code"); + } + break; + } + case "exchange_code": + { + if (exchangeCode != null) + { + // TODO: Check if user has permission and return "Sorry your login does not posses the permissions 'account:oauth:exchangeTokenCode CREATE' needed to perform the requested operation" + var codeExchange = await codeService.TakeCodeAsync(CodeKind.Exchange, exchangeCode); + if (codeExchange != null) + session = await sessionService.CreateSessionAsync(codeExchange.AccountID, clientID, SessionCreationMethod.ExchangeCode); + } + else + { + return ErrorInvalidRequest("exchange_code"); + } + break; + } + case "refresh_token": + { + if (refreshToken != null) + { + session = await sessionService.RefreshSessionAsync(refreshToken); + } + else + { + return ErrorInvalidRequest("refresh_token"); + } + break; + } + case "client_credentials": + { + // always just userless session, usually used for access to public cloudstorage + session = await sessionService.CreateSessionAsync(EpicID.Empty, clientID, SessionCreationMethod.ClientCredentials); + break; + } + case "password": + { + // NOTE: this grant_type is not recommended anymore: https://oauth.net/2/grant-types/password/ + // also this: https://stackoverflow.com/questions/62395052/oauth-password-grant-replacement + // + // we could still support it, since ut understands it cuz its old and we don't + // really need multi-factor auth. it is after all the way that ut's login screen + // works when you start the game without launcher (and without UT4UU). + + // EPIC is returning this ErrorResponse after checking username and password... This is better place IMO + if (!allowPasswordGrant) + { + return Unauthorized(new ErrorResponse + { + ErrorCode = "errors.com.epicgames.common.oauth.unauthorized_client", + ErrorMessage = $"Sorry your client is not allowed to use the grant type {grantType}. Please download and use UT4UU", + NumericErrorCode = 1015, + OriginatingService = "com.epicgames.account.public", + Intent = "prod", + ErrorDescription = $"Sorry your client is not allowed to use the grant type {grantType}. Please download and use UT4UU", + ErrorName = "unauthorized_client", + }); + } + + if (username == null) + { + return ErrorInvalidRequest("username"); + } + + if (password == null) + { + return ErrorInvalidRequest("password"); + } + + account = await accountService.GetAccountUsernameOrEmailAsync(username); + if (account != null && account.CheckPassword(password, allowPasswordGrant)) + { + session = await sessionService.CreateSessionAsync(account.ID, clientID, SessionCreationMethod.Password); + } + + break; + } + default: + { + return BadRequest(new ErrorResponse + { + ErrorCode = "errors.com.epicgames.common.oauth.unsupported_grant_type", + ErrorMessage = $"Unsupported grant type: {grantType}", + NumericErrorCode = 1016, + OriginatingService = "com.epicgames.account.public", + Intent = "prod", + ErrorDescription = $"Unsupported grant type: {grantType}", + ErrorName = "unsupported_grant_type", + }); + } + } + + if (session == null) + { + var message = $"Invalid credentials"; + logger.LogError(message); + + // TODO: Find proper response + return Unauthorized(new ErrorResponse + { + ErrorCode = "errors.com.epicgames.common.oauth.invalid_credentials", + ErrorMessage = message, + NumericErrorCode = 0, + OriginatingService = "com.epicgames.account.public", + Intent = "prod", + ErrorDescription = message, + ErrorName = "invalid_credentials", + }); + } + + if (account == null) + account = await accountService.GetAccountAsync(session.AccountID); + logger.LogInformation($"User '{account?.ToString() ?? EpicID.Empty.ToString()}' was authorized via {grantType}"); + + if (account != null) + { + account.LastLoginAt = DateTime.UtcNow; + await accountService.UpdateAccountAsync(account); + } + + JObject obj = new JObject(); + obj.Add("access_token", session.AccessToken.Value); + obj.Add("expires_in", session.AccessToken.ExpirationDurationInSeconds); + obj.Add("expires_at", session.AccessToken.ExpirationTime.ToStringISO()); + obj.Add("token_type", HttpAuthorization.BearerScheme.ToLower()); + if (!session.AccountID.IsEmpty && account != null) + { + if (session.RefreshToken != null) // should never be null here + { + obj.Add("refresh_token", session.RefreshToken.Value); + obj.Add("refresh_expires", session.RefreshToken.ExpirationDurationInSeconds); + obj.Add("refresh_expires_at", session.RefreshToken.ExpirationTime.ToStringISO()); + } + obj.Add("account_id", account.ID.ToString()); + } + obj.Add("client_id", session.ClientID.ToString()); + obj.Add("internal_client", false); + obj.Add("client_service", "ut"); + if (!session.AccountID.IsEmpty && account != null) + { + obj.Add("displayName", account.Username); + + if (includePerms == true) + { + // should probably be okay to send empty array + obj.Add("perms", new JArray()); + // the actual response can be found in perms.json file in original HttpListener example of this project + } + + obj.Add("app", "ut"); + obj.Add("in_app_id", account.ID.ToString()); + obj.Add("device_id", "465a117c2b144b5c8222ee71b9bc8da2"); // unsure about this, probably some ip tracking feature + } + return Json(obj); + } + + [HttpGet("exchange")] + public async Task CreateExchangeCode() + { + if (User.Identity is not EpicUserIdentity user) + return Unauthorized(); + + /* TODO: Check if user has permission and return: + { + "errorCode": "errors.com.epicgames.common.missing_permission", + "errorMessage": "Sorry your login does not posses the permissions 'account:oauth:exchangeTokenCode CREATE' needed to perform the requested operation", + "messageVars": [ + + "account:oauth:exchangeTokenCode", + "CREATE" + ], + "numericErrorCode": 1023, + "originatingService": "com.epicgames.account.public", + "intent": "prod" + } + */ + + var code = await codeService.CreateCodeAsync(CodeKind.Exchange, user.Session.AccountID, user.Session.ClientID); + if (code == null) + return BadRequest(new ErrorResponse() + { + ErrorName = "cannot_create_exchangecode" // TODO: find proper response + }); + + var obj = new JObject(); + obj.Add("expiresInSeconds", code.Token.ExpirationDurationInSeconds); + obj.Add("code", code.Token.Value); + obj.Add("creatingClientId", code.CreatingClientID.ToString()); + return Json(obj); + } + + [HttpGet("auth")] // this action is originally on "www.epicgames.com/id/api/redirect" + some query specifying client_id and something else, forgot what + public async Task CreateAuthorizationCode() + { + if (User.Identity is not EpicUserIdentity user) + return Unauthorized(); + + var authCode = await codeService.CreateCodeAsync(CodeKind.Authorization, user.Session.AccountID, user.Session.ClientID); + if (authCode == null) + return BadRequest(); + + logger.LogInformation($"Created authorization code: {authCode.Token}"); + + // this is epic's response when you are logged in. + // when you are not logged in, sid is set, and authorizationCode is null (or maybe just empty?) + var obj = new JObject(); + obj.Add("redirectUrl", $"https://localhost/launcher/authorized?code={authCode.Token}"); + obj.Add("authorizationCode", authCode.Token.ToString()); + obj.Add("sid", null); + return Json(obj); + } + + // TODO: EPIC uses accessToken from URL + [HttpDelete("sessions/kill/{accessToken}")] // we don't need to use token in url because we have it in + public async Task KillSession(string accessToken) + { + if (User.Identity is not EpicUserIdentity user) + return Unauthorized(); + + // TODO: Check if session exists and return ErrorResponse: "Sorry we could not find the auth session 'myAuthSessionFromURL'" + + if (accessToken != user.AccessToken) + { + logger.LogInformation($"In request to kill session {user.Session.ID}, token in url didn't match the one in header. Killing session anyway..."); + } + + await sessionService.RemoveSessionAsync(user.Session.ID); + + logger.LogInformation($"Deleted session '{user.Session.ID}'"); + return NoContent(); + } + + [HttpDelete("sessions/kill")] + public async Task KillSessions([FromQuery] string killType) + { + if (User.Identity is not EpicUserIdentity user) + return Unauthorized(); + + switch (killType.ToUpper()) + { + case "ALL": + { + // TODO: Check permission and return ErrorResponse: "Sorry your login does not posses the permissions 'account:token:allSessionsForClient DELETE' needed to perform the requested operation" + await sessionService.RemoveSessionsWithFilterAsync(EpicID.Empty, user.Session.AccountID, EpicID.Empty); + break; + } + case "OTHERS": + { + // TODO: Check permission account:token:otherSessionsForClient DELETE + await sessionService.RemoveSessionsWithFilterAsync(EpicID.Empty, user.Session.AccountID, user.Session.ID); + break; + } + case "ALL_ACCOUNT_CLIENT": + { + // TODO: Check and return ErrorResponse: "Cannot use the killType ALL_ACCOUNT_CLIENT with a client only OauthSession." + await sessionService.RemoveSessionsWithFilterAsync(user.Session.ClientID, user.Session.AccountID, EpicID.Empty); + break; + } + case "OTHERS_ACCOUNT_CLIENT": + { + // TODO: Check and return ErrorResponse: "Cannot use the killType OTHERS_ACCOUNT_CLIENT with a client only OauthSession." + await sessionService.RemoveSessionsWithFilterAsync(user.Session.ClientID, user.Session.AccountID, user.Session.ID); + break; + } + case "OTHERS_ACCOUNT_CLIENT_SERVICE": + { + // TODO: Check and return ErrorResponse: "Cannot use the killType OTHERS_ACCOUNT_CLIENT_SERVICE with a client only OauthSession." + // i am not sure how this is supposed to differ from OTHERS_ACCOUNT_CLIENT + // perhaps service as in epic games launcher and/or website? + await sessionService.RemoveSessionsWithFilterAsync(user.Session.ClientID, user.Session.AccountID, user.Session.ID); + break; + } + default: + { + return ErrorInvalidRequest("a valid killType"); + } + } + + return NoContent(); + } + + // TODO: Make sure this does what it's supposed to. 200 OK should be enough for the client to know the session is still valid. + [HttpGet] + [Route("verify")] + public async Task Verify() + { + if (User.Identity is not EpicUserIdentity user) + return Unauthorized(); + + // no refresh needed, but we should respond with this: + /* + + { + "token": "06577db463064b89af0657b5445b08b7", + "session_id": "06577db463064b89af0657b5445b08b7", + "token_type": "bearer", + "client_id": "1252412dc7704a9690f6ea4611bc81ee", + "internal_client": false, + "client_service": "ut", + "account_id": "64bf8c6d81004e88823d577abe157373", + "expires_in": 28799, + "expires_at": "2022-12-20T23:26:25.049Z", + "auth_method": "exchange_code", + "display_name": "norandomemails", + "app": "ut", + "in_app_id": "64bf8c6d81004e88823d577abe157373", + "device_id": "ee64ee5f292b45f089a368cb7e43d82d", + "perms": [...] + } + */ + + string auth_method = user.Session.CreationMethod switch + { + SessionCreationMethod.AuthorizationCode => "authorization_code", + SessionCreationMethod.ExchangeCode => "exchange_code", + SessionCreationMethod.ClientCredentials => "client_credentials", + SessionCreationMethod.Password => "password", + _ => throw new NotImplementedException(), + }; + + var account = await accountService.GetAccountAsync(user.Session.AccountID); + + var obj = new JObject() + { + { "token", user.AccessToken }, + { "session_id", user.Session.ID.ToString() }, + { "token_type", HttpAuthorization.BearerScheme.ToLower() }, + { "client_id", user.Session.ClientID.ToString() }, + { "internal_client", false }, + { "client_service", "ut" }, + { "account_id", user.Session.AccountID.ToString() }, + { "expires_in", user.Session.AccessToken.ExpirationDurationInSeconds }, + { "expires_at", user.Session.AccessToken.ExpirationTime.ToStringISO() }, + { "auth_method", auth_method }, + { "display_name", account?.Username }, + { "app", "ut" }, + { "in_app_id", user.Session.AccountID.ToString() }, + { "device_id", "ee64ee5f292b45f089a368cb7e43d82d" }, // TODO: figure out proper handling of device id + { "perms", new JArray() } // TODO: none for now + }; + + return Json(obj); + } + + //[HttpPost] + //[Route("login/account")] + //public async Task LoginAccount([FromForm] string username, [FromForm] string password) + //{ + // // this action is originally on "www.epicgames.com/id/api/login" + // var account = await accountService.GetAccountAsync(username, password); + // if (account == null) + // return NotFound(); + + // var session = await sessionService.CreateSessionAsync(account.ID, ClientIdentification.Launcher.ID, SessionCreationMethod.ClientCredentials); ; + // if (session == null) + // return NotFound(); + + // logger.LogInformation($"Created session {session.ID} for user {username}"); + // return NoContent(); + //} + + [NonAction] + private BadRequestObjectResult ErrorInvalidRequest(string requiredInput) + { + return BadRequest(new ErrorResponse + { + ErrorCode = "errors.com.epicgames.common.oauth.invalid_request", + ErrorMessage = $"{requiredInput} is required.", + NumericErrorCode = 1013, + OriginatingService = "com.epicgames.account.public", + Intent = "prod", + ErrorDescription = $"{requiredInput} is required.", + ErrorName = "invalid_request", + }); + } +} diff --git a/UT4MasterServer/Controllers/ErrorsController.cs b/UT4MasterServer/Controllers/ErrorsController.cs index cef791ee..b6826c75 100644 --- a/UT4MasterServer/Controllers/ErrorsController.cs +++ b/UT4MasterServer/Controllers/ErrorsController.cs @@ -1,7 +1,7 @@ using Microsoft.AspNetCore.Diagnostics; using Microsoft.AspNetCore.Mvc; -using UT4MasterServer.Exceptions; -using UT4MasterServer.Models; +using UT4MasterServer.Common.Exceptions; +using UT4MasterServer.Models.DTO.Responses; namespace UT4MasterServer.Controllers; diff --git a/UT4MasterServer/Controllers/FriendsController.cs b/UT4MasterServer/Controllers/FriendsController.cs deleted file mode 100644 index e0b4373a..00000000 --- a/UT4MasterServer/Controllers/FriendsController.cs +++ /dev/null @@ -1,153 +0,0 @@ -using Microsoft.AspNetCore.Mvc; -using Newtonsoft.Json.Linq; -using UT4MasterServer.Authentication; -using UT4MasterServer.Other; -using UT4MasterServer.Services; - -namespace UT4MasterServer.Controllers; - -/// -/// friends-public-service-prod06.ol.epicgames.com -/// -[ApiController] -[Route("friends/api/public")] -[AuthorizeBearer] -[Produces("application/json")] -public sealed class FriendsController : JsonAPIController -{ - private readonly FriendService friendService; - - public FriendsController(ILogger logger, FriendService friendService) : base(logger) - { - this.friendService = friendService; - } - - #region friends - - [HttpGet("friends/{id}")] - public async Task GetFriends(string id, [FromQuery] bool? includePending) - { - if (User.Identity is not EpicUserIdentity authenticatedUser) - return Unauthorized(); - - var eid = EpicID.FromString(id); - - if (eid != authenticatedUser.Session.AccountID) - return Json("[]", StatusCodes.Status401Unauthorized); - - // TODO: Check permission: "Sorry your login does not posses the permission 'friends:{id_from_parameter} READ' needed to perform the requested operation" - - var friends = await friendService.GetFriendsAsync(eid); - - JArray arr = new JArray(); - foreach (var friend in friends) - { - var other = friend.Sender == eid ? friend.Receiver : friend.Sender; - var status = friend.Status == Models.FriendStatus.Accepted ? "ACCEPTED" : "PENDING"; - var direction = friend.Sender == eid ? "OUTBOUND" : "INBOUND"; - - JObject obj = new JObject(); - obj.Add("accountId", other.ToString()); - obj.Add("status", status); - obj.Add("direction", direction); - obj.Add("created", DateTime.UtcNow.ToStringISO()); // should we care? - obj.Add("favourite", false); // TODO: figure out if it's possible to set to true normally - arr.Add(obj); - } - - return Json(arr); - } - - [HttpPost("friends/{id}/{friendID}")] - public async Task SendFriendRequest(string id, string friendID) - { - if (User.Identity is not EpicUserIdentity authenticatedUser) - return Unauthorized(); - - var eid = EpicID.FromString(id); - - if (eid != authenticatedUser.Session.AccountID) - return Json("[]", StatusCodes.Status401Unauthorized); - - await friendService.SendFriendRequestAsync(eid, EpicID.FromString(friendID)); - - return NoContent(); - } - - [HttpDelete("friends/{id}/{friendID}")] - public async Task RemoveFriend(string id, string friendID) - { - if (User.Identity is not EpicUserIdentity authenticatedUser) - return Unauthorized(); - - var eid = EpicID.FromString(id); - - if (eid != authenticatedUser.Session.AccountID) - return Json("[]", StatusCodes.Status401Unauthorized); - - await friendService.CancelFriendRequestAsync(eid, EpicID.FromString(friendID)); - - return NoContent(); - } - - #endregion - - #region blocklist - - [HttpGet("blocklist/{id}")] - public async Task GetBlockedAccounts(string id) - { - if (User.Identity is not EpicUserIdentity authenticatedUser) - return Json("[]", StatusCodes.Status401Unauthorized); - - var eid = EpicID.FromString(id); - - if (eid != authenticatedUser.Session.AccountID) - return Json("[]", StatusCodes.Status401Unauthorized); - - // TODO: Check permission: "Sorry your login does not posses the permission 'blockList:{id_from_parameter} READ' needed to perform the requested operation" - - var blockedUsers = await friendService.GetBlockedUsersAsync(eid); - - JArray arr = new JArray(); - foreach (var blockedUser in blockedUsers) - { - arr.Add(blockedUser.Receiver.ToString()); - } - return Json(arr); - } - - [HttpPost("blocklist/{id}/{friendID}")] - public async Task BlockAccount(string id, string friendID) - { - if (User.Identity is not EpicUserIdentity authenticatedUser) - return Unauthorized(); - - var eid = EpicID.FromString(id); - - if (eid != authenticatedUser.Session.AccountID) - return Json("[]", StatusCodes.Status401Unauthorized); - - await friendService.BlockAccountAsync(eid, EpicID.FromString(friendID)); - - return NoContent(); - } - - [HttpDelete("blocklist/{id}/{friendID}")] - public async Task UnblockAccount(string id, string friendID) - { - if (User.Identity is not EpicUserIdentity authenticatedUser) - return Unauthorized(); - - var eid = EpicID.FromString(id); - - if (eid != authenticatedUser.Session.AccountID) - return Json("[]", StatusCodes.Status401Unauthorized); - - await friendService.UnblockAccountAsync(eid, EpicID.FromString(friendID)); - - return NoContent(); - } - - #endregion -} diff --git a/UT4MasterServer/Controllers/JsonAPIController.cs b/UT4MasterServer/Controllers/JsonAPIController.cs index 5a5f0ab0..77c6d588 100644 --- a/UT4MasterServer/Controllers/JsonAPIController.cs +++ b/UT4MasterServer/Controllers/JsonAPIController.cs @@ -2,9 +2,7 @@ using Microsoft.Extensions.Options; using Newtonsoft.Json.Linq; using System.Net; -using System.Text.Json; -using UT4MasterServer.Models; -using UT4MasterServer.Other; +using UT4MasterServer.Models.Settings; namespace UT4MasterServer.Controllers; @@ -69,17 +67,17 @@ public ContentResult Json(JToken content, int status, bool humanReadable) return r; } - [NonAction] - public JsonResult Json(object? content) - { - return new JsonResult(content, new JsonSerializerOptions() { Converters = { new EpicIDJsonConverter() } }); - } + //[NonAction] + //public JsonResult Json(object? content) + //{ + // return new JsonResult(content, new JsonSerializerOptions() { Converters = { new EpicIDJsonConverter() } }); + //} - [NonAction] - public JsonResult Json(object? content, int status) - { - return new JsonResult(content, new JsonSerializerOptions() { Converters = { new EpicIDJsonConverter() } }) { StatusCode = status }; - } + //[NonAction] + //public JsonResult Json(object? content, int status) + //{ + // return new JsonResult(content, new JsonSerializerOptions() { Converters = { new EpicIDJsonConverter() } }) { StatusCode = status }; + //} [NonAction] protected IPAddress? GetClientIP(IOptions? proxyInfo) @@ -106,7 +104,11 @@ public JsonResult Json(object? content, int status) // look through each instance of the header bottom-to-top for (int hi = headers.Count - 1; hi >= 0; hi--) { - string[] headerParts = headers[hi].Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); + var header = headers[hi]; + if (header == null) + continue; + + string[] headerParts = header.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); // look through each part of header from right-to-left for (int i = headerParts.Length - 1; i >= 0; i--) diff --git a/UT4MasterServer/Controllers/SessionController.cs b/UT4MasterServer/Controllers/SessionController.cs deleted file mode 100644 index d2957d87..00000000 --- a/UT4MasterServer/Controllers/SessionController.cs +++ /dev/null @@ -1,435 +0,0 @@ -using Microsoft.AspNetCore.Mvc; -using Microsoft.Extensions.Options; -using Newtonsoft.Json.Linq; -using UT4MasterServer.Authentication; -using UT4MasterServer.Models; -using UT4MasterServer.Models.Requests; -using UT4MasterServer.Other; -using UT4MasterServer.Services; - -namespace UT4MasterServer.Controllers; - -/// -/// account-public-service-prod03.ol.epicgames.com -/// -[ApiController] -[AuthorizeBearer] -[Route("account/api/oauth")] -[Produces("application/json")] -public sealed class SessionController : JsonAPIController -{ - private readonly AccountService accountService; - private readonly SessionService sessionService; - private readonly CodeService codeService; - private readonly bool allowPasswordGrant; - - public SessionController( - SessionService sessionService, CodeService codeService, AccountService accountService, - IOptions settings, ILogger logger) : base(logger) - { - this.codeService = codeService; - this.sessionService = sessionService; - this.accountService = accountService; - allowPasswordGrant = settings.Value.AllowPasswordGrantType; - } - - [AuthorizeBasic] - [HttpPost("token")] - public async Task Authenticate([FromForm] AuthenticateRequest request) - { - if (User.Identity is not EpicClientIdentity user) - return Unauthorized(); - - EpicID clientID = user.Client.ID; - Session? session = null; - Account? account = null; - switch (request.GrantType) - { - case "authorization_code": - { - if (request.Code != null) - { - var codeAuth = await codeService.TakeCodeAsync(CodeKind.Authorization, request.Code); - if (codeAuth != null) - session = await sessionService.CreateSessionAsync(codeAuth.AccountID, clientID, SessionCreationMethod.AuthorizationCode); - } - else - { - return ErrorInvalidRequest("code"); - } - break; - } - case "exchange_code": - { - if (request.ExchangeCode != null) - { - // TODO: Check if user has permission and return "Sorry your login does not posses the permissions 'account:oauth:exchangeTokenCode CREATE' needed to perform the requested operation" - var codeExchange = await codeService.TakeCodeAsync(CodeKind.Exchange, request.ExchangeCode); - if (codeExchange != null) - session = await sessionService.CreateSessionAsync(codeExchange.AccountID, clientID, SessionCreationMethod.ExchangeCode); - } - else - { - return ErrorInvalidRequest("exchange_code"); - } - break; - } - case "refresh_token": - { - if (request.RefreshToken != null) - { - session = await sessionService.RefreshSessionAsync(request.RefreshToken); - } - else - { - return ErrorInvalidRequest("refresh_token"); - } - break; - } - case "client_credentials": - { - // always just userless session, usually used for access to public cloudstorage - session = await sessionService.CreateSessionAsync(EpicID.Empty, clientID, SessionCreationMethod.ClientCredentials); - break; - } - case "password": - { - // NOTE: this grant_type is not recommended anymore: https://oauth.net/2/grant-types/password/ - // also this: https://stackoverflow.com/questions/62395052/oauth-password-grant-replacement - // - // we could still support it, since ut understands it cuz its old and we don't - // really need multi-factor auth. it is after all the way that ut's login screen - // works when you start the game without launcher (and without UT4UU). - - // EPIC is returning this ErrorResponse after checking username and password... This is better place IMO - if (!allowPasswordGrant) - { - return Unauthorized(new ErrorResponse - { - ErrorCode = "errors.com.epicgames.common.oauth.unauthorized_client", - ErrorMessage = $"Sorry your client is not allowed to use the grant type {request.GrantType}. Please download and use UT4UU", - NumericErrorCode = 1015, - OriginatingService = "com.epicgames.account.public", - Intent = "prod", - ErrorDescription = $"Sorry your client is not allowed to use the grant type {request.GrantType}. Please download and use UT4UU", - Error = "unauthorized_client", - }); - } - - if (request.Username == null) - { - return ErrorInvalidRequest("username"); - } - - if (request.Password == null) - { - return ErrorInvalidRequest("password"); - } - - account = await accountService.GetAccountUsernameOrEmailAsync(request.Username); - if (account != null && account.CheckPassword(request.Password, allowPasswordGrant)) - { - session = await sessionService.CreateSessionAsync(account.ID, clientID, SessionCreationMethod.Password); - } - - break; - } - default: - { - return BadRequest(new ErrorResponse - { - ErrorCode = "errors.com.epicgames.common.oauth.unsupported_grant_type", - ErrorMessage = $"Unsupported grant type: {request.GrantType}", - NumericErrorCode = 1016, - OriginatingService = "com.epicgames.account.public", - Intent = "prod", - ErrorDescription = $"Unsupported grant type: {request.GrantType}", - Error = "unsupported_grant_type", - }); - } - } - - if (session == null) - { - var message = $"Invalid credentials"; - logger.LogError(message); - - // TODO: Find proper response - return Unauthorized(new ErrorResponse - { - ErrorCode = "errors.com.epicgames.common.oauth.invalid_credentials", - ErrorMessage = message, - NumericErrorCode = 0, - OriginatingService = "com.epicgames.account.public", - Intent = "prod", - ErrorDescription = message, - Error = "invalid_credentials", - }); - } - - if (account == null) - account = await accountService.GetAccountAsync(session.AccountID); - logger.LogInformation($"User '{account?.ToString() ?? EpicID.Empty.ToString()}' was authorized via {request.GrantType}"); - - if (account != null) - { - account.LastLoginAt = DateTime.UtcNow; - await accountService.UpdateAccountAsync(account); - } - - JObject obj = new JObject(); - obj.Add("access_token", session.AccessToken.Value); - obj.Add("expires_in", session.AccessToken.ExpirationDurationInSeconds); - obj.Add("expires_at", session.AccessToken.ExpirationTime.ToStringISO()); - obj.Add("token_type", HttpAuthorization.BearerScheme.ToLower()); - if (!session.AccountID.IsEmpty && account != null) - { - if (session.RefreshToken != null) // should never be null here - { - obj.Add("refresh_token", session.RefreshToken.Value); - obj.Add("refresh_expires", session.RefreshToken.ExpirationDurationInSeconds); - obj.Add("refresh_expires_at", session.RefreshToken.ExpirationTime.ToStringISO()); - } - obj.Add("account_id", account.ID.ToString()); - } - obj.Add("client_id", session.ClientID.ToString()); - obj.Add("internal_client", false); - obj.Add("client_service", "ut"); - if (!session.AccountID.IsEmpty && account != null) - { - obj.Add("displayName", account.Username); - - if (request.IncludePerms == true) - { - // should probably be okay to send empty array - obj.Add("perms", new JArray()); - // the actual response can be found in perms.json file in original HttpListener example of this project - } - - obj.Add("app", "ut"); - obj.Add("in_app_id", account.ID.ToString()); - obj.Add("device_id", "465a117c2b144b5c8222ee71b9bc8da2"); // unsure about this, probably some ip tracking feature - } - return Json(obj); - } - - [HttpGet("exchange")] - public async Task CreateExchangeCode() - { - if (User.Identity is not EpicUserIdentity user) - return Unauthorized(); - - /* TODO: Check if user has permission and return: - { - "errorCode": "errors.com.epicgames.common.missing_permission", - "errorMessage": "Sorry your login does not posses the permissions 'account:oauth:exchangeTokenCode CREATE' needed to perform the requested operation", - "messageVars": [ - - "account:oauth:exchangeTokenCode", - "CREATE" - ], - "numericErrorCode": 1023, - "originatingService": "com.epicgames.account.public", - "intent": "prod" - } - */ - - var code = await codeService.CreateCodeAsync(CodeKind.Exchange, user.Session.AccountID, user.Session.ClientID); - if (code == null) - return BadRequest(new ErrorResponse() - { - Error = "cannot_create_exchangecode" // TODO: find proper response - }); - - var obj = new JObject(); - obj.Add("expiresInSeconds", code.Token.ExpirationDurationInSeconds); - obj.Add("code", code.Token.Value); - obj.Add("creatingClientId", code.CreatingClientID.ToString()); - return Json(obj); - } - - [HttpGet("auth")] // this action is originally on "www.epicgames.com/id/api/redirect" + some query specifying client_id and something else, forgot what - public async Task CreateAuthorizationCode() - { - if (User.Identity is not EpicUserIdentity user) - return Unauthorized(); - - var authCode = await codeService.CreateCodeAsync(CodeKind.Authorization, user.Session.AccountID, user.Session.ClientID); - if (authCode == null) - return BadRequest(); - - logger.LogInformation($"Created authorization code: {authCode.Token}"); - - // this is epic's response when you are logged in. - // when you are not logged in, sid is set, and authorizationCode is null (or maybe just empty?) - var obj = new JObject(); - obj.Add("redirectUrl", $"https://localhost/launcher/authorized?code={authCode.Token}"); - obj.Add("authorizationCode", authCode.Token.ToString()); - obj.Add("sid", null); - return Json(obj); - } - - // TODO: EPIC uses accessToken from URL - [HttpDelete("sessions/kill/{accessToken}")] // we don't need to use token in url because we have it in - public async Task KillSession(string accessToken) - { - if (User.Identity is not EpicUserIdentity user) - return Unauthorized(); - - // TODO: Check if session exists and return ErrorResponse: "Sorry we could not find the auth session 'myAuthSessionFromURL'" - - if (accessToken != user.AccessToken) - { - logger.LogInformation($"In request to kill session {user.Session.ID}, token in url didn't match the one in header. Killing session anyway..."); - } - - await sessionService.RemoveSessionAsync(user.Session.ID); - - logger.LogInformation($"Deleted session '{user.Session.ID}'"); - return NoContent(); - } - - [HttpDelete("sessions/kill")] - public async Task KillSessions([FromQuery] string killType) - { - if (User.Identity is not EpicUserIdentity user) - return Unauthorized(); - - switch (killType.ToUpper()) - { - case "ALL": - { - // TODO: Check permission and return ErrorResponse: "Sorry your login does not posses the permissions 'account:token:allSessionsForClient DELETE' needed to perform the requested operation" - await sessionService.RemoveSessionsWithFilterAsync(EpicID.Empty, user.Session.AccountID, EpicID.Empty); - break; - } - case "OTHERS": - { - // TODO: Check permission account:token:otherSessionsForClient DELETE - await sessionService.RemoveSessionsWithFilterAsync(EpicID.Empty, user.Session.AccountID, user.Session.ID); - break; - } - case "ALL_ACCOUNT_CLIENT": - { - // TODO: Check and return ErrorResponse: "Cannot use the killType ALL_ACCOUNT_CLIENT with a client only OauthSession." - await sessionService.RemoveSessionsWithFilterAsync(user.Session.ClientID, user.Session.AccountID, EpicID.Empty); - break; - } - case "OTHERS_ACCOUNT_CLIENT": - { - // TODO: Check and return ErrorResponse: "Cannot use the killType OTHERS_ACCOUNT_CLIENT with a client only OauthSession." - await sessionService.RemoveSessionsWithFilterAsync(user.Session.ClientID, user.Session.AccountID, user.Session.ID); - break; - } - case "OTHERS_ACCOUNT_CLIENT_SERVICE": - { - // TODO: Check and return ErrorResponse: "Cannot use the killType OTHERS_ACCOUNT_CLIENT_SERVICE with a client only OauthSession." - // i am not sure how this is supposed to differ from OTHERS_ACCOUNT_CLIENT - // perhaps service as in epic games launcher and/or website? - await sessionService.RemoveSessionsWithFilterAsync(user.Session.ClientID, user.Session.AccountID, user.Session.ID); - break; - } - default: - { - return ErrorInvalidRequest("a valid killType"); - } - } - - return NoContent(); - } - - // TODO: Make sure this does what it's supposed to. 200 OK should be enough for the client to know the session is still valid. - [HttpGet] - [Route("verify")] - public async Task Verify() - { - if (User.Identity is not EpicUserIdentity user) - return Unauthorized(); - - // no refresh needed, but we should respond with this: - /* - - { - "token": "06577db463064b89af0657b5445b08b7", - "session_id": "06577db463064b89af0657b5445b08b7", - "token_type": "bearer", - "client_id": "1252412dc7704a9690f6ea4611bc81ee", - "internal_client": false, - "client_service": "ut", - "account_id": "64bf8c6d81004e88823d577abe157373", - "expires_in": 28799, - "expires_at": "2022-12-20T23:26:25.049Z", - "auth_method": "exchange_code", - "display_name": "norandomemails", - "app": "ut", - "in_app_id": "64bf8c6d81004e88823d577abe157373", - "device_id": "ee64ee5f292b45f089a368cb7e43d82d", - "perms": [...] - } - */ - - string auth_method = user.Session.CreationMethod switch - { - SessionCreationMethod.AuthorizationCode => "authorization_code", - SessionCreationMethod.ExchangeCode => "exchange_code", - SessionCreationMethod.ClientCredentials => "client_credentials", - SessionCreationMethod.Password => "password", - _ => throw new NotImplementedException(), - }; - - var account = await accountService.GetAccountAsync(user.Session.AccountID); - - var obj = new JObject() - { - { "token", user.AccessToken }, - { "session_id", user.Session.ID.ToString() }, - { "token_type", HttpAuthorization.BearerScheme.ToLower() }, - { "client_id", user.Session.ClientID.ToString() }, - { "internal_client", false }, - { "client_service", "ut" }, - { "account_id", user.Session.AccountID.ToString() }, - { "expires_in", user.Session.AccessToken.ExpirationDurationInSeconds }, - { "expires_at", user.Session.AccessToken.ExpirationTime.ToStringISO() }, - { "auth_method", auth_method }, - { "display_name", account?.Username }, - { "app", "ut" }, - { "in_app_id", user.Session.AccountID.ToString() }, - { "device_id", "ee64ee5f292b45f089a368cb7e43d82d" }, // TODO: figure out proper handling of device id - { "perms", new JArray() } // TODO: none for now - }; - - return Json(obj); - } - - //[HttpPost] - //[Route("login/account")] - //public async Task LoginAccount([FromForm] string username, [FromForm] string password) - //{ - // // this action is originally on "www.epicgames.com/id/api/login" - // var account = await accountService.GetAccountAsync(username, password); - // if (account == null) - // return NotFound(); - - // var session = await sessionService.CreateSessionAsync(account.ID, ClientIdentification.Launcher.ID, SessionCreationMethod.ClientCredentials); ; - // if (session == null) - // return NotFound(); - - // logger.LogInformation($"Created session {session.ID} for user {username}"); - // return NoContent(); - //} - - [NonAction] - private BadRequestObjectResult ErrorInvalidRequest(string requiredInput) - { - return BadRequest(new ErrorResponse - { - ErrorCode = "errors.com.epicgames.common.oauth.invalid_request", - ErrorMessage = $"{requiredInput} is required.", - NumericErrorCode = 1013, - OriginatingService = "com.epicgames.account.public", - Intent = "prod", - ErrorDescription = $"{requiredInput} is required.", - Error = "invalid_request", - }); - } -} diff --git a/UT4MasterServer/Controllers/UnrealTournamentMatchmakingController.cs b/UT4MasterServer/Controllers/UT/MatchmakingController.cs similarity index 89% rename from UT4MasterServer/Controllers/UnrealTournamentMatchmakingController.cs rename to UT4MasterServer/Controllers/UT/MatchmakingController.cs index a88b66ad..6ceb0c69 100644 --- a/UT4MasterServer/Controllers/UnrealTournamentMatchmakingController.cs +++ b/UT4MasterServer/Controllers/UT/MatchmakingController.cs @@ -1,410 +1,411 @@ -#define USE_LOCALHOST_TEST - -using Microsoft.AspNetCore.Authorization; -using Microsoft.AspNetCore.Mvc; -using Microsoft.Extensions.Options; -using Newtonsoft.Json.Linq; -using System.Text.Json; -using UT4MasterServer.Authentication; -using UT4MasterServer.Models; -using UT4MasterServer.Other; -using UT4MasterServer.Services; -using JsonSerializer = System.Text.Json.JsonSerializer; - -namespace UT4MasterServer.Controllers; - -/// -/// ut-public-service-prod10.ol.epicgames.com -/// -[ApiController] -[Route("ut/api/matchmaking")] -[AuthorizeBearer] -[Produces("application/json")] -public sealed class UnrealTournamentMatchmakingController : JsonAPIController -{ - private readonly MatchmakingService matchmakingService; - private readonly TrustedGameServerService trustedGameServerService; - - private readonly IOptions configuration; - - public UnrealTournamentMatchmakingController( - ILogger logger, - IOptions configuration, - MatchmakingService matchmakingService, - ClientService clientService, - TrustedGameServerService trustedGameServerService) : base(logger) - { - this.configuration = configuration; - this.matchmakingService = matchmakingService; - this.trustedGameServerService = trustedGameServerService; - } - -#region Endpoints for Game Servers - - [HttpPost("session")] - public async Task CreateGameServer([FromBody] GameServer server) - { - if (User.Identity is not EpicUserIdentity user) - return Unauthorized(); - - var ipClient = GetClientIP(configuration); - if (ipClient == null) - { - logger.LogError("Could not determine IP Address of remote machine."); - return StatusCode(StatusCodes.Status500InternalServerError); - } - if (ipClient.AddressFamily == System.Net.Sockets.AddressFamily.InterNetworkV6) - { - logger.LogWarning("Client is using IPv6. GameServer might not be accessible by others."); - } - - server.SessionID = user.Session.ID; - server.OwningClientID = user.Session.ClientID; -#if DEBUG - server.SessionAccessToken = user.AccessToken; -#endif - server.ID = EpicID.GenerateNew(); - server.LastUpdated = DateTime.UtcNow; - - server.ServerAddress = ipClient.ToString(); - server.Started = false; - - GameServerTrust trust = GameServerTrust.Untrusted; - var trusted = await trustedGameServerService.GetAsync(server.OwningClientID); - if (trusted != null) - { - trust = trusted.TrustLevel; - } - -#if DEBUG && true - trust = GameServerTrust.Epic; -#endif - - server.Attributes.Set("UT_SERVERTRUSTLEVEL_i", (int)trust); - - if (await matchmakingService.DoesExistWithSessionAsync(server.SessionID)) - { - // each session may only create a single game server - return BadRequest(); - } - - await matchmakingService.AddAsync(server); - - return Json(server.ToJson(false)); - } - - [HttpPut("session/{id}")] - public async Task UpdateGameServer(string id, [FromBody] GameServer updatedServer) - { - if (User.Identity is not EpicUserIdentity user) - return Unauthorized(); - - var serverID = EpicID.FromString(id); - - if (updatedServer.ID != serverID) - return UnknownSessionId(id); - - updatedServer.SessionID = user.Session.ID; - updatedServer.OwningClientID = user.Session.ClientID; - - var server = await matchmakingService.GetAsync(serverID); - if (server == null) - return UnknownSessionId(id); - - if (server.OwningClientID != user.Session.ClientID) - Unauthorized(); - - server.Update(updatedServer); - - await matchmakingService.UpdateAsync(server); - - return Json(server.ToJson(false)); - } - - [HttpGet("session/{id}")] - public async Task GetGameServer(string id) - { - // NOTE: this method is called by client game after server resets session - if (User.Identity is not EpicUserIdentity user) - return Unauthorized(); - - var serverID = EpicID.FromString(id); - - var server = await matchmakingService.GetAsync(serverID); - if (server == null) - return UnknownSessionId(id); - - return Json(server.ToJson(false)); - } - - [HttpDelete("session/{id}")] - public async Task DeleteGameServer(string id) - { - if (User.Identity is not EpicUserIdentity user) - return Unauthorized(); - - var serverID = EpicID.FromString(id); - - var server = await matchmakingService.GetAsync(serverID); - if (server == null) - return UnknownSessionId(id); - - if (server.OwningClientID != user.Session.ClientID) - Unauthorized(); - - bool wasDeleted = await matchmakingService.RemoveAsync(EpicID.FromString(id)); - - // TODO: unknown actual responses but these seem to work - - if (!wasDeleted) - return UnknownSessionId(id); - - return Ok(); - } - - [HttpPost("session/{id}/start")] - public async Task NotifyGameServerIsReady(string id) - { - return await ChangeGameServerStarted(id, true); - } - - [HttpPost("session/{id}/stop")] - public async Task NotifyGameServerHasStopped(string id) - { - return await ChangeGameServerStarted(id, false); - } - - [HttpPost("session/{id}/heartbeat")] - public async Task GameServerHeartbeat(string id) - { - if (User.Identity is not EpicUserIdentity user) - return Unauthorized(); - - var server = await matchmakingService.GetAsync(EpicID.FromString(id)); - if (server == null) - return UnknownSessionId(id); - - if (server.OwningClientID != user.Session.ClientID) - Unauthorized(); - -#if false - // HACK: server will create new session 2h before current one expires. - // to guarantee us a way to track a single server between sessions - // we start sending failed heartbeat before server would - // have created it. by doing so we force the server to: - // - create new session token - // - use new session token to stop old server - // - use new session token to start new server - - if (user.Session.CreationMethod == SessionCreationMethod.ClientCredentials && - user.Session.AccessToken.ExpirationDuration < TimeSpan.FromHours(2) + TimeSpan.FromSeconds(25)) - { - return NotFound(new ErrorResponse - { - ErrorCode = "errors.com.epicgames.common.oauth.unauthorized_client", - ErrorMessage = $"Sorry your client is not allowed to use the grant type client_credentials. Please download and use UT4UU", - NumericErrorCode = 1015, - OriginatingService = "com.epicgames.account.public", - Intent = "prod", - ErrorDescription = $"Sorry your client is not allowed to use the grant type client_credentials. Please download and use UT4UU", - Error = "unauthorized_client", - }); - - //return UnknownSessionId(id); // trigger server recreation - } -#endif - - server.LastUpdated = DateTime.UtcNow; - await matchmakingService.UpdateAsync(server); - - return NoContent(); - } - - [HttpPost("session/{id}/players")] - public async Task UpdateGameServerPlayers(string id, [FromBody] GameServer serverOnlyWithPlayers) - { - if (User.Identity is not EpicUserIdentity user) - return Unauthorized(); - - var serverID = EpicID.FromString(id); - - var server = await matchmakingService.GetAsync(serverID); - if (server == null) - return NoContent(); - - if (server.OwningClientID != user.Session.ClientID) - Unauthorized(); - - // handle player list update - foreach (var player in serverOnlyWithPlayers.PublicPlayers) - { - if (!server.PublicPlayers.Where(x => x == player).Any()) - { - server.PublicPlayers.Add(player); - } - if (server.PrivatePlayers.Where(x => x == player).Any()) - { - server.PrivatePlayers.Remove(player); - } - } - foreach (var player in serverOnlyWithPlayers.PrivatePlayers) - { - if (!server.PrivatePlayers.Where(x => x == player).Any()) - { - server.PrivatePlayers.Add(player); - } - if (server.PublicPlayers.Where(x => x == player).Any()) - { - server.PublicPlayers.Remove(player); - } - } - - await matchmakingService.UpdateAsync(server); - - return Json(server.ToJson(false)); - } - - [HttpDelete("session/{id}/players")] - public async Task RemovePlayer(string id, [FromBody] EpicID[] players) - { - if (User.Identity is not EpicUserIdentity user) - return Unauthorized(); - - var server = await matchmakingService.GetAsync(EpicID.FromString(id)); - if (server == null) - return UnknownSessionId(id); - - if (server.OwningClientID != user.Session.ClientID) - Unauthorized(); - - foreach (var player in players) - { - if (!server.PublicPlayers.Remove(player)) - { - server.PrivatePlayers.Remove(player); - } - } - - await matchmakingService.UpdateAsync(server); - - return Json(server.ToJson(false)); - } - -#endregion - -#region Endpoints for Clients - - [AllowAnonymous] - [HttpPost("session/matchMakingRequest")] - public async Task ListGameServers([FromBody] GameServerFilter filter) - { - if (User.Identity is not EpicUserIdentity) - { - logger.LogInformation($"'{Request.HttpContext.Connection.RemoteIpAddress}' accessed GameServer list without authentication"); - } - - var servers = await matchmakingService.ListAsync(filter); - - //var list = new GameServer[] - //{ - // new GameServer("we", "[DS]dallastn-22938", "192.223.24.243"), - // new GameServer("cracked", "[DS]dallastn-22938", "192.223.24.243"), - // new GameServer("the", "[DS]dallastn-22938", "192.223.24.243"), - // new GameServer("code", "[DS]dallastn-22938", "192.223.24.243"), - // new GameServer("and", "[DS]dallastn-22938", "192.223.24.243"), - // new GameServer("entered", "[DS]dallastn-22938", "192.223.24.243"), - // new GameServer("the", "[DS]dallastn-22938", "192.223.24.243"), // does not show, due to duplicate data - // new GameServer("matrix", "[DS]dallastn-22938", "192.223.24.243"), - //}; - - var arr = new JArray(); - foreach (var server in servers) - { -#if DEBUG && USE_LOCALHOST_TEST - server.ServerAddress = "127.0.0.1"; -#endif - - arr.Add(server.ToJson(true)); - } - - return Json(arr); - } - - /// - /// This action is for convenience of users to be able to easily retrieve a list of hubs and/or servers. - /// - /// whether to return hubs - /// whether to return servers - /// - [AllowAnonymous] - [HttpGet("session/matchMakingRequest")] - public async Task ListGameServers([FromQuery] bool? showHubs, [FromQuery] bool? showServers) - { - if (User.Identity is not EpicUserIdentity) - { - logger.LogInformation($"'{Request.HttpContext.Connection.RemoteIpAddress}' accessed GameServer list without authentication"); - } - - // TODO: implement query filters - return await ListGameServers(new GameServerFilter()); - } - - [HttpPost("session/{id}/join")] - public async Task PlayerJoinGameServer(string id, [FromQuery(Name = "accountId")] string accountID) - { - if (User.Identity is not EpicUserIdentity user) - return Unauthorized(); - - EpicID eid = EpicID.FromString(id); - - if (!await matchmakingService.DoesExistAsync(eid)) - return UnknownSessionId(id); - - // TODO: we should verify that specific user has joined specific GameServer - // instead of just relying on GameServer blindly believing that user - // really is who he says he is. - // Then we can probably deny user's entry to GameServer by not sending data - // in QueryProfile request (just a guess). - - return NoContent(); // correct response - } - -#endregion - - [NonAction] - private NotFoundObjectResult UnknownSessionId(string id) - { - return NotFound(new ErrorResponse - { - ErrorCode = "errors.com.epicgames.modules.matchmaking.unknown_session", - ErrorMessage = $"unknown session id {id}", - MessageVars = new[] { id }, - NumericErrorCode = 12101, - OriginatingService = "utservice", - Intent = "prod10", - }); - } - - [NonAction] - private async Task ChangeGameServerStarted(string id, bool started) - { - if (User.Identity is not EpicUserIdentity user) - return Unauthorized(); - - var serverID = EpicID.FromString(id); - - var server = await matchmakingService.GetAsync(serverID); - if (server == null) - return UnknownSessionId(id); - - if (server.OwningClientID != user.Session.ClientID) - Unauthorized(); - - server.Started = started; - - // Don't immediately remove it, let it become stale. - await matchmakingService.UpdateAsync(server); - - return NoContent(); - } -} +#define USE_LOCALHOST_TEST + +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.Options; +using Newtonsoft.Json.Linq; +using UT4MasterServer.Authentication; +using UT4MasterServer.Models.Database; +using UT4MasterServer.Models.DTO.Request; +using UT4MasterServer.Models.Settings; +using UT4MasterServer.Common; +using UT4MasterServer.Services.Scoped; +using UT4MasterServer.Models.DTO.Responses; +using UT4MasterServer.Common.Enums; +using System.Text.Json.Nodes; + +namespace UT4MasterServer.Controllers.UT; + +/// +/// ut-public-service-prod10.ol.epicgames.com +/// +[ApiController] +[Route("ut/api/matchmaking")] +[AuthorizeBearer] +[Produces("application/json")] +public sealed class MatchmakingController : JsonAPIController +{ + private readonly MatchmakingService matchmakingService; + private readonly TrustedGameServerService trustedGameServerService; + + private readonly IOptions configuration; + + public MatchmakingController( + ILogger logger, + IOptions configuration, + MatchmakingService matchmakingService, + ClientService clientService, + TrustedGameServerService trustedGameServerService) : base(logger) + { + this.configuration = configuration; + this.matchmakingService = matchmakingService; + this.trustedGameServerService = trustedGameServerService; + } + + #region Endpoints for Game Servers + + [HttpPost("session")] + public async Task CreateGameServer([FromBody] GameServer server) + { + if (User.Identity is not EpicUserIdentity user) + return Unauthorized(); + + var ipClient = GetClientIP(configuration); + if (ipClient == null) + { + logger.LogError("Could not determine IP Address of remote machine."); + return StatusCode(StatusCodes.Status500InternalServerError); + } + if (ipClient.AddressFamily == System.Net.Sockets.AddressFamily.InterNetworkV6) + { + logger.LogWarning("Client is using IPv6. GameServer might not be accessible by others."); + } + + server.SessionID = user.Session.ID; + server.OwningClientID = user.Session.ClientID; +#if DEBUG + server.SessionAccessToken = user.AccessToken; +#endif + server.ID = EpicID.GenerateNew(); + server.LastUpdated = DateTime.UtcNow; + + server.ServerAddress = ipClient.ToString(); + server.Started = false; + + GameServerTrust trust = GameServerTrust.Untrusted; + var trusted = await trustedGameServerService.GetAsync(server.OwningClientID); + if (trusted != null) + { + trust = trusted.TrustLevel; + } + +#if DEBUG && true + trust = GameServerTrust.Epic; +#endif + + server.Attributes.Set("UT_SERVERTRUSTLEVEL_i", (int)trust); + + if (await matchmakingService.DoesExistWithSessionAsync(server.SessionID)) + { + // each session may only create a single game server + return BadRequest(); + } + + await matchmakingService.AddAsync(server); + + return Ok(server.ToJson(false)); + } + + [HttpPut("session/{id}")] + public async Task UpdateGameServer(string id, [FromBody] GameServer updatedServer) + { + if (User.Identity is not EpicUserIdentity user) + return Unauthorized(); + + var serverID = EpicID.FromString(id); + + if (updatedServer.ID != serverID) + return UnknownSessionId(id); + + updatedServer.SessionID = user.Session.ID; + updatedServer.OwningClientID = user.Session.ClientID; + + var server = await matchmakingService.GetAsync(serverID); + if (server == null) + return UnknownSessionId(id); + + if (server.OwningClientID != user.Session.ClientID) + Unauthorized(); + + server.Update(updatedServer); + + await matchmakingService.UpdateAsync(server); + + return Ok(server.ToJson(false)); + } + + [HttpGet("session/{id}")] + public async Task GetGameServer(string id) + { + // NOTE: this method is called by client game after server resets session + if (User.Identity is not EpicUserIdentity user) + return Unauthorized(); + + var serverID = EpicID.FromString(id); + + var server = await matchmakingService.GetAsync(serverID); + if (server == null) + return UnknownSessionId(id); + + return Ok(server.ToJson(false)); + } + + [HttpDelete("session/{id}")] + public async Task DeleteGameServer(string id) + { + if (User.Identity is not EpicUserIdentity user) + return Unauthorized(); + + var serverID = EpicID.FromString(id); + + var server = await matchmakingService.GetAsync(serverID); + if (server == null) + return UnknownSessionId(id); + + if (server.OwningClientID != user.Session.ClientID) + Unauthorized(); + + bool wasDeleted = await matchmakingService.RemoveAsync(EpicID.FromString(id)); + + // TODO: unknown actual responses but these seem to work + + if (!wasDeleted) + return UnknownSessionId(id); + + return Ok(); + } + + [HttpPost("session/{id}/start")] + public async Task NotifyGameServerIsReady(string id) + { + return await ChangeGameServerStarted(id, true); + } + + [HttpPost("session/{id}/stop")] + public async Task NotifyGameServerHasStopped(string id) + { + return await ChangeGameServerStarted(id, false); + } + + [HttpPost("session/{id}/heartbeat")] + public async Task GameServerHeartbeat(string id) + { + if (User.Identity is not EpicUserIdentity user) + return Unauthorized(); + + var server = await matchmakingService.GetAsync(EpicID.FromString(id)); + if (server == null) + return UnknownSessionId(id); + + if (server.OwningClientID != user.Session.ClientID) + Unauthorized(); + +#if false + // HACK: server will create new session 2h before current one expires. + // to guarantee us a way to track a single server between sessions + // we start sending failed heartbeat before server would + // have created it. by doing so we force the server to: + // - create new session token + // - use new session token to stop old server + // - use new session token to start new server + + if (user.Session.CreationMethod == SessionCreationMethod.ClientCredentials && + user.Session.AccessToken.ExpirationDuration < TimeSpan.FromHours(2) + TimeSpan.FromSeconds(25)) + { + return NotFound(new ErrorResponse + { + ErrorCode = "errors.com.epicgames.common.oauth.unauthorized_client", + ErrorMessage = $"Sorry your client is not allowed to use the grant type client_credentials. Please download and use UT4UU", + NumericErrorCode = 1015, + OriginatingService = "com.epicgames.account.public", + Intent = "prod", + ErrorDescription = $"Sorry your client is not allowed to use the grant type client_credentials. Please download and use UT4UU", + Error = "unauthorized_client", + }); + + //return UnknownSessionId(id); // trigger server recreation + } +#endif + + server.LastUpdated = DateTime.UtcNow; + await matchmakingService.UpdateAsync(server); + + return NoContent(); + } + + [HttpPost("session/{id}/players")] + public async Task UpdateGameServerPlayers(string id, [FromBody] GameServer serverOnlyWithPlayers) + { + if (User.Identity is not EpicUserIdentity user) + return Unauthorized(); + + var serverID = EpicID.FromString(id); + + var server = await matchmakingService.GetAsync(serverID); + if (server == null) + return NoContent(); + + if (server.OwningClientID != user.Session.ClientID) + Unauthorized(); + + // handle player list update + foreach (var player in serverOnlyWithPlayers.PublicPlayers) + { + if (!server.PublicPlayers.Where(x => x == player).Any()) + { + server.PublicPlayers.Add(player); + } + if (server.PrivatePlayers.Where(x => x == player).Any()) + { + server.PrivatePlayers.Remove(player); + } + } + foreach (var player in serverOnlyWithPlayers.PrivatePlayers) + { + if (!server.PrivatePlayers.Where(x => x == player).Any()) + { + server.PrivatePlayers.Add(player); + } + if (server.PublicPlayers.Where(x => x == player).Any()) + { + server.PublicPlayers.Remove(player); + } + } + + await matchmakingService.UpdateAsync(server); + + return Ok(server.ToJson(false)); + } + + [HttpDelete("session/{id}/players")] + public async Task RemovePlayer(string id, [FromBody] EpicID[] players) + { + if (User.Identity is not EpicUserIdentity user) + return Unauthorized(); + + var server = await matchmakingService.GetAsync(EpicID.FromString(id)); + if (server == null) + return UnknownSessionId(id); + + if (server.OwningClientID != user.Session.ClientID) + Unauthorized(); + + foreach (var player in players) + { + server.PublicPlayers.Remove(player); + server.PrivatePlayers.Remove(player); + } + + await matchmakingService.UpdateAsync(server); + + return Ok(server.ToJson(false)); + } + + #endregion + + #region Endpoints for Clients + + [AllowAnonymous] + [HttpPost("session/matchMakingRequest")] + public async Task ListGameServers([FromBody] GameServerFilterRequest filter) + { + if (User.Identity is not EpicUserIdentity) + { + logger.LogInformation($"'{Request.HttpContext.Connection.RemoteIpAddress}' accessed GameServer list without authentication"); + } + + var servers = await matchmakingService.ListAsync(filter); + + //var list = new GameServer[] + //{ + // new GameServer("we", "[DS]dallastn-22938", "192.223.24.243"), + // new GameServer("cracked", "[DS]dallastn-22938", "192.223.24.243"), + // new GameServer("the", "[DS]dallastn-22938", "192.223.24.243"), + // new GameServer("code", "[DS]dallastn-22938", "192.223.24.243"), + // new GameServer("and", "[DS]dallastn-22938", "192.223.24.243"), + // new GameServer("entered", "[DS]dallastn-22938", "192.223.24.243"), + // new GameServer("the", "[DS]dallastn-22938", "192.223.24.243"), // does not show, due to duplicate data + // new GameServer("matrix", "[DS]dallastn-22938", "192.223.24.243"), + //}; + + var arr = new JsonArray(); + foreach (var server in servers) + { +#if DEBUG && USE_LOCALHOST_TEST + server.ServerAddress = "127.0.0.1"; +#endif + + arr.Add(server.ToJson(true)); + } + + return Ok(arr); + } + + /// + /// This action is for convenience of users to be able to easily retrieve a list of hubs and/or servers. + /// + /// whether to return hubs + /// whether to return servers + /// + [AllowAnonymous] + [HttpGet("session/matchMakingRequest")] + public async Task ListGameServers([FromQuery] bool? showHubs, [FromQuery] bool? showServers) + { + if (User.Identity is not EpicUserIdentity) + { + logger.LogInformation($"'{Request.HttpContext.Connection.RemoteIpAddress}' accessed GameServer list without authentication"); + } + + // TODO: implement query filters + return await ListGameServers(new GameServerFilterRequest()); + } + + [HttpPost("session/{id}/join")] + public async Task PlayerJoinGameServer(string id, [FromQuery(Name = "accountId")] string accountID) + { + if (User.Identity is not EpicUserIdentity user) + return Unauthorized(); + + EpicID eid = EpicID.FromString(id); + + if (!await matchmakingService.DoesExistAsync(eid)) + return UnknownSessionId(id); + + // TODO: we should verify that specific user has joined specific GameServer + // instead of just relying on GameServer blindly believing that user + // really is who he says he is. + // Then we can probably deny user's entry to GameServer by not sending data + // in QueryProfile request (just a guess). + + return NoContent(); // correct response + } + + #endregion + + [NonAction] + private NotFoundObjectResult UnknownSessionId(string id) + { + return NotFound(new ErrorResponse + { + ErrorCode = "errors.com.epicgames.modules.matchmaking.unknown_session", + ErrorMessage = $"unknown session id {id}", + MessageVars = new[] { id }, + NumericErrorCode = 12101, + OriginatingService = "utservice", + Intent = "prod10", + }); + } + + [NonAction] + private async Task ChangeGameServerStarted(string id, bool started) + { + if (User.Identity is not EpicUserIdentity user) + return Unauthorized(); + + var serverID = EpicID.FromString(id); + + var server = await matchmakingService.GetAsync(serverID); + if (server == null) + return UnknownSessionId(id); + + if (server.OwningClientID != user.Session.ClientID) + Unauthorized(); + + server.Started = started; + + // Don't immediately remove it, let it become stale. + await matchmakingService.UpdateAsync(server); + + return NoContent(); + } +} diff --git a/UT4MasterServer/Controllers/UT/ProfileController.cs b/UT4MasterServer/Controllers/UT/ProfileController.cs new file mode 100644 index 00000000..6c0cde2e --- /dev/null +++ b/UT4MasterServer/Controllers/UT/ProfileController.cs @@ -0,0 +1,405 @@ +using Microsoft.AspNetCore.Mvc; +using Newtonsoft.Json.Linq; +using UT4MasterServer.Authentication; +using UT4MasterServer.Controllers.Epic; +using UT4MasterServer.Models.DTO.Request; +using UT4MasterServer.Common; +using UT4MasterServer.Services.Scoped; +using UT4MasterServer.Common.Helpers; + +namespace UT4MasterServer.Controllers.UT; + +/// +/// ut-public-service-prod10.ol.epicgames.com +/// +[ApiController] +[Route("ut/api/game/v2/profile")] +[AuthorizeBearer] +[Produces("application/json")] +public sealed class ProfileController : JsonAPIController +{ + private static readonly List<(string item, int requiredLevel)> profileItems; + + static ProfileController() + { + profileItems = new() + { + ("BeanieBlack", 2), + ("Sunglasses", 3), + ("HockeyMask", 4), + ("ThundercrashMale05", 5), + ("NecrisMale01", 7), + ("ThundercrashMale03", 8), + ("NecrisHelm01", 10), + ("ThundercrashBeanieGreen", 12), + ("HockeyMask02", 14), + ("ThundercrashMale02", 17), + ("NecrisFemale02", 20), + ("BeanieWhite", 23), + ("NecrisHelm02", 26), + ("SkaarjMale01", 30), + ("BeanieGrey", 34), + ("ThundercrashBeanieRed", 39), + ("SkaarjMale02", 40), + ("ThundercrashBeret", 45), + ("NecrisMale04", 50), + + // TODO: figure out how profile items below should be unlocked + //("ThundercrashSunglasses", 0), + //("ThundercrashMale01", 0), + //("ThundercrashBeanieWhite", 0), + //("PhayderStealthHelm", 0), + //("NanoblackHelmGreen", 0), + //("NanoblackHelmBlack", 0), + //("Infiltrator", 0), + //("EnergyHelm", 0), + //("EliteAssassinHelm", 0), + }; + } + + private readonly AccountService accountService; + private readonly MatchmakingService matchmakingService; + + public ProfileController(ILogger logger, AccountService accountService, MatchmakingService matchmakingService) : base(logger) + { + this.accountService = accountService; + this.matchmakingService = matchmakingService; + } + + [HttpPost("{id}/{clientKind}/QueryProfile")] + public async Task QueryProfile(string id, + string clientKind, + [FromQuery] string profileId, + [FromQuery] int rvn) + { + if (User.Identity is not EpicUserIdentity user) + return Unauthorized(); + + bool isRequestSentFromClient = clientKind.ToLower() == "client"; + bool isRequestSentFromServer = clientKind.ToLower() == "dedicated_server"; + + // TODO: I think "rvn" is revision number and it represents index of profile change entry. + // negative values probably mean index from back-to-front in array. + + var body = await Request.Body.ReadAsStringAsync(1024); + var jsonBody = JObject.Parse(body); + + if (rvn == -1) + rvn = 1; + + // game sends empty json object as body + if (!(isRequestSentFromClient || isRequestSentFromServer) | profileId != "profile0" || !JToken.DeepEquals(jsonBody, JObject.Parse("{}"))) + { + logger.LogWarning($"QueryProfile received unexpected data! k:\"{clientKind}\" p:\"{profileId}\" rvn:\"{rvn}\" body:\"{body}\""); + } + + var account = await accountService.GetAccountAsync(EpicID.FromString(id)); + if (account == null) + return NotFound(); + + // actual response example is in /OldReferenceCode/Server.cs line 750 + + int revisionNumber = rvn + 1; + int commandRevision = rvn - 1; + + JObject obj = new(); + obj.Add("profileRevision", revisionNumber); + obj.Add("profileId", profileId); + obj.Add("profileChangesBaseRevision", revisionNumber); + JArray profileChanges = new JArray(); + // foreach { + JObject profileChange = new(); + profileChange.Add("changeType", "fullProfileUpdate"); + JObject profile = new(); + { + profile.Add("_id", account.ID.ToString()); + profile.Add("created", account.CreatedAt.ToStringISO()); + profile.Add("updated", (account.LastLoginAt - TimeSpan.FromSeconds(10)).ToStringISO()); // we don't store this info, send an arbitrary one + profile.Add("rvn", revisionNumber); + profile.Add("wipeNumber", 0); + profile.Add("accountId", account.ID.ToString()); + profile.Add("commandRevision", commandRevision); + profile.Add("profileId", profileId); + profile.Add("version", "ut_base"); + } + JObject items = new(); + foreach (var profileItem in profileItems) + { +#if (!DEBUG) || (DEBUG && true) + if (account.Level < profileItem.requiredLevel) + continue; +#endif + + // guid probably represents the id of profile item + // we don't really store obtained items, so we generate new id + // each time + string profileItemGuid = Guid.NewGuid().ToString(); + items.Add(profileItemGuid, new JObject() + { + { "templateId", "Item." + profileItem.item }, + { "attributes", new JObject() + { + { "tradable", false } + } + }, + { "quantity", 1 } + }); + } + profile.Add("items", items); + JObject stats = new(); + { + stats.Add("templateId", "profile_v2"); + JObject attributes = new(); + attributes.Add("CountryFlag", account.CountryFlag); + attributes.Add("GoldStars", account.GoldStars); + JObject loginRewards = new(); + loginRewards.Add("nextClaimTime", null); + loginRewards.Add("level", 0); + loginRewards.Add("totalDays", 0); + attributes.Add("login_rewards", loginRewards); + attributes.Add("Avatar", account.Avatar); + attributes.Add("inventory_limit_bonus", 0); + attributes.Add("daily_purchases", new JObject()); + attributes.Add("in_app_purchases", new JObject()); + attributes.Add("LastXPTime", (account.LastLoginAt - TimeSpan.FromSeconds(10)).ToUnixTimestamp()); // we don't store this info, send an arbitrary one + attributes.Add("XP", account.XP); + attributes.Add("Level", account.LevelStockLimited); // TODO: try values over 50 + attributes.Add("BlueStars", account.BlueStars); + attributes.Add("RecentXP", 0);//account.XPLastMatch); // probably xp from last finished match + attributes.Add("boosts", new JArray()); + attributes.Add("new_items", new JObject()); + stats.Add("attributes", attributes); + } + profile.Add("stats", stats); + profileChange.Add("profile", profile); + // } + profileChanges.Add(profileChange); + obj.Add("profileChanges", profileChanges); + obj.Add("profileCommandRevision", commandRevision); + obj.Add("serverTime", DateTime.UtcNow.ToStringISO()); + obj.Add("responseVersion", 1); + obj.Add("command", "QueryProfile"); + + return Json(obj); + } + + [HttpPost("{id}/{clientKind}/SetAvatarAndFlag")] + public async Task SetAvatarAndFlag(string id, string clientKind, [FromQuery] string profileId, [FromQuery] int rvn) + { + if (User.Identity is not EpicUserIdentity user) + return Unauthorized(); + + if (user.Session.AccountID != EpicID.FromString(id)) + return Unauthorized(); + + // TODO: Permission: "Sorry your login does not posses the permissions 'ut:profile:{id_from_params}:commands ALL' needed to perform the requested operation" + + if (rvn == -1) + rvn = 1; + + int revisionNumber = rvn; + + JObject obj = JObject.Parse(await Request.Body.ReadAsStringAsync(1024)); + string? avatar = obj["newAvatar"]?.ToObject(); + string? flag = obj["newFlag"]?.ToObject(); + + obj = new JObject(); + obj.Add("profileRevision", revisionNumber); + obj.Add("profileId", "profile0"); + obj.Add("profileChangesBaseRevision", revisionNumber - 1); + JArray profileChanges = new JArray(); + if (avatar != null || flag != null) + { + var acc = await accountService.GetAccountAsync(user.Session.AccountID); + if (acc == null) + { + logger.LogError("Account is null"); + return StatusCode(StatusCodes.Status500InternalServerError); // should never happen + } + + if (avatar != null) + { + acc.Avatar = avatar; + JObject profileChange = new() + { + { "changeType", "statModified" }, + { "name", "Avatar" }, + { "value", acc.Avatar } + }; + profileChanges.Add(profileChange); + } + + if (flag != null) + { + acc.CountryFlag = flag; + JObject profileChange = new() + { + { "changeType", "statModified" }, + { "name", "CountryFlag" }, + { "value", acc.CountryFlag } + }; + profileChanges.Add(profileChange); + } + + await accountService.UpdateAccountAsync(acc); + } + obj.Add("profileChanges", profileChanges); + obj.Add("profileCommandRevision", revisionNumber - 1); + obj.Add("serverTime", DateTime.UtcNow.ToStringISO()); + obj.Add("responseVersion", 1); + obj.Add("command", "SetAvatarAndFlag"); + + return Json(obj); + } + + [HttpPost("{id}/{clientKind}/GrantXP")] + public async Task GrantXP(string id, string clientKind, [FromQuery] string profileId, [FromQuery] int rvn, [FromBody] GrantXPRequest body) + { + if (User.Identity is not EpicUserIdentity user) + return Unauthorized(); + + var eid = EpicID.FromString(id); + + // only known to be sent by dedicated_server so far + bool isRequestSentFromClient = clientKind.ToLower() == "client"; + bool isRequestSentFromServer = clientKind.ToLower() == "dedicated_server"; + + if (isRequestSentFromServer && user.Session.AccountID.IsEmpty) + { + // it is "okay" to let any server handle anyone's XP since we have a limit on how much xp/h one can earn + } + else if (isRequestSentFromClient && user.Session.AccountID == eid) + { + // it is "okay" to let client modify his own XP since we have a limit on how much xp/h one can earn + } + else + { + return Unauthorized(); + } + + var acc = await accountService.GetAccountAsync(eid); + if (acc == null) + return NotFound(); + + + const double maxXPPerHour = 500.0; + var hoursSinceLastMatch = (DateTime.UtcNow - acc.LastMatchAt).TotalHours; + + // this is just some hard limit on max xp allowed per request/match + if (body.XPAmount > 300) + { + logger.LogWarning("{User} supposedly earned {XP} XP in a single match.", acc.ToString(), body.XPAmount); + body.XPAmount = 300; + } + + var maxEarnableXP = maxXPPerHour * hoursSinceLastMatch; + if (body.XPAmount > maxEarnableXP) + { + logger.LogWarning("{User} supposedly earned {XP} XP in {Hours} hours. Limiting to {AdjustedXP} XP which is max allowed XP within this timespan.", + acc.ToString(), body.XPAmount, hoursSinceLastMatch, maxEarnableXP); + body.XPAmount = (int)maxEarnableXP; + } + + + + var prevXP = acc.XP; + var prevLevel = acc.LevelStockLimited; + + if (rvn == -1) + rvn = 1; + var revisionNumber = rvn; + + var obj = new JObject(); + obj.Add("profileRevision", revisionNumber); + obj.Add("profileId", "profile0"); + obj.Add("profileChangesBaseRevision", revisionNumber - 1); + JArray profileChanges = new JArray(); + { + acc.LastMatchAt = DateTime.UtcNow; + profileChanges.Add(new JObject() + { + { "changeType", "statModified" }, + { "name", "LastXPTime" }, + { "value", acc.LastMatchAt.ToUnixTimestamp() } + }); + + //acc.XPLastMatch = body.XPAmount; + profileChanges.Add(new JObject() + { + { "changeType", "statModified" }, + { "name", "RecentXP" }, + { "value", 0 } //acc.XPLastMatch } + }); + + acc.XP += body.XPAmount; + profileChanges.Add(new JObject() + { + { "changeType", "statModified" }, + { "name", "XP" }, + { "value", acc.XP } + }); + + profileChanges.Add(new JObject() + { + { "changeType", "statModified" }, + { "name", "Level" }, + { "value", acc.LevelStockLimited } + }); + } + obj.Add("profileChanges", profileChanges); + + obj.Add("notifications", new JArray() + { + { + new JObject() + { + { "type", "XPProgress" }, + { "primary", false }, + { "prevXP", prevXP }, + { "XP", acc.XP }, + { "prevLevel", prevLevel }, + { "level", acc.LevelStockLimited } + } + } + }); + + obj.Add("profileCommandRevision", revisionNumber - 1); + obj.Add("serverTime", DateTime.UtcNow.ToStringISO()); + obj.Add("responseVersion", 1); + obj.Add("command", "GrantXP"); + + await accountService.UpdateAccountAsync(acc); + + return Json(obj); + } + + [HttpPost("{id}/{clientKind}/SetStars")] + public async Task SetStars(string id, string clientKind, [FromQuery] string profileId, [FromQuery] string rvn, [FromBody] SetStarsRequest body) + { + if (User.Identity is not EpicUserIdentity user) + return Unauthorized(); + + if (user.Session.AccountID != EpicID.FromString(id)) + return Unauthorized(); + + // only known to be sent by client so far + bool isRequestSentFromClient = clientKind.ToLower() == "client"; + bool isRequestSentFromServer = clientKind.ToLower() == "dedicated_server"; + + // this endpoint is kind of pointless. the actual stars are stored in cloudstorage progression file. + // then whenever it is changed, it sends an update to master server. + + var account = await accountService.GetAccountAsync(user.Session.AccountID); + if (account == null) + return BadRequest(); + + account.GoldStars = body.NewGoldStars; + account.BlueStars = body.NewBlueStars; + + await accountService.UpdateAccountAsync(account); + + // TODO: send out a proper response which is similar to QueryProfile + + return Ok(); + } +} diff --git a/UT4MasterServer/Controllers/UT/RatingsController.cs b/UT4MasterServer/Controllers/UT/RatingsController.cs new file mode 100644 index 00000000..0718c7ad --- /dev/null +++ b/UT4MasterServer/Controllers/UT/RatingsController.cs @@ -0,0 +1,88 @@ +using Microsoft.AspNetCore.Mvc; +using Newtonsoft.Json.Linq; +using UT4MasterServer.Authentication; +using UT4MasterServer.Controllers.Epic; +using UT4MasterServer.Models.DTO.Responses; +using UT4MasterServer.Services.Scoped; + +namespace UT4MasterServer.Controllers.UT; + +/// +/// ut-public-service-prod10.ol.epicgames.com +/// +[ApiController] +[Route("ut/api/game/v2/ratings")] +[AuthorizeBearer] +[Produces("application/json")] +public sealed class RatingController : JsonAPIController +{ + private readonly AccountService accountService; + + public RatingController(ILogger logger, AccountService accountService) : base(logger) + { + this.accountService = accountService; + } + + [HttpPost("account/{id}/mmrbulk")] + public IActionResult MmrBulk(string id, [FromBody] MMRBulkResponse ratings) + { + if (User.Identity is not EpicUserIdentity user) + return Unauthorized(); + + for (int i = 0; i < ratings.RatingTypes.Count; i++) + { + ratings.Ratings.Add(1500); + ratings.NumGamesPlayed.Add(0); + } + + return Ok(ratings); + } + + [HttpGet("account/{id}/mmr/{ratingType}")] + public IActionResult Mmr(string id, string ratingType) + { + if (User.Identity is not EpicUserIdentity user) + return Unauthorized(); + + // TODO: return only one type of rating + + // proper response: {"rating":1844,"numGamesPlayed":182} + JObject obj = new JObject() + { + { "rating", 1500 }, + { "numGamesPlayed", 0 } + }; + + return Json(obj); + } + + [HttpGet("account/{id}/league/{leagueName}")] + public IActionResult LeagueRating(string id, string leagueName) + { + if (User.Identity is not EpicUserIdentity user) + return Unauthorized(); + + var league = new LeagueResponse(); + // TODO: for now we just send default/empty values + return Ok(league); + } + + [HttpPost("team/elo/{ratingType}")] + public IActionResult JoinQuickplay(string ratingType, [FromBody] RatingTeam body) + { + if (User.Identity is not EpicUserIdentity user) + return Unauthorized(); + + // TODO: calculate proper rating for this team + + return Ok(new RatingResponse() { RatingValue = 1500 }); + } + + [HttpPost("team/match_result")] + public IActionResult MatchResult([FromBody] RatingMatch body) + { + // TODO: update ELO rating + + return NoContent(); // Response: correct response + } +} diff --git a/UT4MasterServer/Controllers/UT/StatsController.cs b/UT4MasterServer/Controllers/UT/StatsController.cs new file mode 100644 index 00000000..eecbaa2f --- /dev/null +++ b/UT4MasterServer/Controllers/UT/StatsController.cs @@ -0,0 +1,111 @@ +using Microsoft.AspNetCore.Mvc; +using UT4MasterServer.Authentication; +using UT4MasterServer.Common.Enums; +using UT4MasterServer.Models.Database; +using UT4MasterServer.Common; +using UT4MasterServer.Services.Scoped; + +namespace UT4MasterServer.Controllers.UT; + +/// +/// ut-public-service-prod10.ol.epicgames.com +/// +[ApiController] +[Route("ut/api/stats")] +[AuthorizeBearer] +[Produces("application/json")] +public sealed class StatsController : JsonAPIController +{ + private readonly StatisticsService statisticsService; + private readonly MatchmakingService matchmakingService; + private readonly TrustedGameServerService trustedGameServerService; + + public StatsController( + ILogger logger, + StatisticsService statisticsService, + MatchmakingService matchmakingService, + TrustedGameServerService trustedGameServerService) : base(logger) + { + this.statisticsService = statisticsService; + this.matchmakingService = matchmakingService; + this.trustedGameServerService = trustedGameServerService; + } + + // Examples: + // /ut/api/stats/accountId/0b0f09b400854b9b98932dd9e5abe7c5/bulk/window/daily + [HttpGet("accountId/{id}/bulk/window/daily")] + public async Task GetDailyAccountStatistics(string id) + { + var accountId = EpicID.FromString(id); + var result = await statisticsService.GetAggregateAccountStatisticsAsync(accountId, StatisticWindow.Daily); + return Ok(result); + } + + // Examples: + // /ut/api/stats/accountId/0b0f09b400854b9b98932dd9e5abe7c5/bulk/window/weekly + [HttpGet("accountId/{id}/bulk/window/weekly")] + public async Task GetWeeklyAccountStatistics(string id) + { + var accountId = EpicID.FromString(id); + var result = await statisticsService.GetAggregateAccountStatisticsAsync(accountId, StatisticWindow.Weekly); + return Ok(result); + } + + // Examples: + // /ut/api/stats/accountId/0b0f09b400854b9b98932dd9e5abe7c5/bulk/window/monthly + [HttpGet("accountId/{id}/bulk/window/monthly")] + public async Task GetMonthlyAccountStatistics(string id) + { + var accountId = EpicID.FromString(id); + var result = await statisticsService.GetAggregateAccountStatisticsAsync(accountId, StatisticWindow.Monthly); + return Ok(result); + } + + // Examples: + // /ut/api/stats/accountId/0b0f09b400854b9b98932dd9e5abe7c5/bulk/window/alltime + [HttpGet("accountId/{id}/bulk/window/alltime")] + public async Task GetAllTimeAccountStatistics(string id) + { + var accountId = EpicID.FromString(id); + var result = await statisticsService.GetAllTimeAccountStatisticsAsync(accountId); + return Ok(result); + } + + // Example: + // /ut/api/stats/accountId/0b0f09b400854b9b98932dd9e5abe7c5/bulk?ownertype=1 + [HttpPost("accountId/{id}/bulk")] + public async Task CreateAccountStatistics( + string id, + [FromQuery] OwnerType ownerType, + [FromBody] StatisticBase statisticBase) + { + var accountId = EpicID.FromString(id); + + if (User.Identity is not EpicUserIdentity user) + return Unauthorized(); + + if (user.Session.AccountID != accountId) + { + bool isMultiplayerMatch = await matchmakingService.DoesClientOwnGameServerWithPlayerAsync(user.Session.ClientID, accountId); + + // NOTE: In debug we allow anyone to post stats for easier testing. + // Normally only trusted servers are allowed to post stats + +#if !DEBUG + if (!isMultiplayerMatch) + return Unauthorized(); +#endif + + var trusted = await trustedGameServerService.GetAsync(user.Session.ClientID); + bool isTrustedMatch = (int)(trusted?.TrustLevel ?? GameServerTrust.Untrusted) < 2; + +#if !DEBUG + if (!isTrustedMatch) + return Unauthorized(); +#endif + } + + await statisticsService.CreateAccountStatisticsAsync(accountId, ownerType, statisticBase); + return Ok(); + } +} diff --git a/UT4MasterServer/Controllers/UT/WaitTimesController.cs b/UT4MasterServer/Controllers/UT/WaitTimesController.cs new file mode 100644 index 00000000..3f15329e --- /dev/null +++ b/UT4MasterServer/Controllers/UT/WaitTimesController.cs @@ -0,0 +1,44 @@ +using Microsoft.AspNetCore.Mvc; +using UT4MasterServer.Authentication; +using UT4MasterServer.Services.Singleton; + +namespace UT4MasterServer.Controllers.UT; + +/// +/// ut-public-service-prod10.ol.epicgames.com +/// +[ApiController] +[Route("ut/api/game/v2/wait_times")] +[AuthorizeBearer] +[Produces("application/json")] +public sealed class WaitTimesController : JsonAPIController +{ + private readonly MatchmakingWaitTimeEstimateService service; + + public WaitTimesController( + ILogger logger, + MatchmakingWaitTimeEstimateService service) : base(logger) + { + this.service = service; + } + + [HttpGet("estimate")] + public IActionResult QuickplayWaitEstimate() + { + if (User.Identity is not EpicUserIdentity user) + return Unauthorized(); + + return Ok(service.GetWaitTimes()); + } + + [HttpGet("report/{ratingType}/{timeWaited}")] + public IActionResult QuickplayWaitReport(string ratingType, double timeWaited) + { + if (User.Identity is not EpicUserIdentity user) + return Unauthorized(); + + service.AddWaitTime(ratingType, timeWaited); + + return NoContent(); + } +} diff --git a/UT4MasterServer/Controllers/UnrealTournamentProfileController.cs b/UT4MasterServer/Controllers/UnrealTournamentProfileController.cs deleted file mode 100644 index 17ef4910..00000000 --- a/UT4MasterServer/Controllers/UnrealTournamentProfileController.cs +++ /dev/null @@ -1,403 +0,0 @@ -using Microsoft.AspNetCore.Mvc; -using Newtonsoft.Json.Linq; -using UT4MasterServer.Authentication; -using UT4MasterServer.Models.Requests; -using UT4MasterServer.Other; -using UT4MasterServer.Services; - -namespace UT4MasterServer.Controllers; - -/// -/// ut-public-service-prod10.ol.epicgames.com -/// -[ApiController] -[Route("ut/api/game/v2/profile")] -[AuthorizeBearer] -[Produces("application/json")] -public sealed class UnrealTournamentProfileController : JsonAPIController -{ - private static readonly List<(string item, int requiredLevel)> profileItems; - - static UnrealTournamentProfileController() - { - profileItems = new() - { - ("BeanieBlack", 2), - ("Sunglasses", 3), - ("HockeyMask", 4), - ("ThundercrashMale05", 5), - ("NecrisMale01", 7), - ("ThundercrashMale03", 8), - ("NecrisHelm01", 10), - ("ThundercrashBeanieGreen", 12), - ("HockeyMask02", 14), - ("ThundercrashMale02", 17), - ("NecrisFemale02", 20), - ("BeanieWhite", 23), - ("NecrisHelm02", 26), - ("SkaarjMale01", 30), - ("BeanieGrey", 34), - ("ThundercrashBeanieRed", 39), - ("SkaarjMale02", 40), - ("ThundercrashBeret", 45), - ("NecrisMale04", 50), - - // TODO: figure out how profile items below should be unlocked - //("ThundercrashSunglasses", 0), - //("ThundercrashMale01", 0), - //("ThundercrashBeanieWhite", 0), - //("PhayderStealthHelm", 0), - //("NanoblackHelmGreen", 0), - //("NanoblackHelmBlack", 0), - //("Infiltrator", 0), - //("EnergyHelm", 0), - //("EliteAssassinHelm", 0), - }; - } - - private readonly AccountService accountService; - private readonly MatchmakingService matchmakingService; - - public UnrealTournamentProfileController(ILogger logger, AccountService accountService, MatchmakingService matchmakingService) : base(logger) - { - this.accountService = accountService; - this.matchmakingService = matchmakingService; - } - - [HttpPost("{id}/{clientKind}/QueryProfile")] - public async Task QueryProfile(string id, - string clientKind, - [FromQuery] string profileId, - [FromQuery] int rvn) - { - if (User.Identity is not EpicUserIdentity user) - return Unauthorized(); - - bool isRequestSentFromClient = clientKind.ToLower() == "client"; - bool isRequestSentFromServer = clientKind.ToLower() == "dedicated_server"; - - // TODO: I think "rvn" is revision number and it represents index of profile change entry. - // negative values probably mean index from back-to-front in array. - - var body = await Request.BodyReader.ReadAsStringAsync(1024); - var jsonBody = JObject.Parse(body); - - if (rvn == -1) - rvn = 1; - - // game sends empty json object as body - if (!(isRequestSentFromClient || isRequestSentFromServer) | profileId != "profile0" || !JToken.DeepEquals(jsonBody, JObject.Parse("{}"))) - { - logger.LogWarning($"QueryProfile received unexpected data! k:\"{clientKind}\" p:\"{profileId}\" rvn:\"{rvn}\" body:\"{body}\""); - } - - var account = await accountService.GetAccountAsync(EpicID.FromString(id)); - if (account == null) - return NotFound(); - - // actual response example is in /OldReferenceCode/Server.cs line 750 - - int revisionNumber = rvn + 1; - int commandRevision = rvn - 1; - - JObject obj = new(); - obj.Add("profileRevision", revisionNumber); - obj.Add("profileId", profileId); - obj.Add("profileChangesBaseRevision", revisionNumber); - JArray profileChanges = new JArray(); - // foreach { - JObject profileChange = new(); - profileChange.Add("changeType", "fullProfileUpdate"); - JObject profile = new(); - { - profile.Add("_id", account.ID.ToString()); - profile.Add("created", account.CreatedAt.ToStringISO()); - profile.Add("updated", (account.LastLoginAt - TimeSpan.FromSeconds(10)).ToStringISO()); // we don't store this info, send an arbitrary one - profile.Add("rvn", revisionNumber); - profile.Add("wipeNumber", 0); - profile.Add("accountId", account.ID.ToString()); - profile.Add("commandRevision", commandRevision); - profile.Add("profileId", profileId); - profile.Add("version", "ut_base"); - } - JObject items = new(); - foreach (var profileItem in profileItems) - { -#if (!DEBUG) || (DEBUG && true) - if (account.Level < profileItem.requiredLevel) - continue; -#endif - - // guid probably represents the id of profile item - // we don't really store obtained items, so we generate new id - // each time - string profileItemGuid = Guid.NewGuid().ToString(); - items.Add(profileItemGuid, new JObject() - { - { "templateId", "Item." + profileItem.item }, - { "attributes", new JObject() - { - { "tradable", false } - } - }, - { "quantity", 1 } - }); - } - profile.Add("items", items); - JObject stats = new(); - { - stats.Add("templateId", "profile_v2"); - JObject attributes = new(); - attributes.Add("CountryFlag", account.CountryFlag); - attributes.Add("GoldStars", account.GoldStars); - JObject loginRewards = new(); - loginRewards.Add("nextClaimTime", null); - loginRewards.Add("level", 0); - loginRewards.Add("totalDays", 0); - attributes.Add("login_rewards", loginRewards); - attributes.Add("Avatar", account.Avatar); - attributes.Add("inventory_limit_bonus", 0); - attributes.Add("daily_purchases", new JObject()); - attributes.Add("in_app_purchases", new JObject()); - attributes.Add("LastXPTime", (account.LastLoginAt - TimeSpan.FromSeconds(10)).ToUnixTimestamp()); // we don't store this info, send an arbitrary one - attributes.Add("XP", account.XP); - attributes.Add("Level", account.LevelStockLimited); // TODO: try values over 50 - attributes.Add("BlueStars", account.BlueStars); - attributes.Add("RecentXP", 0);//account.XPLastMatch); // probably xp from last finished match - attributes.Add("boosts", new JArray()); - attributes.Add("new_items", new JObject()); - stats.Add("attributes", attributes); - } - profile.Add("stats", stats); - profileChange.Add("profile", profile); - // } - profileChanges.Add(profileChange); - obj.Add("profileChanges", profileChanges); - obj.Add("profileCommandRevision", commandRevision); - obj.Add("serverTime", DateTime.UtcNow.ToStringISO()); - obj.Add("responseVersion", 1); - obj.Add("command", "QueryProfile"); - - return Json(obj); - } - - [HttpPost("{id}/{clientKind}/SetAvatarAndFlag")] - public async Task SetAvatarAndFlag(string id, string clientKind, [FromQuery] string profileId, [FromQuery] int rvn) - { - if (User.Identity is not EpicUserIdentity user) - return Unauthorized(); - - if (user.Session.AccountID != EpicID.FromString(id)) - return Unauthorized(); - - // TODO: Permission: "Sorry your login does not posses the permissions 'ut:profile:{id_from_params}:commands ALL' needed to perform the requested operation" - - if (rvn == -1) - rvn = 1; - - int revisionNumber = rvn; - - JObject obj = JObject.Parse(await Request.BodyReader.ReadAsStringAsync(1024)); - string? avatar = obj["newAvatar"]?.ToObject(); - string? flag = obj["newFlag"]?.ToObject(); - - obj = new JObject(); - obj.Add("profileRevision", revisionNumber); - obj.Add("profileId", "profile0"); - obj.Add("profileChangesBaseRevision", revisionNumber - 1); - JArray profileChanges = new JArray(); - if (avatar != null || flag != null) - { - var acc = await accountService.GetAccountAsync(user.Session.AccountID); - if (acc == null) - { - logger.LogError("Account is null"); - return StatusCode(StatusCodes.Status500InternalServerError); // should never happen - } - - if (avatar != null) - { - acc.Avatar = avatar; - JObject profileChange = new() - { - { "changeType", "statModified" }, - { "name", "Avatar" }, - { "value", acc.Avatar } - }; - profileChanges.Add(profileChange); - } - - if (flag != null) - { - acc.CountryFlag = flag; - JObject profileChange = new() - { - { "changeType", "statModified" }, - { "name", "CountryFlag" }, - { "value", acc.CountryFlag } - }; - profileChanges.Add(profileChange); - } - - await accountService.UpdateAccountAsync(acc); - } - obj.Add("profileChanges", profileChanges); - obj.Add("profileCommandRevision", revisionNumber - 1); - obj.Add("serverTime", DateTime.UtcNow.ToStringISO()); - obj.Add("responseVersion", 1); - obj.Add("command", "SetAvatarAndFlag"); - - return Json(obj); - } - - [HttpPost("{id}/{clientKind}/GrantXP")] - public async Task GrantXP(string id, string clientKind, [FromQuery] string profileId, [FromQuery] int rvn, [FromBody] GrantXP body) - { - if (User.Identity is not EpicUserIdentity user) - return Unauthorized(); - - var eid = EpicID.FromString(id); - - // only known to be sent by dedicated_server so far - bool isRequestSentFromClient = clientKind.ToLower() == "client"; - bool isRequestSentFromServer = clientKind.ToLower() == "dedicated_server"; - - if (isRequestSentFromServer && user.Session.AccountID.IsEmpty) - { - // it is "okay" to let any server handle anyone's XP since we have a limit on how much xp/h one can earn - } - else if (isRequestSentFromClient && user.Session.AccountID == eid) - { - // it is "okay" to let client modify his own XP since we have a limit on how much xp/h one can earn - } - else - { - return Unauthorized(); - } - - var acc = await accountService.GetAccountAsync(eid); - if (acc == null) - return NotFound(); - - - const double maxXPPerHour = 500.0; - var hoursSinceLastMatch = (DateTime.UtcNow - acc.LastMatchAt).TotalHours; - - // this is just some hard limit on max xp allowed per request/match - if (body.XPAmount > 300) - { - logger.LogWarning("{User} supposedly earned {XP} XP in a single match.", acc.ToString(), body.XPAmount); - body.XPAmount = 300; - } - - var maxEarnableXP = maxXPPerHour * hoursSinceLastMatch; - if (body.XPAmount > maxEarnableXP) - { - logger.LogWarning("{User} supposedly earned {XP} XP in {Hours} hours. Limiting to {AdjustedXP} XP which is max allowed XP within this timespan.", - acc.ToString(), body.XPAmount, hoursSinceLastMatch, maxEarnableXP); - body.XPAmount = (int)maxEarnableXP; - } - - - - var prevXP = acc.XP; - var prevLevel = acc.LevelStockLimited; - - if (rvn == -1) - rvn = 1; - var revisionNumber = rvn; - - var obj = new JObject(); - obj.Add("profileRevision", revisionNumber); - obj.Add("profileId", "profile0"); - obj.Add("profileChangesBaseRevision", revisionNumber - 1); - JArray profileChanges = new JArray(); - { - acc.LastMatchAt = DateTime.UtcNow; - profileChanges.Add(new JObject() - { - { "changeType", "statModified" }, - { "name", "LastXPTime" }, - { "value", acc.LastMatchAt.ToUnixTimestamp() } - }); - - //acc.XPLastMatch = body.XPAmount; - profileChanges.Add(new JObject() - { - { "changeType", "statModified" }, - { "name", "RecentXP" }, - { "value", 0 } //acc.XPLastMatch } - }); - - acc.XP += body.XPAmount; - profileChanges.Add(new JObject() - { - { "changeType", "statModified" }, - { "name", "XP" }, - { "value", acc.XP } - }); - - profileChanges.Add(new JObject() - { - { "changeType", "statModified" }, - { "name", "Level" }, - { "value", acc.LevelStockLimited } - }); - } - obj.Add("profileChanges", profileChanges); - - obj.Add("notifications", new JArray() - { - { - new JObject() - { - { "type", "XPProgress" }, - { "primary", false }, - { "prevXP", prevXP }, - { "XP", acc.XP }, - { "prevLevel", prevLevel }, - { "level", acc.LevelStockLimited } - } - } - }); - - obj.Add("profileCommandRevision", revisionNumber - 1); - obj.Add("serverTime", DateTime.UtcNow.ToStringISO()); - obj.Add("responseVersion", 1); - obj.Add("command", "GrantXP"); - - await accountService.UpdateAccountAsync(acc); - - return Json(obj); - } - - [HttpPost("{id}/{clientKind}/SetStars")] - public async Task SetStars(string id, string clientKind, [FromQuery] string profileId, [FromQuery] string rvn, [FromBody] SetStars body) - { - if (User.Identity is not EpicUserIdentity user) - return Unauthorized(); - - if (user.Session.AccountID != EpicID.FromString(id)) - return Unauthorized(); - - // only known to be sent by client so far - bool isRequestSentFromClient = clientKind.ToLower() == "client"; - bool isRequestSentFromServer = clientKind.ToLower() == "dedicated_server"; - - // this endpoint is kind of pointless. the actual stars are stored in cloudstorage progression file. - // then whenever it is changed, it sends an update to master server. - - var account = await accountService.GetAccountAsync(user.Session.AccountID); - if (account == null) - return BadRequest(); - - account.GoldStars = body.NewGoldStars; - account.BlueStars = body.NewBlueStars; - - await accountService.UpdateAccountAsync(account); - - // TODO: send out a proper response which is similar to QueryProfile - - return Ok(); - } -} diff --git a/UT4MasterServer/Controllers/UnrealTournamentRatingsController.cs b/UT4MasterServer/Controllers/UnrealTournamentRatingsController.cs deleted file mode 100644 index 17eb1486..00000000 --- a/UT4MasterServer/Controllers/UnrealTournamentRatingsController.cs +++ /dev/null @@ -1,89 +0,0 @@ -using Microsoft.AspNetCore.Mvc; -using Newtonsoft.Json.Linq; -using UT4MasterServer.Authentication; -using UT4MasterServer.Models; -using UT4MasterServer.Models.Requests; -using UT4MasterServer.Other; -using UT4MasterServer.Services; - -namespace UT4MasterServer.Controllers; - -/// -/// ut-public-service-prod10.ol.epicgames.com -/// -[ApiController] -[Route("ut/api/game/v2/ratings")] -[AuthorizeBearer] -[Produces("application/json")] -public sealed class UnrealTournamentRatingController : JsonAPIController -{ - private readonly AccountService accountService; - - public UnrealTournamentRatingController(ILogger logger, AccountService accountService) : base(logger) - { - this.accountService = accountService; - } - - [HttpPost("account/{id}/mmrbulk")] - public IActionResult MmrBulk(string id, [FromBody] MMRBulk ratings) - { - if (User.Identity is not EpicUserIdentity user) - return Unauthorized(); - - for (int i = 0; i < ratings.RatingTypes.Count; i++) - { - ratings.Ratings.Add(1500); - ratings.NumGamesPlayed.Add(0); - } - - return Json(ratings); - } - - [HttpGet("account/{id}/mmr/{ratingType}")] - public IActionResult Mmr(string id, string ratingType) - { - if (User.Identity is not EpicUserIdentity user) - return Unauthorized(); - - // TODO: return only one type of rating - - // proper response: {"rating":1844,"numGamesPlayed":182} - JObject obj = new JObject() - { - { "rating", 1500 }, - { "numGamesPlayed", 0 } - }; - - return Json(obj); - } - - [HttpGet("account/{id}/league/{leagueName}")] - public IActionResult LeagueRating(string id, string leagueName) - { - if (User.Identity is not EpicUserIdentity user) - return Unauthorized(); - - var league = new League(); - // TODO: for now we just send default/empty values - return Ok(league); - } - - [HttpPost("team/elo/{ratingType}")] - public IActionResult JoinQuickplay(string ratingType, [FromBody] RatingTeam body) - { - if (User.Identity is not EpicUserIdentity user) - return Unauthorized(); - - // TODO: calculate proper rating for this team - - return Ok(new RatingResponse() { Rating = 1500 }); - } - - [HttpPost("team/match_result")] - public IActionResult MatchResult([FromBody] RatingMatch body) - { - // TODO: update ELO rating - - return NoContent(); // Response: correct response - } -} diff --git a/UT4MasterServer/Controllers/UnrealTournamentStatsController.cs b/UT4MasterServer/Controllers/UnrealTournamentStatsController.cs deleted file mode 100644 index ad6ac69a..00000000 --- a/UT4MasterServer/Controllers/UnrealTournamentStatsController.cs +++ /dev/null @@ -1,111 +0,0 @@ -using Microsoft.AspNetCore.Mvc; -using UT4MasterServer.Authentication; -using UT4MasterServer.Enums; -using UT4MasterServer.Models; -using UT4MasterServer.Other; -using UT4MasterServer.Services; - -namespace UT4MasterServer.Controllers; - -/// -/// ut-public-service-prod10.ol.epicgames.com -/// -[ApiController] -[Route("ut/api/stats")] -[AuthorizeBearer] -[Produces("application/json")] -public sealed class UnrealTournamentStatsController : JsonAPIController -{ - private readonly StatisticsService statisticsService; - private readonly MatchmakingService matchmakingService; - private readonly TrustedGameServerService trustedGameServerService; - - public UnrealTournamentStatsController( - ILogger logger, - StatisticsService statisticsService, - MatchmakingService matchmakingService, - TrustedGameServerService trustedGameServerService) : base(logger) - { - this.statisticsService = statisticsService; - this.matchmakingService = matchmakingService; - this.trustedGameServerService = trustedGameServerService; - } - - // Examples: - // /ut/api/stats/accountId/0b0f09b400854b9b98932dd9e5abe7c5/bulk/window/daily - [HttpGet("accountId/{id}/bulk/window/daily")] - public async Task GetDailyAccountStatistics(string id) - { - var accountId = EpicID.FromString(id); - var result = await statisticsService.GetAggregateAccountStatisticsAsync(accountId, StatisticWindow.Daily); - return Ok(result); - } - - // Examples: - // /ut/api/stats/accountId/0b0f09b400854b9b98932dd9e5abe7c5/bulk/window/weekly - [HttpGet("accountId/{id}/bulk/window/weekly")] - public async Task GetWeeklyAccountStatistics(string id) - { - var accountId = EpicID.FromString(id); - var result = await statisticsService.GetAggregateAccountStatisticsAsync(accountId, StatisticWindow.Weekly); - return Ok(result); - } - - // Examples: - // /ut/api/stats/accountId/0b0f09b400854b9b98932dd9e5abe7c5/bulk/window/monthly - [HttpGet("accountId/{id}/bulk/window/monthly")] - public async Task GetMonthlyAccountStatistics(string id) - { - var accountId = EpicID.FromString(id); - var result = await statisticsService.GetAggregateAccountStatisticsAsync(accountId, StatisticWindow.Monthly); - return Ok(result); - } - - // Examples: - // /ut/api/stats/accountId/0b0f09b400854b9b98932dd9e5abe7c5/bulk/window/alltime - [HttpGet("accountId/{id}/bulk/window/alltime")] - public async Task GetAllTimeAccountStatistics(string id) - { - var accountId = EpicID.FromString(id); - var result = await statisticsService.GetAllTimeAccountStatisticsAsync(accountId); - return Ok(result); - } - - // Example: - // /ut/api/stats/accountId/0b0f09b400854b9b98932dd9e5abe7c5/bulk?ownertype=1 - [HttpPost("accountId/{id}/bulk")] - public async Task CreateAccountStatistics( - string id, - [FromQuery] OwnerType ownerType, - [FromBody] StatisticBase statisticBase) - { - var accountId = EpicID.FromString(id); - - if (User.Identity is not EpicUserIdentity user) - return Unauthorized(); - - if (user.Session.AccountID != accountId) - { - bool isMultiplayerMatch = await matchmakingService.DoesClientOwnGameServerWithPlayerAsync(user.Session.ClientID, accountId); - - // NOTE: In debug we allow anyone to post stats for easier testing. - // Normally only trusted servers are allowed to post stats - -#if !DEBUG - if (!isMultiplayerMatch) - return Unauthorized(); -#endif - - var trusted = await trustedGameServerService.GetAsync(user.Session.ClientID); - bool isTrustedMatch = (int)(trusted?.TrustLevel ?? GameServerTrust.Untrusted) < 2; - -#if !DEBUG - if (!isTrustedMatch) - return Unauthorized(); -#endif - } - - await statisticsService.CreateAccountStatisticsAsync(accountId, ownerType, statisticBase); - return Ok(); - } -} diff --git a/UT4MasterServer/Controllers/UnrealTournamentWaitTimesController.cs b/UT4MasterServer/Controllers/UnrealTournamentWaitTimesController.cs deleted file mode 100644 index 4ad67097..00000000 --- a/UT4MasterServer/Controllers/UnrealTournamentWaitTimesController.cs +++ /dev/null @@ -1,47 +0,0 @@ -using Microsoft.AspNetCore.Mvc; -using Newtonsoft.Json.Linq; -using UT4MasterServer.Authentication; -using UT4MasterServer.Models; -using UT4MasterServer.Other; -using UT4MasterServer.Services; - -namespace UT4MasterServer.Controllers; - -/// -/// ut-public-service-prod10.ol.epicgames.com -/// -[ApiController] -[Route("ut/api/game/v2/wait_times")] -[AuthorizeBearer] -[Produces("application/json")] -public sealed class UnrealTournamentWaitTimesController : JsonAPIController -{ - private readonly MatchmakingWaitTimeEstimateService service; - - public UnrealTournamentWaitTimesController( - ILogger logger, - MatchmakingWaitTimeEstimateService service) : base(logger) - { - this.service = service; - } - - [HttpGet("estimate")] - public IActionResult QuickplayWaitEstimate() - { - if (User.Identity is not EpicUserIdentity user) - return Unauthorized(); - - return Ok(service.GetWaitTimes()); - } - - [HttpGet("report/{ratingType}/{timeWaited}")] - public IActionResult QuickplayWaitReport(string ratingType, double timeWaited) - { - if (User.Identity is not EpicUserIdentity user) - return Unauthorized(); - - service.AddWaitTime(ratingType, timeWaited); - - return NoContent(); - } -} diff --git a/UT4MasterServer/Enums/OwnerType.cs b/UT4MasterServer/Enums/OwnerType.cs deleted file mode 100644 index 045fc2a4..00000000 --- a/UT4MasterServer/Enums/OwnerType.cs +++ /dev/null @@ -1,6 +0,0 @@ -namespace UT4MasterServer.Enums; - -public enum OwnerType -{ - Default = 1, -} diff --git a/UT4MasterServer/Formatters/StatisticBaseInputFormatter.cs b/UT4MasterServer/Formatters/StatisticBaseInputFormatter.cs index 1057db98..d19c2552 100644 --- a/UT4MasterServer/Formatters/StatisticBaseInputFormatter.cs +++ b/UT4MasterServer/Formatters/StatisticBaseInputFormatter.cs @@ -1,6 +1,6 @@ using Microsoft.AspNetCore.Mvc.Formatters; using System.Text.Json; -using UT4MasterServer.Models; +using UT4MasterServer.Models.Database; namespace UT4MasterServer.Formatters; diff --git a/UT4MasterServer/Models/Requests/AuthenticateRequest.cs b/UT4MasterServer/Models/Requests/AuthenticateRequest.cs deleted file mode 100644 index f4810741..00000000 --- a/UT4MasterServer/Models/Requests/AuthenticateRequest.cs +++ /dev/null @@ -1,22 +0,0 @@ -using Microsoft.AspNetCore.Mvc; - -namespace UT4MasterServer.Models.Requests -{ - public class AuthenticateRequest - { - [FromForm(Name = "grant_type")] - public string? GrantType { get; set; } - [FromForm(Name = "includePerms")] - public bool? IncludePerms { get; set; } - [FromForm(Name = "code")] - public string? Code { get; set; } - [FromForm(Name = "exchange_code")] - public string? ExchangeCode { get; set; } - [FromForm(Name = "refresh_token")] - public string? RefreshToken { get; set; } - [FromForm(Name = "username")] - public string? Username { get; set; } - [FromForm(Name = "password")] - public string? Password { get; set; } - } -} diff --git a/UT4MasterServer/Models/Responses/TrustedGameServerResponse.cs b/UT4MasterServer/Models/Responses/TrustedGameServerResponse.cs deleted file mode 100644 index 211dee66..00000000 --- a/UT4MasterServer/Models/Responses/TrustedGameServerResponse.cs +++ /dev/null @@ -1,7 +0,0 @@ -namespace UT4MasterServer.Models.Responses; - -public sealed class TrustedGameServerResponse: TrustedGameServer -{ - public Client? Client { get; set; } = null; - public Account? Owner { get; set; } = null; -} diff --git a/UT4MasterServer/Other/BsonSerializationProvider.cs b/UT4MasterServer/Other/BsonSerializationProvider.cs deleted file mode 100644 index b2442427..00000000 --- a/UT4MasterServer/Other/BsonSerializationProvider.cs +++ /dev/null @@ -1,19 +0,0 @@ -using MongoDB.Bson.Serialization; -using UT4MasterServer.Models; - -namespace UT4MasterServer.Other; - -public class BsonSerializationProvider : IBsonSerializationProvider -{ - public IBsonSerializer GetSerializer(Type type) - { - if (type == typeof(EpicID)) - return new EpicIDSerializer(); - if (type == typeof(GameServerAttributes)) - return new GameServerAttributesBsonSerializer(); - - // returning null here seems to be fine. - // it probably signals to the caller that we don't have serializer for specified type. - return null!; - } -} diff --git a/UT4MasterServer/Other/DateTimeISOJsonConverter.cs b/UT4MasterServer/Other/DateTimeISOJsonConverter.cs deleted file mode 100644 index 38a11def..00000000 --- a/UT4MasterServer/Other/DateTimeISOJsonConverter.cs +++ /dev/null @@ -1,29 +0,0 @@ -using System.Text.Json; -using System.Text.Json.Serialization; - -namespace UT4MasterServer.Other; - -public class DateTimeISOJsonConverter : JsonConverter -{ - public override DateTime Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) - { - if (typeToConvert != typeof(string) && typeToConvert != typeof(DateTime)) - return DateTime.MinValue; - - var str = reader.GetString(); - if (str == null) - return DateTime.MinValue; - - DateTime ret; - if (!DateTime.TryParse("yyyy-MM-dd'T'HH:mm:ss.fffK", out ret)) - return DateTime.MinValue; - - return ret; - } - - public override void Write(Utf8JsonWriter writer, DateTime value, JsonSerializerOptions options) - { - string val = value.ToStringISO(); - writer.WriteStringValue(val); - } -} diff --git a/UT4MasterServer/Other/EpicIDJsonConverter.cs b/UT4MasterServer/Other/EpicIDJsonConverter.cs deleted file mode 100644 index d47be269..00000000 --- a/UT4MasterServer/Other/EpicIDJsonConverter.cs +++ /dev/null @@ -1,54 +0,0 @@ -using System.Text.Json; -using System.Text.Json.Serialization; - -namespace UT4MasterServer.Other; - -public class EpicIDJsonConverter : JsonConverter -{ - //public override bool CanWrite { get => true; } - - //public override void WriteJson(JsonWriter writer, object? value, JsonSerializer serializer) - //{ - // if (value is EpicID eid) - // writer.WriteToken(JsonToken.String, eid.ToString()); - //} - - //public override bool CanConvert(Type objectType) - //{ - // return objectType == typeof(string) || objectType == typeof(EpicID); - //} - - //public override object ReadJson(JsonReader reader, Type objectType, object? existingValue, JsonSerializer serializer) - //{ - // if (reader.TokenType == JsonToken.String && objectType == typeof(EpicID)) - // { - // if (reader.Value == null) - // return EpicID.Empty; - - // var str = reader.Value as string; - // if (str == null) - // return EpicID.Empty; - - // return EpicID.FromString(str); - // } - - // return null!; - //} - - public override EpicID Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) - { - if (typeToConvert != typeof(string) && typeToConvert != typeof(EpicID)) - return EpicID.Empty; - - var str = reader.GetString(); - if (str == null) - return EpicID.Empty; - - return EpicID.FromString(str); - } - - public override void Write(Utf8JsonWriter writer, EpicID value, JsonSerializerOptions options) - { - writer.WriteStringValue(value.ToString()); - } -} diff --git a/UT4MasterServer/Other/EpicIDSerializer.cs b/UT4MasterServer/Other/EpicIDSerializer.cs deleted file mode 100644 index 59e85c0a..00000000 --- a/UT4MasterServer/Other/EpicIDSerializer.cs +++ /dev/null @@ -1,151 +0,0 @@ -using MongoDB.Bson; -using MongoDB.Bson.Serialization; -using MongoDB.Bson.Serialization.Serializers; - -namespace UT4MasterServer.Other; - -/// -/// Represents a serializer for Strings. -/// -public class EpicIDSerializer : StructSerializerBase, IRepresentationConfigurable -{ - #region static - private static readonly StringSerializer __instance = new StringSerializer(); - - // public static properties - /// - /// Gets a cached instance of a default string serializer. - /// - public static StringSerializer Instance => __instance; - #endregion - - // private fields - private readonly BsonType _representation; - - // constructors - /// - /// Initializes a new instance of the class. - /// - public EpicIDSerializer() : this(BsonType.String) - { - } - - /// - /// Initializes a new instance of the class. - /// - /// The representation. - public EpicIDSerializer(BsonType representation) - { - switch (representation) - { - case BsonType.ObjectId: - case BsonType.String: - case BsonType.Symbol: - break; - - default: - var message = string.Format("{0} is not a valid representation for a EpicIDSerializer.", representation); - throw new ArgumentException(message); - } - - _representation = representation; - } - - // public properties - /// - /// Gets the representation. - /// - /// - /// The representation. - /// - public BsonType Representation - { - get { return _representation; } - } - - // public methods - /// - /// Deserializes a value. - /// - /// The deserialization context. - /// The deserialization args. - /// A deserialized value. - public override EpicID Deserialize(BsonDeserializationContext context, BsonDeserializationArgs args) - { - var bsonReader = context.Reader; - - var bsonType = bsonReader.GetCurrentBsonType(); - switch (bsonType) - { - case BsonType.ObjectId: - if (_representation == BsonType.ObjectId) - { - return EpicID.FromString(bsonReader.ReadObjectId().ToString()); - } - - goto default; - - case BsonType.String: - return EpicID.FromString(bsonReader.ReadString()); - - case BsonType.Symbol: - return EpicID.FromString(bsonReader.ReadSymbol()); - - default: - throw CreateCannotDeserializeFromBsonTypeException(bsonType); - } - } - - /// - /// Serializes a value. - /// - /// The serialization context. - /// The serialization args. - /// The object. - public override void Serialize(BsonSerializationContext context, BsonSerializationArgs args, EpicID value) - { - var bsonWriter = context.Writer; - - switch (_representation) - { - case BsonType.ObjectId: - bsonWriter.WriteObjectId(ObjectId.Parse(value.ID)); - break; - - case BsonType.String: - bsonWriter.WriteString(value.ID); - break; - - case BsonType.Symbol: - bsonWriter.WriteSymbol(value.ID); - break; - - default: - var message = string.Format("'{0}' is not a valid String representation.", _representation); - throw new BsonSerializationException(message); - } - } - - /// - /// Returns a serializer that has been reconfigured with the specified representation. - /// - /// The representation. - /// The reconfigured serializer. - public EpicIDSerializer WithRepresentation(BsonType representation) - { - if (representation == _representation) - { - return this; - } - else - { - return new EpicIDSerializer(representation); - } - } - - // explicit interface implementations - IBsonSerializer IRepresentationConfigurable.WithRepresentation(BsonType representation) - { - return WithRepresentation(representation); - } -} diff --git a/UT4MasterServer/Other/GameServerAttributesBsonSerializer.cs b/UT4MasterServer/Other/GameServerAttributesBsonSerializer.cs deleted file mode 100644 index 93b57e33..00000000 --- a/UT4MasterServer/Other/GameServerAttributesBsonSerializer.cs +++ /dev/null @@ -1,69 +0,0 @@ -using MongoDB.Bson.IO; -using MongoDB.Bson.Serialization; -using MongoDB.Bson.Serialization.Serializers; -using System.Text.Json; -using System.Text.Json.Serialization; -using UT4MasterServer.Models; - -namespace UT4MasterServer.Other; - -public class GameServerAttributesBsonSerializer : SerializerBase -{ - public override void Serialize(BsonSerializationContext context, BsonSerializationArgs args, GameServerAttributes value) - { - context.Writer.WriteStartDocument(); - - foreach (var key in value.GetKeys()) - { - var val = value.Get(key); - - if (val is string valString) - context.Writer.WriteString(key, valString); - else if (val is int valInt) - context.Writer.WriteInt32(key, valInt); - else if (val is bool valBool) - context.Writer.WriteBoolean(key, valBool); - else - { - // Other kv-pairs are ignored because they are invalid - // TODO: Is throw more appropriate? - continue; - } - } - - context.Writer.WriteEndDocument(); - } - - public override GameServerAttributes Deserialize(BsonDeserializationContext context, BsonDeserializationArgs args) - { - var result = new GameServerAttributes(); - - if (context.Reader.CurrentBsonType != MongoDB.Bson.BsonType.Document) - throw new FormatException("Cannot deserialize into GameServerAttributes"); - - context.Reader.ReadStartDocument(); - - while (true) - { - var t = context.Reader.ReadBsonType(); - if (t == MongoDB.Bson.BsonType.EndOfDocument) - break; - - string key = context.Reader.ReadName(); - - if (t == MongoDB.Bson.BsonType.String) - result.Set(key, context.Reader.ReadString()); - else if (t == MongoDB.Bson.BsonType.Int32) - result.Set(key, context.Reader.ReadInt32()); - else if (t == MongoDB.Bson.BsonType.Boolean) - result.Set(key, context.Reader.ReadBoolean()); - else - throw new FormatException("Cannot deserialize into GameServerAttributes"); - } - - - context.Reader.ReadEndDocument(); - - return result; - } -} diff --git a/UT4MasterServer/Other/GameServerAttributesJsonConverter.cs b/UT4MasterServer/Other/GameServerAttributesJsonConverter.cs deleted file mode 100644 index ed6e0ae9..00000000 --- a/UT4MasterServer/Other/GameServerAttributesJsonConverter.cs +++ /dev/null @@ -1,73 +0,0 @@ -using System.Text.Json; -using System.Text.Json.Serialization; -using UT4MasterServer.Models; - -namespace UT4MasterServer.Other; - -public class GameServerAttributesJsonConverter : JsonConverter -{ - public override GameServerAttributes? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) - { - if (reader.TokenType != JsonTokenType.StartObject) - return null; - - GameServerAttributes attribs = new GameServerAttributes(); - - while (true) - { - reader.Read(); - - if (reader.TokenType == JsonTokenType.EndObject) - return attribs; - - if (reader.TokenType != JsonTokenType.PropertyName) - return null; - - var attributeName = reader.GetString(); - if (attributeName == null) - return null; - - if (!reader.Read()) - return null; - - if (reader.TokenType == JsonTokenType.String) - { - attribs.Set(attributeName, reader.GetString()); - } - else if (reader.TokenType == JsonTokenType.Number) - { - attribs.Set(attributeName, reader.GetInt32()); - } - else if (reader.TokenType == JsonTokenType.True || reader.TokenType == JsonTokenType.False) - { - attribs.Set(attributeName, reader.GetBoolean()); - } - else if (reader.TokenType == JsonTokenType.Null) - { - attribs.Set(attributeName, null as string); // need to specify some type - } - else - { - return null; - } - } - } - - public override void Write(Utf8JsonWriter writer, GameServerAttributes value, JsonSerializerOptions options) - { - writer.WriteStartObject(); - foreach (var key in value.GetKeys()) - { - var val = value.Get(key); - - writer.WritePropertyName(key); - if (val is string valString) - writer.WriteStringValue(valString); - else if (val is int valInt) - writer.WriteNumberValue(valInt); - else if (val is bool valBool) - writer.WriteBooleanValue(valBool); - } - writer.WriteEndObject(); - } -} diff --git a/UT4MasterServer/Other/PipeReaderExtensions.cs b/UT4MasterServer/Other/PipeReaderExtensions.cs deleted file mode 100644 index 312a4eda..00000000 --- a/UT4MasterServer/Other/PipeReaderExtensions.cs +++ /dev/null @@ -1,47 +0,0 @@ -using System.Buffers; -using System.IO.Pipelines; -using System.Text; - -namespace UT4MasterServer.Other; - -public static class PipeReaderExtensions -{ - public static async Task ReadAsStringAsync(this PipeReader pipe, int maxByteLength) - { - return Encoding.UTF8.GetString(await ReadAsBytesAsync(pipe, maxByteLength)); - } - - public static async Task ReadAsBytesAsync(this PipeReader pipe, int maxByteLength) - { - var rawArray = ArrayPool.Shared.Rent(maxByteLength); - var rawMemory = new Memory(rawArray); - int fillCount = 0; - - while (true) - { - ReadResult readResult = await pipe.ReadAsync(); - var buffer = readResult.Buffer; - bool isEOF = false; - - if (readResult.IsCompleted || buffer.Length >= maxByteLength) - { - fillCount = (int)buffer.Length; - buffer.CopyTo(rawMemory.Span.Slice(0, fillCount)); - isEOF = true; - } - - pipe.AdvanceTo(buffer.Start, buffer.End); - - if (isEOF) - { - break; - } - } - - byte[] ret = new byte[fillCount]; - Array.Copy(rawArray, ret, fillCount); - ArrayPool.Shared.Return(rawArray); - - return ret; - } -} diff --git a/UT4MasterServer/Program.cs b/UT4MasterServer/Program.cs index 3b690137..fcd42138 100644 --- a/UT4MasterServer/Program.cs +++ b/UT4MasterServer/Program.cs @@ -6,10 +6,15 @@ using UT4MasterServer.Authentication; using UT4MasterServer.Configuration; using UT4MasterServer.Formatters; -using UT4MasterServer.Models; -using UT4MasterServer.Other; +using UT4MasterServer.Models.Database; +using UT4MasterServer.Common; using UT4MasterServer.Services; -using UT4MasterServer.Settings; +using UT4MasterServer.Models.Settings; +using UT4MasterServer.Serializers.Bson; +using UT4MasterServer.Serializers.Json; +using UT4MasterServer.Services.Scoped; +using UT4MasterServer.Services.Singleton; +using UT4MasterServer.Services.Hosted; namespace UT4MasterServer; @@ -97,6 +102,7 @@ public static void Main(string[] args) // services whose instance is created once and are persistent builder.Services + .AddSingleton() .AddSingleton() .AddSingleton(); @@ -115,6 +121,8 @@ public static void Main(string[] args) builder.AddSerilog(); }); + builder.Services.AddControllers(); + builder.Services.AddEndpointsApiExplorer(); builder.Services.AddSwaggerGen(config => { diff --git a/UT4MasterServer/UT4MasterServer.csproj b/UT4MasterServer/UT4MasterServer.csproj index 350c1963..34c56545 100644 --- a/UT4MasterServer/UT4MasterServer.csproj +++ b/UT4MasterServer/UT4MasterServer.csproj @@ -23,4 +23,11 @@ + + + + + + + diff --git a/UT4MasterServer/docs/client-create-instance.md b/UT4MasterServer/docs/client-create-instance.md deleted file mode 100644 index ea885e36..00000000 --- a/UT4MasterServer/docs/client-create-instance.md +++ /dev/null @@ -1,119 +0,0 @@ -# Client Creating Instance Flow - -These are the calls specific to a client creating a hub instance. - -## Matchmaking Request - -``` -POST https://ut-public-service-prod10.ol.epicgames.com/ut/api/matchmaking/session/matchMakingRequest HTTP/1.1 -Host: ut-public-service-prod10.ol.epicgames.com -Accept: */* -Cookie: AWSELB=E9958FDB1448870EBFBE28377F3D10376F43CDC4B14B7412096B9B03EBCEE56C4051F02C0839B49802B60CF8413BEE9CF0EE52960FC45D37FCCE1689E8CE7AEE49558F7C5C; AWSELBCORS=E9958FDB1448870EBFBE28377F3D10376F43CDC4B14B7412096B9B03EBCEE56C4051F02C0839B49802B60CF8413BEE9CF0EE52960FC45D37FCCE1689E8CE7AEE49558F7C5C -Content-Type: application/json -User-Agent: game=UnrealTournament, engine=UE4, build=++UT+Release-Next-CL-3525360 -X-Epic-Correlation-ID: UE4-31957B9A4DCE2C7F894131BC869FFBC9 -Authorization: bearer 8999c1a8446549e38e621e2e6f7603ec -Content-Length: 280 -Pragma: no-cache - -{ - "criteria": [ - { - "type": "EQUAL", - "key": "UT_SERVERVERSION_s", - "value": "3525360" - }, - { - "type": "EQUAL", - "key": "UT_SERVERINSTANCEGUID_s", - "value": "7C5F20C74F6C0DA060BDB3B6A7868EC7" - } - ], - "buildUniqueId": "256652735", - "maxResults": 1 -} -``` - -The request criteria is static, except for the key for `UT_SERVERINSTANCEGUID_s`. - -### Response - -``` -[ - { - "id": "e67b11a43927473ba1e89ca1e01a7140", - "ownerId": "71B106B04C0E9AA05CD0D090D3996FB8", - "ownerName": "[DS]DESKTOP-IQJVTED-25164", - "serverName": "[DS]DESKTOP-IQJVTED-25164", - "serverAddress": "108.172.186.109", - "serverPort": 8000, - "maxPublicPlayers": 10000, - "openPublicPlayers": 10000, - "maxPrivatePlayers": 0, - "openPrivatePlayers": 0, - "attributes": { - "UT_REDTEAMSIZE_i": 0, - "BEACONPORT_i": 7788, - "UT_PLAYERONLINE_i": 0, - "UT_MATCHSTATE_s": "WaitingToStart", - "UT_SERVERINSTANCEGUID_s": "7C5F20C74F6C0DA060BDB3B6A7868EC7", - "UT_SPECTATORSONLINE_i": 0, - "UT_MAXPLAYERS_i": 10, - "UT_MATCHELO_i": 0, - "UT_SERVERNAME_s": "Capture the Flag on CTF-TitanPass ", - "GAMENAME_s": "Capture the Flag", - "UT_NUMMATCHES_i": 1, - "UT_GAMEINSTANCE_i": 1, - "UT_MAXSPECTATORS_i": 7, - "UT_SERVERVERSION_s": "3525360", - "GAMEMODE_s": "/Script/UnrealTournament.UTCTFGameMode", - "UT_HUBGUID_s": "516E6C8C4595684421A2A3AB7BA704F6", - "UT_BLUETEAMSIZE_i": 0, - "UT_SERVERTRUSTLEVEL_i": 2, - "UT_TRAININGGROUND_b": false, - "UT_MINELO_i": 0, - "UT_MAXELO_i": 0, - "UT_SERVERMOTD_s": "This is the ServerMOTD string from Game.ini ", - "MAPNAME_s": "CTF-TitanPass", - "UT_MATCHDURATION_i": 0, - "UT_SERVERFLAGS_i": 0 - }, - "publicPlayers": [], - "privatePlayers": [], - "totalPlayers": 0, - "allowJoinInProgress": true, - "shouldAdvertise": true, - "isDedicated": true, - "usesStats": false, - "allowInvites": true, - "usesPresence": false, - "allowJoinViaPresence": true, - "allowJoinViaPresenceFriendsOnly": false, - "buildUniqueId": "256652735", - "lastUpdated": "2022-12-21T04:51:46.633Z", - "started": true - } -] -``` - -This is the same format as is typical for a hub or instance launch. Note that the key for -`UT_SERVERINSTANCEGUID_s` is what was sent in the request body. The `id` field is what is -used for a client to make a request to join an instance. - -## Joining a Hub Instance - -``` -POST https://ut-public-service-prod10.ol.epicgames.com/ut/api/matchmaking/session/e67b11a43927473ba1e89ca1e01a7140/join?accountId=fd83abe496ca401497b5adf4e412bf2c HTTP/1.1 -Host: ut-public-service-prod10.ol.epicgames.com -Accept: */* -Cookie: AWSELB=E9958FDB1448870EBFBE28377F3D10376F43CDC4B14B7412096B9B03EBCEE56C4051F02C0839B49802B60CF8413BEE9CF0EE52960FC45D37FCCE1689E8CE7AEE49558F7C5C; AWSELBCORS=E9958FDB1448870EBFBE28377F3D10376F43CDC4B14B7412096B9B03EBCEE56C4051F02C0839B49802B60CF8413BEE9CF0EE52960FC45D37FCCE1689E8CE7AEE49558F7C5C -Content-Type: application/json -User-Agent: game=UnrealTournament, engine=UE4, build=++UT+Release-Next-CL-3525360 -X-Epic-Correlation-ID: UE4-D7FEC91146B93EDD251D66B733EC181C -Authorization: bearer 8999c1a8446549e38e621e2e6f7603ec -Content-Length: 0 -Pragma: no-cache -``` - -This is the same request as made to join the hub itself. Just noting that the id used here is the id -returned in the previous request. \ No newline at end of file diff --git a/UT4MasterServer/docs/client-join-hub.md b/UT4MasterServer/docs/client-join-hub.md deleted file mode 100644 index 75a3ef3b..00000000 --- a/UT4MasterServer/docs/client-join-hub.md +++ /dev/null @@ -1,76 +0,0 @@ -# Client Joining Hub Flow - -These are the calls specific to joining a hub lobby. - -## Query Profile - -``` -POST https://ut-public-service-prod10.ol.epicgames.com/ut/api/game/v2/profile/fd83abe496ca401497b5adf4e412bf2c/client/QueryProfile?profileId=profile0&rvn=3911 HTTP/1.1 -Host: ut-public-service-prod10.ol.epicgames.com -Cookie: AWSELB=E9958FDB1448870EBFBE28377F3D10376F43CDC4B14B7412096B9B03EBCEE56C4051F02C0839B49802B60CF8413BEE9CF0EE52960FC45D37FCCE1689E8CE7AEE49558F7C5C; AWSELBCORS=E9958FDB1448870EBFBE28377F3D10376F43CDC4B14B7412096B9B03EBCEE56C4051F02C0839B49802B60CF8413BEE9CF0EE52960FC45D37FCCE1689E8CE7AEE49558F7C5C -Accept: */* -Content-Type: application/json -User-Agent: game=UnrealTournament, engine=UE4, build=++UT+Release-Next-CL-3525360 -X-Epic-Correlation-ID: UE4-004C6347484951CB836BC2842F236F7B -Authorization: bearer 8999c1a8446549e38e621e2e6f7603ec -Content-Length: 4 -Pragma: no-cache - -{ -} -``` - -Similar to login flow `QueryProfile`, but uses `rvn=3911` instead of `-1` and a different response -comes back. - -### Response - -``` -{ - "profileRevision": 3911, - "profileId": "profile0", - "profileChangesBaseRevision": 3911, - "profileChanges": [], - "profileCommandRevision": 3910, - "serverTime": "2022-12-21T04:39:41.662Z", - "responseVersion": 1, - "command": "QueryProfile" -} -``` - -## Get Hub Session Data - -``` -GET https://ut-public-service-prod10.ol.epicgames.com/ut/api/matchmaking/session/31a9890865de46b5af8b58105340cdc3 HTTP/1.1 -Host: ut-public-service-prod10.ol.epicgames.com -Accept: */* -Cookie: AWSELB=E9958FDB1448870EBFBE28377F3D10376F43CDC4B14B7412096B9B03EBCEE56C4051F02C0839B49802B60CF8413BEE9CF0EE52960FC45D37FCCE1689E8CE7AEE49558F7C5C; AWSELBCORS=E9958FDB1448870EBFBE28377F3D10376F43CDC4B14B7412096B9B03EBCEE56C4051F02C0839B49802B60CF8413BEE9CF0EE52960FC45D37FCCE1689E8CE7AEE49558F7C5C -Content-Type: application/json -User-Agent: game=UnrealTournament, engine=UE4, build=++UT+Release-Next-CL-3525360 -X-Epic-Correlation-ID: UE4-27E1898E4BCF1CB7570FF0A6EC3A2BDF -Authorization: bearer 8999c1a8446549e38e621e2e6f7603ec -Content-Length: 0 -Pragma: no-cache -``` - -This gets all of the session data for the hub, in the same format as the responses to the PUT and -POST requests made by the hub. - -## Join Hub - -``` -POST https://ut-public-service-prod10.ol.epicgames.com/ut/api/matchmaking/session/31a9890865de46b5af8b58105340cdc3/join?accountId=fd83abe496ca401497b5adf4e412bf2c HTTP/1.1 -Host: ut-public-service-prod10.ol.epicgames.com -Accept: */* -Cookie: AWSELB=E9958FDB1448870EBFBE28377F3D10376F43CDC4B14B7412096B9B03EBCEE56C4051F02C0839B49802B60CF8413BEE9CF0EE52960FC45D37FCCE1689E8CE7AEE49558F7C5C; AWSELBCORS=E9958FDB1448870EBFBE28377F3D10376F43CDC4B14B7412096B9B03EBCEE56C4051F02C0839B49802B60CF8413BEE9CF0EE52960FC45D37FCCE1689E8CE7AEE49558F7C5C -Content-Type: application/json -User-Agent: game=UnrealTournament, engine=UE4, build=++UT+Release-Next-CL-3525360 -X-Epic-Correlation-ID: UE4-3D8AB9C949DC5BF98C398FA62038BCD8 -Authorization: bearer 8999c1a8446549e38e621e2e6f7603ec -Content-Length: 0 -Pragma: no-cache - -``` - -Server is likely checking that the `accountId` is valid and that responding appropriately. Will get -a 204 back on success. \ No newline at end of file diff --git a/UT4MasterServer/docs/hub-instance-start.md b/UT4MasterServer/docs/hub-instance-start.md deleted file mode 100644 index 522baef6..00000000 --- a/UT4MasterServer/docs/hub-instance-start.md +++ /dev/null @@ -1,139 +0,0 @@ -# Hub Instance Creation Flow - -These are the calls specific to launching a hub instance. - -## Auth - -Identical to hub launch auth - -## Start Instance Session - - -``` -POST https://ut-public-service-prod10.ol.epicgames.com/ut/api/matchmaking/session HTTP/1.1 -Host: ut-public-service-prod10.ol.epicgames.com -Accept: */* -Content-Type: application/json -User-Agent: game=UnrealTournament, engine=UE4, build=++UT+Release-Next-CL-3525360 -X-Epic-Correlation-ID: UE4-6277930D4EF798AB6ECC2A9DF8B26049 -Authorization: bearer de936fc420a94742a42cfbd06e7945d1 -Content-Length: 1469 -Pragma: no-cache - -{ - "ownerId": "D1E2CEDA40050AAF3E205185E1697234", - "ownerName": "[DS]DESKTOP-IQJVTED-15520", - "serverName": "[DS]DESKTOP-IQJVTED-15520", - "maxPublicPlayers": 10000, - "maxPrivatePlayers": 0, - "shouldAdvertise": true, - "allowJoinInProgress": true, - "isDedicated": true, - "usesStats": false, - "allowInvites": true, - "usesPresence": false, - "allowJoinViaPresence": true, - "allowJoinViaPresenceFriendsOnly": true, - "buildUniqueId": "256652735", - "attributes": - { - "BEACONPORT_i": 7788, - "GAMEMODE_s": "/Script/UnrealTournament.UTCTFGameMode", - "MAPNAME_s": "CTF-TitanPass", - "GAMENAME_s": "Capture the Flag", - "UT_MATCHELO_i": 0, - "UT_SERVERNAME_s": "Capture the Flag on CTF-TitanPass", - "UT_SERVERMOTD_s": "This is the ServerMOTD string from Game.ini", - "UT_MATCHDURATION_i": 0, - "UT_HUBGUID_s": "516E6C8C4595684421A2A3AB7BA704F6", - "UT_REDTEAMSIZE_i": 0, - "UT_BLUETEAMSIZE_i": 0, - "UT_TRAININGGROUND_b": false, - "UT_MINELO_i": 0, - "UT_MAXELO_i": 0, - "UT_PLAYERONLINE_i": 0, - "UT_SPECTATORSONLINE_i": 0, - "UT_SERVERVERSION_s": "3525360", - "UT_SERVERINSTANCEGUID_s": "9E61ACB74C95EFDEDAF11080C26D211E", - "UT_GAMEINSTANCE_i": 1, - "UT_SERVERFLAGS_i": 0, - "UT_NUMMATCHES_i": 1, - "UT_MAXPLAYERS_i": 10, - "UT_MAXSPECTATORS_i": 7, - "UT_MATCHSTATE_s": "EnteringMap" - }, - "serverPort": 8000, - "openPrivatePlayers": 0, - "openPublicPlayers": 10000, - "sortWeight": 0, - "publicPlayers": [], - "privatePlayers": [] -} -``` - -Very similar to hub session announcement, but with added `attributes` fields: - -``` -GAMENAME_s: string // "Capture the Flag" aka gametype name -UT_MATCHELO_i: int // probably min ELO required to join -``` - -### Response - -``` -{ - "id": "29013d8988ca45fdb632546f93990426", - "ownerId": "D1E2CEDA40050AAF3E205185E1697234", - "ownerName": "[DS]DESKTOP-IQJVTED-15520", - "serverName": "[DS]DESKTOP-IQJVTED-15520", - "serverAddress": "108.172.186.109", - "serverPort": 8000, - "maxPublicPlayers": 10000, - "openPublicPlayers": 10000, - "maxPrivatePlayers": 0, - "openPrivatePlayers": 0, - "attributes": { - "UT_REDTEAMSIZE_i": 0, - "BEACONPORT_i": 7788, - "UT_PLAYERONLINE_i": 0, - "UT_MATCHSTATE_s": "EnteringMap", - "UT_SERVERINSTANCEGUID_s": "9E61ACB74C95EFDEDAF11080C26D211E", - "UT_SPECTATORSONLINE_i": 0, - "UT_MAXPLAYERS_i": 10, - "UT_MATCHELO_i": 0, - "UT_SERVERNAME_s": "Capture the Flag on CTF-TitanPass", - "GAMENAME_s": "Capture the Flag", - "UT_NUMMATCHES_i": 1, - "UT_GAMEINSTANCE_i": 1, - "UT_MAXSPECTATORS_i": 7, - "UT_SERVERVERSION_s": "3525360", - "GAMEMODE_s": "/Script/UnrealTournament.UTCTFGameMode", - "UT_HUBGUID_s": "516E6C8C4595684421A2A3AB7BA704F6", - "UT_BLUETEAMSIZE_i": 0, - "UT_SERVERTRUSTLEVEL_i": 2, - "UT_TRAININGGROUND_b": false, - "UT_MINELO_i": 0, - "UT_MAXELO_i": 0, - "UT_SERVERMOTD_s": "This is the ServerMOTD string from Game.ini", - "MAPNAME_s": "CTF-TitanPass", - "UT_MATCHDURATION_i": 0, - "UT_SERVERFLAGS_i": 0 - }, - "publicPlayers": [], - "privatePlayers": [], - "totalPlayers": 0, - "allowJoinInProgress": true, - "shouldAdvertise": true, - "isDedicated": true, - "usesStats": false, - "allowInvites": true, - "usesPresence": false, - "allowJoinViaPresence": true, - "allowJoinViaPresenceFriendsOnly": true, - "buildUniqueId": "256652735", - "lastUpdated": "2022-12-21T03:48:20.258Z", - "started": false -} -``` - -No different in format than hub launch response. \ No newline at end of file diff --git a/UT4MasterServer/docs/hub-launch.md b/UT4MasterServer/docs/hub-launch.md deleted file mode 100644 index 7cd004a3..00000000 --- a/UT4MasterServer/docs/hub-launch.md +++ /dev/null @@ -1,333 +0,0 @@ -# Hub Startup Flow - -These are the calls specific to launching a hub. There are other calls made to the -cloudstorage endpoints, but they are the same calls made by clients. - -## Auth - -``` -POST https://account-public-service-prod03.ol.epicgames.com/account/api/oauth/token HTTP/1.1 -Host: account-public-service-prod03.ol.epicgames.com -Accept: */* -Content-Type: application/x-www-form-urlencoded -User-Agent: game=UnrealTournament, engine=UE4, build=++UT+Release-Next-CL-3525360 -Authorization: basic NmZmNDNlNzQzZWRjNGQxZGJhYzM1OTQ4NzdiNGJlZDk6NTQ2MTlkNmY4NGQ0NDNlMTk1MjAwYjU0YWI2NDlhNTM= -Content-Length: 29 -Pragma: no-cache - -grant_type=client_credentials -``` - -All hub and instance auth requests use the same basic auth data, where the username is -`6ff43e743edc4d1dbac3594877b4bed9` and the password is `54619d6f84d443e195200b54ab649a53` - - -## Start Hub Session - -``` -POST https://ut-public-service-prod10.ol.epicgames.com/ut/api/matchmaking/session HTTP/1.1 -Host: ut-public-service-prod10.ol.epicgames.com -Accept: */* -Content-Type: application/json -User-Agent: game=UnrealTournament, engine=UE4, build=++UT+Release-Next-CL-3525360 -X-Epic-Correlation-ID: UE4-C88EEEF44FD10436955A35BD21C075DE -Authorization: bearer 47c88ffa188c46afa3807c1aefdcfcc8 -Content-Length: 1417 -Pragma: no-cache - -{ - "ownerId": "02494C20477617250BB7ED984530AB99", - "ownerName": "[DS]DESKTOP-IQJVTED-27320", - "serverName": "[DS]DESKTOP-IQJVTED-27320", - "maxPublicPlayers": 10000, - "maxPrivatePlayers": 0, - "shouldAdvertise": true, - "allowJoinInProgress": true, - "isDedicated": true, - "usesStats": false, - "allowInvites": true, - "usesPresence": false, - "allowJoinViaPresence": true, - "allowJoinViaPresenceFriendsOnly": true, - "buildUniqueId": "256652735", - "attributes": - { - "BEACONPORT_i": 7787, - "GAMEMODE_s": "/Script/UnrealTournament.UTLobbyGameMode", - "MAPNAME_s": "UT-Entry", - "UT_SERVERNAME_s": "This is the ServerName string from Game.ini", - "UT_SERVERMOTD_s": "This is the ServerMOTD string from Game.ini", - "UT_MATCHDURATION_i": 0, - "UT_HUBGUID_s": "516E6C8C4595684421A2A3AB7BA704F6", - "UT_REDTEAMSIZE_i": 0, - "UT_BLUETEAMSIZE_i": 0, - "UT_TRAININGGROUND_b": false, - "UT_MINELO_i": 0, - "UT_MAXELO_i": 0, - "UT_PLAYERONLINE_i": 0, - "UT_SPECTATORSONLINE_i": 0, - "UT_SERVERVERSION_s": "3525360", - "UT_SERVERINSTANCEGUID_s": "516E6C8C4595684421A2A3AB7BA704F6", - "UT_GAMEINSTANCE_i": 0, - "UT_SERVERFLAGS_i": 0, - "UT_NUMMATCHES_i": 0, - "UT_MAXPLAYERS_i": 200, - "UT_MAXSPECTATORS_i": 7, - "UT_MATCHSTATE_s": "EnteringMap" - }, - "serverPort": 7777, - "openPrivatePlayers": 0, - "openPublicPlayers": 10000, - "sortWeight": 0, - "publicPlayers": [], - "privatePlayers": [] -} -``` - -This POST request announces a new instance. `ownerName` and `serverName` are always `[DS]` -followed by the system device name (`DESKTOP-IQJVTED`) followed by some numbers. - -`UT_HUBGUID_s` and `UT_SERVERINSTANCEGUID_s` are found in the server's Game.ini: - -``` -[/Script/UnrealTournament.UTBaseGameMode] -ServerInstanceID=516E6C8C4595684421A2A3AB7BA704F6 -``` - -### Response - -``` -{ - "id": "4366368a402a4dd1adf3a9fd9fa15a36", - "ownerId": "02494C20477617250BB7ED984530AB99", - "ownerName": "[DS]DESKTOP-IQJVTED-27320", - "serverName": "[DS]DESKTOP-IQJVTED-27320", - "serverAddress": "108.172.186.109", - "serverPort": 7777, - "maxPublicPlayers": 10000, - "openPublicPlayers": 10000, - "maxPrivatePlayers": 0, - "openPrivatePlayers": 0, - "attributes": { - "UT_SERVERNAME_s": "This is the ServerName string from Game.ini", - "UT_REDTEAMSIZE_i": 0, - "UT_NUMMATCHES_i": 0, - "UT_GAMEINSTANCE_i": 0, - "UT_MAXSPECTATORS_i": 7, - "BEACONPORT_i": 7787, - "UT_PLAYERONLINE_i": 0, - "UT_SERVERVERSION_s": "3525360", - "GAMEMODE_s": "/Script/UnrealTournament.UTLobbyGameMode", - "UT_HUBGUID_s": "516E6C8C4595684421A2A3AB7BA704F6", - "UT_BLUETEAMSIZE_i": 0, - "UT_MATCHSTATE_s": "EnteringMap", - "UT_SERVERTRUSTLEVEL_i": 2, - "UT_SERVERINSTANCEGUID_s": "516E6C8C4595684421A2A3AB7BA704F6", - "UT_TRAININGGROUND_b": false, - "UT_MINELO_i": 0, - "UT_MAXELO_i": 0, - "UT_SPECTATORSONLINE_i": 0, - "UT_MAXPLAYERS_i": 200, - "UT_SERVERMOTD_s": "This is the ServerMOTD string from Game.ini", - "MAPNAME_s": "UT-Entry", - "UT_MATCHDURATION_i": 0, - "UT_SERVERFLAGS_i": 0 - }, - "publicPlayers": [], - "privatePlayers": [], - "totalPlayers": 0, - "allowJoinInProgress": true, - "shouldAdvertise": true, - "isDedicated": true, - "usesStats": false, - "allowInvites": true, - "usesPresence": false, - "allowJoinViaPresence": true, - "allowJoinViaPresenceFriendsOnly": true, - "buildUniqueId": "256652735", - "lastUpdated": "2022-12-21T03:46:12.280Z", - "started": false -} -``` - -Responds with a 200 including all passed in data plus some additional fields: -``` -id: string // a guid -lastUpdated: string // an iso8601 datetime -serverAddress: string // an ipv4 address -started: false // whether or not the hub has started -totalPlayers: 0 -``` - -## Update Hub Session - -``` -PUT https://ut-public-service-prod10.ol.epicgames.com/ut/api/matchmaking/session/4366368a402a4dd1adf3a9fd9fa15a36 HTTP/1.1 -Host: ut-public-service-prod10.ol.epicgames.com -Accept: */* -Cookie: AWSELB=E9958FDB1448870EBFBE28377F3D10376F43CDC4B19CDE0B558DCA7837C13530AF8AEE356833F1E77089BA61742B66DC413B1809DF694FA70E14833E719C9EECB1D2256DBC; AWSELBCORS=E9958FDB1448870EBFBE28377F3D10376F43CDC4B19CDE0B558DCA7837C13530AF8AEE356833F1E77089BA61742B66DC413B1809DF694FA70E14833E719C9EECB1D2256DBC -Content-Type: application/json -User-Agent: game=UnrealTournament, engine=UE4, build=++UT+Release-Next-CL-3525360 -X-Epic-Correlation-ID: UE4-56E0785C4AC160A710AB45B06CB4113B -Authorization: bearer 47c88ffa188c46afa3807c1aefdcfcc8 -Content-Length: 1526 -Pragma: no-cache - -{ - "ownerId": "02494C20477617250BB7ED984530AB99", - "ownerName": "[DS]DESKTOP-IQJVTED-27320", - "serverName": "[DS]DESKTOP-IQJVTED-27320", - "maxPublicPlayers": 10000, - "maxPrivatePlayers": 0, - "shouldAdvertise": true, - "allowJoinInProgress": true, - "isDedicated": true, - "usesStats": false, - "allowInvites": true, - "usesPresence": false, - "allowJoinViaPresence": true, - "allowJoinViaPresenceFriendsOnly": false, - "buildUniqueId": "256652735", - "attributes": - { - "BEACONPORT_i": 7787, - "GAMEMODE_s": "/Script/UnrealTournament.UTLobbyGameMode", - "MAPNAME_s": "UT-Entry", - "UT_SERVERNAME_s": "This is the ServerName string from Game.ini", - "UT_SERVERMOTD_s": "This is the ServerMOTD string from Game.ini", - "UT_MATCHDURATION_i": 0, - "UT_HUBGUID_s": "516E6C8C4595684421A2A3AB7BA704F6", - "UT_REDTEAMSIZE_i": 0, - "UT_BLUETEAMSIZE_i": 0, - "UT_TRAININGGROUND_b": false, - "UT_MINELO_i": 0, - "UT_MAXELO_i": 0, - "UT_PLAYERONLINE_i": 0, - "UT_SPECTATORSONLINE_i": 0, - "UT_SERVERVERSION_s": "3525360", - "UT_SERVERINSTANCEGUID_s": "516E6C8C4595684421A2A3AB7BA704F6", - "UT_GAMEINSTANCE_i": 0, - "UT_SERVERFLAGS_i": 0, - "UT_NUMMATCHES_i": 0, - "UT_MAXPLAYERS_i": 200, - "UT_MAXSPECTATORS_i": 7, - "UT_MATCHSTATE_s": "EnteringMap", - "UT_SERVERTRUSTLEVEL_i": 2 - }, - "id": "4366368a402a4dd1adf3a9fd9fa15a36", - "serverAddress": "1823259245", - "serverPort": 7777, - "openPrivatePlayers": 0, - "openPublicPlayers": 10000, - "sortWeight": 0, - "publicPlayers": [], - "privatePlayers": [] -} -``` - -Same deal as the initial announce, just with a PUT to update. For some reason sends a -strange `serverAddress` field. Response is in the same format as the initial POST. - -## Start Hub Session - -``` -POST https://ut-public-service-prod10.ol.epicgames.com/ut/api/matchmaking/session/4366368a402a4dd1adf3a9fd9fa15a36/start HTTP/1.1 -Host: ut-public-service-prod10.ol.epicgames.com -Accept: */* -Cookie: AWSELB=E9958FDB1448870EBFBE28377F3D10376F43CDC4B19CDE0B558DCA7837C13530AF8AEE356833F1E77089BA61742B66DC413B1809DF694FA70E14833E719C9EECB1D2256DBC; AWSELBCORS=E9958FDB1448870EBFBE28377F3D10376F43CDC4B19CDE0B558DCA7837C13530AF8AEE356833F1E77089BA61742B66DC413B1809DF694FA70E14833E719C9EECB1D2256DBC -Content-Type: application/json -User-Agent: game=UnrealTournament, engine=UE4, build=++UT+Release-Next-CL-3525360 -X-Epic-Correlation-ID: UE4-A93DEE5342F1F3D3E4A74CB8762F645C -Authorization: bearer 47c88ffa188c46afa3807c1aefdcfcc8 -Content-Length: 0 -Pragma: no-cache -``` - -Sends no data, receives none back. Just a 204 response. Probably indicating that the hub is ready. - -## Update Hub Session - -``` -PUT https://ut-public-service-prod10.ol.epicgames.com/ut/api/matchmaking/session/4366368a402a4dd1adf3a9fd9fa15a36 HTTP/1.1 -Host: ut-public-service-prod10.ol.epicgames.com -Accept: */* -Cookie: AWSELB=E9958FDB1448870EBFBE28377F3D10376F43CDC4B19CDE0B558DCA7837C13530AF8AEE356833F1E77089BA61742B66DC413B1809DF694FA70E14833E719C9EECB1D2256DBC; AWSELBCORS=E9958FDB1448870EBFBE28377F3D10376F43CDC4B19CDE0B558DCA7837C13530AF8AEE356833F1E77089BA61742B66DC413B1809DF694FA70E14833E719C9EECB1D2256DBC -Content-Type: application/json -User-Agent: game=UnrealTournament, engine=UE4, build=++UT+Release-Next-CL-3525360 -X-Epic-Correlation-ID: UE4-18A71DF0426B405BEA1389BACEF5E72F -Authorization: bearer 47c88ffa188c46afa3807c1aefdcfcc8 -Content-Length: 1531 -Pragma: no-cache - -{ - "ownerId": "02494C20477617250BB7ED984530AB99", - "ownerName": "[DS]DESKTOP-IQJVTED-27320", - "serverName": "[DS]DESKTOP-IQJVTED-27320", - "maxPublicPlayers": 10000, - "maxPrivatePlayers": 0, - "shouldAdvertise": true, - "allowJoinInProgress": true, - "isDedicated": true, - "usesStats": false, - "allowInvites": true, - "usesPresence": false, - "allowJoinViaPresence": true, - "allowJoinViaPresenceFriendsOnly": false, - "buildUniqueId": "256652735", - "attributes": - { - "BEACONPORT_i": 7787, - "GAMEMODE_s": "/Script/UnrealTournament.UTLobbyGameMode", - "MAPNAME_s": "UT-Entry", - "UT_SERVERNAME_s": "This is the ServerName string from Game.ini ", - "UT_SERVERMOTD_s": "This is the ServerMOTD string from Game.ini ", - "UT_MATCHDURATION_i": 0, - "UT_HUBGUID_s": "516E6C8C4595684421A2A3AB7BA704F6", - "UT_REDTEAMSIZE_i": 0, - "UT_BLUETEAMSIZE_i": 0, - "UT_TRAININGGROUND_b": false, - "UT_MINELO_i": 0, - "UT_MAXELO_i": 0, - "UT_PLAYERONLINE_i": 0, - "UT_SPECTATORSONLINE_i": 0, - "UT_SERVERVERSION_s": "3525360", - "UT_SERVERINSTANCEGUID_s": "516E6C8C4595684421A2A3AB7BA704F6", - "UT_GAMEINSTANCE_i": 0, - "UT_SERVERFLAGS_i": 0, - "UT_NUMMATCHES_i": 0, - "UT_MAXPLAYERS_i": 200, - "UT_MAXSPECTATORS_i": 7, - "UT_MATCHSTATE_s": "WaitingToStart", - "UT_SERVERTRUSTLEVEL_i": 2 - }, - "id": "4366368a402a4dd1adf3a9fd9fa15a36", - "serverAddress": "1823259245", - "serverPort": 7777, - "openPrivatePlayers": 0, - "openPublicPlayers": 10000, - "sortWeight": 0, - "publicPlayers": [], - "privatePlayers": [] -} -``` - -Same as the initial PUT, but with a new `UT_MATCHSTATE_s`. Also has added spaces to the end of -`UT_SERVERNAME_s` and `UT_SERVERMOTD_s` - -## Heartbeat - -``` -POST https://ut-public-service-prod10.ol.epicgames.com/ut/api/matchmaking/session/4366368a402a4dd1adf3a9fd9fa15a36/heartbeat HTTP/1.1 -Host: ut-public-service-prod10.ol.epicgames.com -Accept: */* -Cookie: AWSELB=E9958FDB1448870EBFBE28377F3D10376F43CDC4B19CDE0B558DCA7837C13530AF8AEE356833F1E77089BA61742B66DC413B1809DF694FA70E14833E719C9EECB1D2256DBC; AWSELBCORS=E9958FDB1448870EBFBE28377F3D10376F43CDC4B19CDE0B558DCA7837C13530AF8AEE356833F1E77089BA61742B66DC413B1809DF694FA70E14833E719C9EECB1D2256DBC -Content-Type: application/json -User-Agent: game=UnrealTournament, engine=UE4, build=++UT+Release-Next-CL-3525360 -X-Epic-Correlation-ID: UE4-1D75DB9D4D6F0E2C0A2DE88E11BC34D0 -Authorization: bearer 47c88ffa188c46afa3807c1aefdcfcc8 -Content-Length: 0 -Pragma: no-cache -``` - -This is sent every 30 seconds. No data in or out, just a 204. Master server probably delists hubs a -short time after their last heartbeat. \ No newline at end of file diff --git a/UT4MasterServer/docs/hub-player-join.md b/UT4MasterServer/docs/hub-player-join.md deleted file mode 100644 index e69fc56a..00000000 --- a/UT4MasterServer/docs/hub-player-join.md +++ /dev/null @@ -1,248 +0,0 @@ -# Player Join Hub Flow - -These are the calls that are made specific to a player joining a hub. - -## Join - -``` -POST https://ut-public-service-prod10.ol.epicgames.com/ut/api/game/v2/profile/fd83abe496ca401497b5adf4e412bf2c/dedicated_server/QueryProfile?profileId=profile0&rvn=-1 HTTP/1.1 -Host: ut-public-service-prod10.ol.epicgames.com -Cookie: AWSELB=E9958FDB1448870EBFBE28377F3D10376F43CDC4B19CDE0B558DCA7837C13530AF8AEE356833F1E77089BA61742B66DC413B1809DF694FA70E14833E719C9EECB1D2256DBC; AWSELBCORS=E9958FDB1448870EBFBE28377F3D10376F43CDC4B19CDE0B558DCA7837C13530AF8AEE356833F1E77089BA61742B66DC413B1809DF694FA70E14833E719C9EECB1D2256DBC -Accept: */* -Content-Type: application/json -User-Agent: game=UnrealTournament, engine=UE4, build=++UT+Release-Next-CL-3525360 -X-Epic-Correlation-ID: UE4-1BC8A26F436E4244ACDCE7ADA2138B61 -Authorization: bearer 47c88ffa188c46afa3807c1aefdcfcc8 -Content-Length: 4 -Pragma: no-cache - -{ -} -``` - -POSTs a request to get the profile of the joined player. - -### Server Response - -``` -{ - "profileRevision": 3911, - "profileId": "profile0", - "profileChangesBaseRevision": 3911, - "profileChanges": [ - { - "changeType": "fullProfileUpdate", - "profile": { - "_id": "dd8ba6fe24944718938b6b23a071ffa5", - "created": "2017-01-29T03:21:48.822Z", - "updated": "2022-12-15T00:25:57.941Z", - "rvn": 3911, - "wipeNumber": 4, - "accountId": "fd83abe496ca401497b5adf4e412bf2c", - "profileId": "profile0", - "version": "ut_base", - "items": { - "c3424820-7c39-44f6-a2f4-897d91f4eb7b": { - "templateId": "Item.ThundercrashMale03", - "attributes": { - "tradable": false - }, - "quantity": 1 - }, - "6f686cc3-7a82-4764-a9e3-c5a9e49eb260": { - "templateId": "Item.ThundercrashMale02", - "attributes": { - "tradable": false - }, - "quantity": 1 - }, - "b18ae8de-fc6b-4112-a593-6998333d73b3": { - "templateId": "Item.Sunglasses", - "attributes": { - "tradable": true - }, - "quantity": 1 - }, - "a9079379-6c72-4c39-837e-bf93badf3ad6": { - "templateId": "Item.HockeyMask", - "attributes": { - "tradable": true - }, - "quantity": 1 - }, - "e4712c64-bc7f-479f-9946-bf024969c626": { - "templateId": "Item.HockeyMask02", - "attributes": { - "tradable": true - }, - "quantity": 1 - }, - "e6f6f3ad-b86d-4d25-8d48-79c85d586db8": { - "templateId": "Item.ThundercrashBeanieGreen", - "attributes": { - "tradable": true - }, - "quantity": 1 - }, - "f4e8d9ab-8d71-4779-b70a-927d642f145e": { - "templateId": "Item.NecrisMale04", - "attributes": { - "tradable": false - }, - "quantity": 1 - }, - "ff756c89-200d-4b12-b245-c420255d32a5": { - "templateId": "Item.ThundercrashBeanieRed", - "attributes": { - "tradable": true - }, - "quantity": 1 - }, - "03adb035-6a79-417d-9768-344ea25b1ac2": { - "templateId": "Item.BeanieWhite", - "attributes": { - "tradable": true - }, - "quantity": 1 - }, - "b92045b3-4361-45fc-a240-f1d74174677f": { - "templateId": "Item.ThundercrashMale05", - "attributes": { - "tradable": false - }, - "quantity": 1 - }, - "a2abe02d-a43d-4c61-b502-527f1377b16b": { - "templateId": "Item.NecrisHelm01", - "attributes": { - "tradable": true - }, - "quantity": 1 - }, - "db1287ba-158d-4e89-847c-8d5d4c8326ce": { - "templateId": "Item.NecrisHelm02", - "attributes": { - "tradable": true - }, - "quantity": 1 - }, - "efecec94-c480-42c6-9ab7-0a913b87320c": { - "templateId": "Item.SkaarjMale01", - "attributes": { - "tradable": false - }, - "quantity": 1 - }, - "64741a54-5831-4812-90ab-a80fb8f545dc": { - "templateId": "Item.BeanieBlack", - "attributes": { - "tradable": true - }, - "quantity": 1 - }, - "f6258e09-9087-4b07-a0f2-20ea975dc97c": { - "templateId": "Item.ThundercrashBeret", - "attributes": { - "tradable": true - }, - "quantity": 1 - }, - "58c238bb-b7ee-491b-848f-816b085ec75b": { - "templateId": "Item.NecrisMale01", - "attributes": { - "tradable": false - }, - "quantity": 1 - }, - "1535b5e3-328a-4209-abfc-665471981387": { - "templateId": "Item.Infiltrator", - "attributes": { - "tradable": true - }, - "quantity": 1 - }, - "d433c849-cdaf-434a-88f7-af7130a56dbf": { - "templateId": "Item.NecrisFemale02", - "attributes": { - "tradable": false - }, - "quantity": 1 - }, - "d080b370-9e72-4964-bd9c-44e130e57b75": { - "templateId": "Item.BeanieGrey", - "attributes": { - "tradable": true - }, - "quantity": 1 - }, - "51284c9e-f246-4147-88fd-b4045a3daca4": { - "templateId": "Item.SkaarjMale02", - "attributes": { - "tradable": false - }, - "quantity": 1 - } - }, - "stats": { - "templateId": "profile_v2", - "attributes": { - "CountryFlag": "Canada", - "GoldStars": 20, - "login_rewards": { - "nextClaimTime": null, - "level": 0, - "totalDays": 0 - }, - "Avatar": "UT.Avatar.2", - "inventory_limit_bonus": 0, - "daily_purchases": {}, - "in_app_purchases": {}, - "LastXPTime": 1670909243, - "XP": 294056, - "Level": 50, - "BlueStars": 2, - "RecentXP": 107, - "boosts": [], - "new_items": {} - } - }, - "commandRevision": 3910 - } - } - ], - "profileCommandRevision": 3910, - "serverTime": "2022-12-21T03:47:39.506Z", - "responseVersion": 1, - "command": "QueryProfile" -} -``` - -Similar data that is returned to the client when it makes a QueryProfile request. - -## Update Players - -``` -POST https://ut-public-service-prod10.ol.epicgames.com/ut/api/matchmaking/session/4366368a402a4dd1adf3a9fd9fa15a36/players HTTP/1.1 -Host: ut-public-service-prod10.ol.epicgames.com -Accept: */* -Cookie: AWSELB=E9958FDB1448870EBFBE28377F3D10376F43CDC4B19CDE0B558DCA7837C13530AF8AEE356833F1E77089BA61742B66DC413B1809DF694FA70E14833E719C9EECB1D2256DBC; AWSELBCORS=E9958FDB1448870EBFBE28377F3D10376F43CDC4B19CDE0B558DCA7837C13530AF8AEE356833F1E77089BA61742B66DC413B1809DF694FA70E14833E719C9EECB1D2256DBC -Content-Type: application/json -User-Agent: game=UnrealTournament, engine=UE4, build=++UT+Release-Next-CL-3525360 -X-Epic-Correlation-ID: UE4-300D354C408D213AA3443EBEDA043C79 -Authorization: bearer 47c88ffa188c46afa3807c1aefdcfcc8 -Content-Length: 91 -Pragma: no-cache - -{ - "publicPlayers": [ - "fd83abe496ca401497b5adf4e412bf2c" - ], - "privatePlayers": [] -} -``` - -POSTs the new player list. Response is the usual hub update response format. - -## Update Hub Session - -Same as the PUT request and response in the initial hub launch sequence. \ No newline at end of file