Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add presence server #652

Merged
Merged
6 changes: 6 additions & 0 deletions Refresh.Common/Constants/EndpointRoutes.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
namespace Refresh.Common.Constants;

public static class EndpointRoutes
{
public const string PresenceBaseRoute = "/_internal/presence/";
}
jvyden marked this conversation as resolved.
Show resolved Hide resolved
3 changes: 3 additions & 0 deletions Refresh.Common/Constants/SystemUsers.cs
Original file line number Diff line number Diff line change
Expand Up @@ -17,4 +17,7 @@ public static class SystemUsers

public const string UnknownUserName = "!Unknown";
public const string UnknownUserDescription = "I'm a fake user that represents a non existent publisher for re-published levels.";

public const string HashedUserName = "!Hashed";
public const string HashedUserDescription = "I'm a fake user that represents an unknown publisher for hashed levels.";
}
110 changes: 110 additions & 0 deletions Refresh.Common/Helpers/ResourceHelper.cs
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@
using System.Buffers.Binary;
using System.IO.Compression;
using System.Reflection;
using System.Runtime.CompilerServices;
using System.Runtime.InteropServices;
using System.Security.Cryptography;
using FastAes;
using IronCompress;
Expand Down Expand Up @@ -107,4 +109,112 @@ public static byte[] PspDecrypt(Span<byte> data, ReadOnlySpan<byte> key)
//Return a copy of the decompressed data
return decompressed.AsSpan().ToArray();
}

static int XXTEA_DELTA = Unsafe.BitCast<uint, int>(0x9e3779b9);

/// <summary>
/// In-place encrypts byte data using big endian XXTEA.
///
/// Due to how XXTEA data works, you must pad the data to a multiple of 4 bytes.
/// </summary>
/// <param name="byteData">The data to encrypt</param>
/// <param name="key">The key used to encrypt the data</param>
/// <exception cref="ArgumentException">The input is not a multiple of 4 bytes</exception>
/// <remarks>
/// Referenced from https://github.com/ennuo/toolkit/blob/dc82bee57ab58e9f4bf35993d405529d4cbc7d00/lib/cwlib/src/main/java/cwlib/util/Crypto.java#L97
/// </remarks>
public static void XxteaEncrypt(Span<byte> byteData, Span<int> key)
{
if (byteData.Length % 4 != 0)
throw new ArgumentException("Data must be padded to a multiple of 4 bytes.", nameof(byteData));

// Alias the byte data as integers
Span<int> data = MemoryMarshal.Cast<byte, int>(byteData);

// endian swap from BE so the math happens in LE space
BinaryPrimitives.ReverseEndianness(data, data);

int n = data.Length - 1;
if (n < 1)
{
BinaryPrimitives.ReverseEndianness(data, data);

return;
}

int p, q = 6 + 52 / (n + 1);

int z = data[n], y, sum = 0, e;
while (q-- > 0)
{
sum += XXTEA_DELTA;
e = sum >>> 2 & 3;
for (p = 0; p < n; p++)
{
y = data[p + 1];
z =
data[p] += ((z >>> 5 ^ y << 2) + (y >>> 3 ^ z << 4) ^ (sum ^ y) + (key[p & 3 ^ e] ^ z));
}

y = data[0];
z =
data[n] += ((z >>> 5 ^ y << 2) + (y >>> 3 ^ z << 4) ^ (sum ^ y) + (key[p & 3 ^ e] ^ z));
}

// endian swap so the final data is in LE again
BinaryPrimitives.ReverseEndianness(data, data);
}

