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