/// <summary>
/// In-place decrypts byte data using big endian XXTEA.
///
/// Due to how XXTEA data works, you must pad the data to a multiple of 4 bytes.
/// </summary>
/// <param name="byteData">The data to decrypt</param>
/// <param name="key">The key used to decrypt the data</param>
/// <exception cref="ArgumentException">The input is not a multiple of 4 bytes</exception>
/// <remarks>
/// Referenced from https://github.com/ennuo/toolkit/blob/dc82bee57ab58e9f4bf35993d405529d4cbc7d00/lib/cwlib/src/main/java/cwlib/util/Crypto.java#L97
/// </remarks>
public static void XxteaDecrypt(Span<byte> byteData, Span<int> key)
{
if (byteData.Length % 4 != 0)
throw new ArgumentException("Data must be padded to 4 bytes.", nameof(byteData));

// Alias the byte data as integers
Span<int> data = MemoryMarshal.Cast<byte, int>(byteData);

// endian swap from BE so the math happens in LE space
BinaryPrimitives.ReverseEndianness(data, data);

int n = data.Length - 1;
if (n < 1)
{
BinaryPrimitives.ReverseEndianness(data, data);

return;
}

int p, q = 6 + 52 / (n + 1);

int z, y = data[0], sum = q * XXTEA_DELTA, e;
while (sum != 0)
{
e = sum >>> 2 & 3;
for (p = n; p > 0; p--)
{
z = data[p - 1];
y = data[p] -=
((z >>> 5 ^ y << 2) + (y >>> 3 ^ z << 4) ^ (sum ^ y) + (key[p & 3 ^ e] ^ z));
}

z = data[n];
y =
data[0] -= ((z >>> 5 ^ y << 2) + (y >>> 3 ^ z << 4) ^ (sum ^ y) + (key[p & 3 ^ e] ^ z));
sum -= XXTEA_DELTA;
}

// endian swap so the final data is in LE again
BinaryPrimitives.ReverseEndianness(data, data);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,10 @@ public GameAuthenticationProvider(GameServerConfig? config)

public Token? AuthenticateToken(ListenerContext request, Lazy<IDatabaseContext> db)
{
// Dont attempt to authenticate presence endpoints, as authentication is handled by PresenceAuthenticationMiddleware
if (request.Uri.AbsolutePath.StartsWith(PresenceEndpointAttribute.BaseRoute))
return null;

// First try to grab game token data from MM_AUTH
string? tokenData = request.Cookies["MM_AUTH"];
TokenType tokenType = TokenType.Game;
Expand Down
12 changes: 11 additions & 1 deletion Refresh.GameServer/Configuration/IntegrationConfig.cs
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ namespace Refresh.GameServer.Configuration;
/// </summary>
public class IntegrationConfig : Config
{
public override int CurrentConfigVersion => 5;
public override int CurrentConfigVersion => 6;
public override int Version { get; set; }
protected override void Migrate(int oldVer, dynamic oldConfig)
{
Expand Down Expand Up @@ -56,6 +56,16 @@ protected override void Migrate(int oldVer, dynamic oldConfig)

public bool AipiRestrictAccountOnDetection { get; set; } = false;

#endregion

#region Presence

public bool PresenceEnabled { get; set; } = false;

public string PresenceBaseUrl { get; set; } = "http://localhost:10073";

public string PresenceSharedSecret { get; set; } = "SHARED_SECRET";

#endregion

public string? GrafanaDashboardUrl { get; set; }
Expand Down
8 changes: 8 additions & 0 deletions Refresh.GameServer/Database/GameDatabaseContext.Users.cs
Original file line number Diff line number Diff line change
Expand Up @@ -455,4 +455,12 @@ public void SetUserRootPlaylist(GameUser user, GamePlaylist playlist)
user.RootPlaylist = playlist;
});
}

public void SetUserPresenceAuthToken(GameUser user, string? token)
{
this.Write(() =>
{
user.PresenceServerAuthToken = token;
});
}
}
2 changes: 1 addition & 1 deletion Refresh.GameServer/Database/GameDatabaseProvider.cs
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ protected GameDatabaseProvider(IDateTimeProvider time)
this._time = time;
}

protected override ulong SchemaVersion => 156;
protected override ulong SchemaVersion => 159;

protected override string Filename => "refreshGameServer.realm";

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -46,8 +46,9 @@ public class ApiExtendedGameUserResponse : IApiResponse, IDataConvertableFrom<Ap

public required ApiGameUserStatisticsResponse Statistics { get; set; }
public required ApiGameRoomResponse? ActiveRoom { get; set; }
public required bool ConnectedToPresenceServer { get; set; }

[ContractAnnotation("null => null; notnull => notnull")]
[ContractAnnotation("user:null => null; user:notnull => notnull")]
public static ApiExtendedGameUserResponse? FromOld(GameUser? user, DataContext dataContext)
{
if (user == null) return null;
Expand Down Expand Up @@ -77,6 +78,7 @@ public class ApiExtendedGameUserResponse : IApiResponse, IDataConvertableFrom<Ap
LevelVisibility = user.LevelVisibility,
ProfileVisibility = user.ProfileVisibility,
ShowModdedContent = user.ShowModdedContent,
ConnectedToPresenceServer = user.PresenceServerAuthToken != null,
};
}

Expand Down
20 changes: 15 additions & 5 deletions Refresh.GameServer/Endpoints/ApiV3/LevelApiEndpoints.cs
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
using Refresh.GameServer.Endpoints.ApiV3.ApiTypes.Errors;
using Refresh.GameServer.Endpoints.ApiV3.DataTypes.Request;
using Refresh.GameServer.Endpoints.ApiV3.DataTypes.Response.Levels;
using Refresh.GameServer.Endpoints.Game.DataTypes.Response;
using Refresh.GameServer.Endpoints.Game.Levels.FilterSettings;
using Refresh.GameServer.Extensions;
using Refresh.GameServer.Services;
Expand Down Expand Up @@ -144,13 +145,20 @@ public ApiOkResponse DeleteLevelById(RequestContext context, GameDatabaseContext
[ApiV3Endpoint("levels/id/{id}/setAsOverride", HttpMethods.Post)]
[DocSummary("Marks the level to show in the next slot list gotten from the game")]
[DocError(typeof(ApiNotFoundError), ApiNotFoundError.LevelMissingErrorWhen)]
public ApiOkResponse SetLevelAsOverrideById(RequestContext context, GameDatabaseContext database, GameUser user, LevelListOverrideService service,
public ApiOkResponse SetLevelAsOverrideById(RequestContext context,
GameDatabaseContext database,
GameUser user,
LevelListOverrideService overrideService,
PresenceService presenceService,
[DocSummary("The ID of the level")] int id)
{
GameLevel? level = database.GetLevelById(id);
if (level == null) return ApiNotFoundError.LevelMissingError;

service.AddIdOverridesForUser(user, level);

// If the user isn't on the presence server, or it's unavailable, fallback to a slot override
// TODO: return whether or not the presence server was used
if (!presenceService.PlayLevel(user, id))
Beyley marked this conversation as resolved.
Show resolved Hide resolved
overrideService.AddIdOverridesForUser(user, level);

return new ApiOkResponse();
}
Expand All @@ -159,12 +167,14 @@ public ApiOkResponse SetLevelAsOverrideById(RequestContext context, GameDatabase
[DocSummary("Marks the level hash to show in the next slot list gotten from the game")]
[DocError(typeof(ApiValidationError), ApiValidationError.HashInvalidErrorWhen)]
public ApiOkResponse SetLevelAsOverrideByHash(RequestContext context, GameDatabaseContext database, GameUser user,
LevelListOverrideService service, [DocSummary("The hash of level root resource")] string hash)
LevelListOverrideService service, PresenceService presenceService, [DocSummary("The hash of level root resource")] string hash)
{
if (!CommonPatterns.Sha1Regex().IsMatch(hash))
return ApiValidationError.HashInvalidError;

bool presenceUsed = presenceService.PlayLevel(user, GameLevelResponse.LevelIdFromHash(hash));

service.AddHashOverrideForUser(user, hash);
service.AddHashOverrideForUser(user, hash, presenceUsed);
Beyley marked this conversation as resolved.
Show resolved Hide resolved

return new ApiOkResponse();
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -110,7 +110,7 @@ public static GameLevelResponse FromHash(string hash, DataContext dataContext)
Location = new GameLocation(),
Handle = new SerializedUserHandle
{
Username = $"!Hashed",
Username = SystemUsers.HashedUserName,
IconHash = "0",
},
Type = GameSlotType.User.ToGameType(),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -166,9 +166,12 @@ public class AuthenticationEndpoints : EndpointGroup

Token token = database.GenerateTokenForUser(user, TokenType.Game, game.Value, platform.Value, context.RemoteIp(), GameDatabaseContext.GameTokenExpirySeconds); // 4 hours

//Clear the user's force match
// Clear the user's force match
database.ClearForceMatch(user);

// Mark the user as disconnected from the presence server
database.SetUserPresenceAuthToken(user, null);

context.Logger.LogInfo(BunkumCategory.Authentication, $"{user} successfully logged in on {game} via {platform}");

if (game == TokenGame.LittleBigPlanetPSP)
Expand Down Expand Up @@ -278,6 +281,8 @@ public Response RevokeThisToken(RequestContext context, GameDatabaseContext data

// Revoke the token
database.RevokeToken(token);
// Mark them as disconnected from the presence server
database.SetUserPresenceAuthToken(user, null);
jvyden marked this conversation as resolved.
Show resolved Hide resolved

context.Logger.LogInfo(BunkumCategory.Authentication, $"{user} logged out");
return OK;
Expand Down
4 changes: 2 additions & 2 deletions Refresh.GameServer/Endpoints/Game/Levels/LevelEndpoints.cs
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
using Bunkum.Core;
using Bunkum.Core.Endpoints;
using Bunkum.Core.Endpoints.Debugging;
using Bunkum.Core.Storage;
using Bunkum.Listener.Protocol;
using Refresh.Common.Constants;
using Refresh.GameServer.Authentication;
using Refresh.GameServer.Database;
using Refresh.GameServer.Endpoints.Game.DataTypes.Response;
Expand Down Expand Up @@ -47,7 +47,7 @@ public class LevelEndpoints : EndpointGroup

// If we are getting the levels by a user, and that user is "!Hashed", then we pull that user's overrides
if (route == "by"
&& (context.QueryString.Get("u") == "!Hashed" || user.Username == "!Hashed")
&& (context.QueryString.Get("u") == SystemUsers.HashedUserName || user.Username == SystemUsers.HashedUserName)
&& overrideService.GetLastHashOverrideForUser(token, out string hash))
{
return new SerializedMinimalLevelList
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
using Bunkum.Core;
using Bunkum.Core.Endpoints;
using Bunkum.Core.Responses;
using Bunkum.Protocols.Http;
using Refresh.GameServer.Authentication;
using Refresh.GameServer.Database;
using Refresh.GameServer.Types.UserData;

namespace Refresh.GameServer.Endpoints.Internal.Presence;

public class PresenceEndpoints : EndpointGroup
{
// Test endpoint to allow the presence server to make sure presence support is enabled and configured correctly
[PresenceEndpoint("test", HttpMethods.Post), Authentication(false)]
public Response TestSecret(RequestContext context)
{
return OK;
}

[PresenceEndpoint("informConnection", HttpMethods.Post), Authentication(false)]
public Response InformConnection(RequestContext context, GameDatabaseContext database, string body)
{
GameUser? user = database.GetUserFromTokenData(body, TokenType.Game);

if (user == null)
return NotFound;

database.SetUserPresenceAuthToken(user, body);

context.Logger.LogInfo(RefreshContext.Presence, $"User {user} connected to the presence server");

return OK;
}

[PresenceEndpoint("informDisconnection", HttpMethods.Post), Authentication(false)]
public Response InformDisconnection(RequestContext context, GameDatabaseContext database, string body)
{
GameUser? user = database.GetUserFromTokenData(body, TokenType.Game);

if (user == null)
return NotFound;

database.SetUserPresenceAuthToken(user, null);

context.Logger.LogInfo(RefreshContext.Presence, $"User {user} disconnected from the presence server");

return OK;
}
}
jvyden marked this conversation as resolved.
Show resolved Hide resolved
19 changes: 19 additions & 0 deletions Refresh.GameServer/Endpoints/PresenceEndpointAttribute.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
using Bunkum.Protocols.Http;
using JetBrains.Annotations;
using Refresh.Common.Constants;

namespace Refresh.GameServer.Endpoints;

[MeansImplicitUse]
public class PresenceEndpointAttribute : HttpEndpointAttribute
{
public const string BaseRoute = EndpointRoutes.PresenceBaseRoute;

public PresenceEndpointAttribute(string route, HttpMethods method = HttpMethods.Get, string contentType = Bunkum.Listener.Protocol.ContentType.Plaintext)
: base(BaseRoute + route, method, contentType)
{}

public PresenceEndpointAttribute(string route, string contentType, HttpMethods method = HttpMethods.Get)
: base(BaseRoute + route, method, contentType)
{}
}
Loading
Loading