diff --git a/Refresh.Interfaces.Game/Constants/PlaylistEndpointLimits.cs b/Refresh.Interfaces.Game/Constants/PlaylistEndpointLimits.cs new file mode 100644 index 00000000..0439abe7 --- /dev/null +++ b/Refresh.Interfaces.Game/Constants/PlaylistEndpointLimits.cs @@ -0,0 +1,12 @@ +namespace Refresh.Interfaces.Game.Constants.Playlists; + +public static class PlaylistEndpointLimits +{ + // rate-limits + public const int UploadTimeoutDuration = 300; + public const int MaxCreateAmount = 4; // should be enough + public const int MaxUpdateAmount = 12; + public const int UploadBlockDuration = 300; + public const string CreateBucket = "playlist-create"; + public const string UpdateBucket = "playlist-update"; +} \ No newline at end of file diff --git a/Refresh.Interfaces.Game/Endpoints/Playlists/Lbp1PlaylistEndpoints.cs b/Refresh.Interfaces.Game/Endpoints/Playlists/Lbp1PlaylistEndpoints.cs index f28b4d6d..c36d415b 100644 --- a/Refresh.Interfaces.Game/Endpoints/Playlists/Lbp1PlaylistEndpoints.cs +++ b/Refresh.Interfaces.Game/Endpoints/Playlists/Lbp1PlaylistEndpoints.cs @@ -1,5 +1,6 @@ using Bunkum.Core; using Bunkum.Core.Endpoints; +using Bunkum.Core.RateLimit; using Bunkum.Core.Responses; using Bunkum.Listener.Protocol; using Bunkum.Protocols.Http; @@ -11,6 +12,7 @@ using Refresh.Database.Models.Levels; using Refresh.Database.Models.Playlists; using Refresh.Database.Models.Users; +using Refresh.Interfaces.Game.Constants.Playlists; using Refresh.Interfaces.Game.Types.Levels; using Refresh.Interfaces.Game.Types.Lists; using Refresh.Interfaces.Game.Types.Playlists; @@ -19,9 +21,48 @@ namespace Refresh.Interfaces.Game.Endpoints.Playlists; public class Lbp1PlaylistEndpoints : EndpointGroup { + private const int UploadTimeoutDuration = PlaylistEndpointLimits.UploadTimeoutDuration; + private const int MaxCreateAmount = PlaylistEndpointLimits.MaxCreateAmount; + private const int MaxUpdateAmount = PlaylistEndpointLimits.MaxUpdateAmount; + private const int UploadBlockDuration = PlaylistEndpointLimits.UploadBlockDuration; + private const string CreateBucket = PlaylistEndpointLimits.CreateBucket; + private const string UpdateBucket = PlaylistEndpointLimits.UpdateBucket; + + /// + /// Returns a blank hash (0) if the given reference is a GUID which doesn't reference a texture, + /// or a hash referencing an asset which doesn't exist on the server. + /// Returns the passed icon reference if nothing is wrong with it. + /// Don't reject requests with invalid icons, as users normally can't control what icon the game will + /// include in creation requests, and root playlist creation requests will be spammed + softlock the game + /// until the server returns a successful response. + /// + private string ValidateIconHash(string iconHash, DataContext dataContext) + { + if (!string.IsNullOrWhiteSpace(iconHash) && iconHash != "0") + { + if (iconHash.StartsWith('g')) + { + //Parse out the GUID + long guid = long.Parse(iconHash.AsSpan()[1..]); + + if (!dataContext.GuidChecker.IsTextureGuid(dataContext.Game, guid)) + { + return "0"; + } + } + else if (!dataContext.DataStore.ExistsInStore(iconHash)) + { + return "0"; + } + } + + return iconHash; + } + // Creates a playlist, with an optional parent ID [GameEndpoint("createPlaylist", HttpMethods.Post, ContentType.Xml)] [RequireEmailVerified] + [RateLimitSettings(UploadTimeoutDuration, MaxCreateAmount, UploadBlockDuration, CreateBucket)] public Response CreatePlaylist(RequestContext context, DataContext dataContext, GameServerConfig config, SerializedLbp1Playlist body) { GameUser user = dataContext.User!; @@ -57,6 +98,9 @@ public Response CreatePlaylist(RequestContext context, DataContext dataContext, if (body.Description.Length > UgcLimits.DescriptionLimit) body.Description = body.Description[..UgcLimits.DescriptionLimit]; + // Validate icon + body.Icon = this.ValidateIconHash(body.Icon, dataContext); + // Create the playlist, marking it as the root playlist if the user does not have one set already GamePlaylist playlist = dataContext.Database.CreatePlaylist(user, body, rootPlaylist == null); @@ -150,12 +194,13 @@ public Response CreatePlaylist(RequestContext context, DataContext dataContext, [GameEndpoint("setPlaylistMetaData/{id}", HttpMethods.Post, ContentType.Xml)] [RequireEmailVerified] - public Response UpdatePlaylistMetadata(RequestContext context, GameServerConfig config, GameDatabaseContext database, GameUser user, int id, SerializedLbp1Playlist body) + [RateLimitSettings(UploadTimeoutDuration, MaxUpdateAmount, UploadBlockDuration, UpdateBucket)] + public Response UpdatePlaylistMetadata(RequestContext context, GameServerConfig config, DataContext dataContext, GameUser user, int id, SerializedLbp1Playlist body) { if (user.IsWriteBlocked(config)) return Unauthorized; - GamePlaylist? playlist = database.GetPlaylistById(id); + GamePlaylist? playlist = dataContext.Database.GetPlaylistById(id); if (playlist == null) return NotFound; @@ -170,7 +215,10 @@ public Response UpdatePlaylistMetadata(RequestContext context, GameServerConfig if (body.Description.Length > UgcLimits.DescriptionLimit) body.Description = body.Description[..UgcLimits.DescriptionLimit]; - database.UpdatePlaylist(playlist, body); + // Validate icon + body.Icon = this.ValidateIconHash(body.Icon, dataContext); + + dataContext.Database.UpdatePlaylist(playlist, body); return OK; } diff --git a/Refresh.Interfaces.Game/Endpoints/Playlists/Lbp3PlaylistEndpoints.cs b/Refresh.Interfaces.Game/Endpoints/Playlists/Lbp3PlaylistEndpoints.cs index 31db3520..bc91fc1b 100644 --- a/Refresh.Interfaces.Game/Endpoints/Playlists/Lbp3PlaylistEndpoints.cs +++ b/Refresh.Interfaces.Game/Endpoints/Playlists/Lbp3PlaylistEndpoints.cs @@ -1,5 +1,6 @@ using Bunkum.Core; using Bunkum.Core.Endpoints; +using Bunkum.Core.RateLimit; using Bunkum.Core.Responses; using Bunkum.Listener.Protocol; using Bunkum.Protocols.Http; @@ -11,6 +12,7 @@ using Refresh.Database.Models.Levels; using Refresh.Database.Models.Playlists; using Refresh.Database.Models.Users; +using Refresh.Interfaces.Game.Constants.Playlists; using Refresh.Interfaces.Game.Types.Levels; using Refresh.Interfaces.Game.Types.Lists; using Refresh.Interfaces.Game.Types.Playlists; @@ -19,8 +21,16 @@ namespace Refresh.Interfaces.Game.Endpoints.Playlists; public class Lbp3PlaylistEndpoints : EndpointGroup { + private const int UploadTimeoutDuration = PlaylistEndpointLimits.UploadTimeoutDuration; + private const int MaxCreateAmount = PlaylistEndpointLimits.MaxCreateAmount; + private const int MaxUpdateAmount = PlaylistEndpointLimits.MaxUpdateAmount; + private const int UploadBlockDuration = PlaylistEndpointLimits.UploadBlockDuration; + private const string CreateBucket = PlaylistEndpointLimits.CreateBucket; + private const string UpdateBucket = PlaylistEndpointLimits.UpdateBucket; + [GameEndpoint("playlists", HttpMethods.Post, ContentType.Xml)] [RequireEmailVerified] + [RateLimitSettings(UploadTimeoutDuration, MaxCreateAmount, UploadBlockDuration, CreateBucket)] public Response CreatePlaylist(RequestContext context, GameServerConfig config, DataContext dataContext, GameUser user, SerializedLbp3Playlist body) { if (user.IsWriteBlocked(config)) @@ -48,6 +58,7 @@ public Response CreatePlaylist(RequestContext context, GameServerConfig config, [GameEndpoint("playlists/{playlistId}", HttpMethods.Post, ContentType.Xml)] [RequireEmailVerified] + [RateLimitSettings(UploadTimeoutDuration, MaxUpdateAmount, UploadBlockDuration, UpdateBucket)] public Response UpdatePlaylist(RequestContext context, GameServerConfig config, DataContext dataContext, GameUser user, SerializedLbp3Playlist body, int playlistId) { if (user.IsWriteBlocked(config)) diff --git a/RefreshTests.GameServer/Tests/Playlists/PlaylistUploadTests.cs b/RefreshTests.GameServer/Tests/Playlists/PlaylistUploadTests.cs new file mode 100644 index 00000000..09eb1fba --- /dev/null +++ b/RefreshTests.GameServer/Tests/Playlists/PlaylistUploadTests.cs @@ -0,0 +1,262 @@ +using Refresh.Common.Constants; +using Refresh.Database; +using Refresh.Database.Models; +using Refresh.Database.Models.Authentication; +using Refresh.Database.Models.Playlists; +using Refresh.Database.Models.Users; +using Refresh.Interfaces.Game.Types.Playlists; +using RefreshTests.GameServer.Extensions; + +namespace RefreshTests.GameServer.Tests.Playlists; + +public class PlaylistUploadTests : GameServerTest +{ + private const string ValidIconGuid = "g18451"; // star sticker + private const string InvalidIconGuid = "g1087"; // sackboy model + private const string IconHash = "9488801db61c5313db3bb15db7d66fd26df7e789"; // hash of "TEX " + + [Test] + public void CreateAndUpdateLbp1Playlist() + { + using TestContext context = this.GetServer(); + GameUser user = context.CreateUser(); + + HttpClient client = context.GetAuthenticatedClient(TokenType.Game, TokenGame.LittleBigPlanet1, TokenPlatform.PS3, user); + + // create root playlist + SerializedLbp1Playlist request = new() + { + Name = "root", + Icon = ValidIconGuid, + Description = "DESCRIPTION", + Location = new GameLocation(), + }; + + HttpResponseMessage message = client.PostAsync("/lbp/createPlaylist", new StringContent(request.AsXML())).Result; + Assert.That(message.StatusCode, Is.EqualTo(OK)); + + SerializedLbp1Playlist rootResponse = message.Content.ReadAsXML(); + Assert.That(rootResponse.Name, Is.EqualTo("root")); + + // create actual playlist + request = new() + { + Name = "real", + Icon = ValidIconGuid, + Description = "DESCRIPTION", + Location = new GameLocation(), + }; + + message = client.PostAsync($"/lbp/createPlaylist?parent_id={rootResponse.Id}", new StringContent(request.AsXML())).Result; + Assert.That(message.StatusCode, Is.EqualTo(OK)); + + SerializedLbp1Playlist subResponse = message.Content.ReadAsXML(); + Assert.That(subResponse.Name, Is.EqualTo("real")); + + // Ensure the playlists are properly fetchable + GamePlaylist? root = context.Database.GetUserRootPlaylist(user); + Assert.That(root, Is.Not.Null); + Assert.That(root!.PlaylistId, Is.EqualTo(rootResponse.Id)); + Assert.That(root!.PublisherId, Is.EqualTo(user.UserId)); + + DatabaseList playlists = context.Database.GetPlaylistsByAuthor(user, 0, 10); + Assert.That(playlists.Items.Count, Is.EqualTo(1)); + + playlists = context.Database.GetPlaylistsInPlaylist(root, 0, 10); + Assert.That(playlists.Items.Count, Is.EqualTo(1)); + + // Now update + request = new() + { + Name = "legit", + Icon = ValidIconGuid, + Description = "DESCRIPTION", + Location = new GameLocation(), + }; + + message = client.PostAsync($"/lbp/setPlaylistMetaData/{subResponse.Id}", new StringContent(request.AsXML())).Result; + Assert.That(message.StatusCode, Is.EqualTo(OK)); + + context.Database.Refresh(); + + GamePlaylist? updated = context.Database.GetPlaylistById(subResponse.Id); + Assert.That(updated, Is.Not.Null); + Assert.That(updated!.Name, Is.EqualTo("legit")); + } + + [Test] + public void CreateAndUpdateLbp3Playlist() + { + using TestContext context = this.GetServer(); + GameUser user = context.CreateUser(); + + HttpClient client = context.GetAuthenticatedClient(TokenType.Game, TokenGame.LittleBigPlanet3, TokenPlatform.PS3, user); + + // create playlist + SerializedLbp3Playlist request = new() + { + Name = "real", + }; + + HttpResponseMessage message = client.PostAsync($"/lbp/playlists", new StringContent(request.AsXML())).Result; + Assert.That(message.StatusCode, Is.EqualTo(OK)); + + SerializedLbp3Playlist response = message.Content.ReadAsXML(); + Assert.That(response.Name, Is.EqualTo("real")); + Assert.That(response.Description, Is.EqualTo("")); + + // Ensure a root playlist was automatically created, and the actual playlist is in that root playlist + GamePlaylist? root = context.Database.GetUserRootPlaylist(user); + Assert.That(root, Is.Not.Null); + Assert.That(root!.PlaylistId, Is.Not.EqualTo(response.Id)); + Assert.That(root!.PublisherId, Is.EqualTo(user.UserId)); + + DatabaseList playlists = context.Database.GetPlaylistsByAuthor(user, 0, 10); + Assert.That(playlists.Items.Count, Is.EqualTo(1)); + + playlists = context.Database.GetPlaylistsInPlaylist(root, 0, 10); + Assert.That(playlists.Items.Count, Is.EqualTo(1)); + + // Now update + request = new() + { + Description = "DESCRIPTION", + }; + + message = client.PostAsync($"/lbp/playlists/{response.Id}", new StringContent(request.AsXML())).Result; + Assert.That(message.StatusCode, Is.EqualTo(OK)); + + response = message.Content.ReadAsXML(); + Assert.That(response.Name, Is.EqualTo("real")); + Assert.That(response.Description, Is.EqualTo("DESCRIPTION")); + } + + [Test] + public void TrimLbp1PlaylistStrings() + { + using TestContext context = this.GetServer(); + GameUser user = context.CreateUser(); + + HttpClient client = context.GetAuthenticatedClient(TokenType.Game, TokenGame.LittleBigPlanet1, TokenPlatform.PS3, user); + + // create root playlist + SerializedLbp1Playlist request = new() + { + Name = new string('*', UgcLimits.TitleLimit * 2), + Icon = ValidIconGuid, + Description = new string('*', UgcLimits.DescriptionLimit * 2), + Location = new GameLocation(), + }; + + HttpResponseMessage message = client.PostAsync("/lbp/createPlaylist", new StringContent(request.AsXML())).Result; + Assert.That(message.StatusCode, Is.EqualTo(OK)); + + SerializedLbp1Playlist response = message.Content.ReadAsXML(); + Assert.That(response.Name.Length, Is.EqualTo(UgcLimits.TitleLimit)); + Assert.That(response.Description.Length, Is.EqualTo(UgcLimits.DescriptionLimit)); + + // Now update + request = new() + { + Name = new string('d', UgcLimits.TitleLimit * 2), + Icon = ValidIconGuid, + Description = new string('d', UgcLimits.DescriptionLimit * 2), + Location = new GameLocation(), + }; + message = client.PostAsync($"/lbp/setPlaylistMetaData/{response.Id}", new StringContent(request.AsXML())).Result; + Assert.That(message.StatusCode, Is.EqualTo(OK)); + + GamePlaylist? updated = context.Database.GetPlaylistById(response.Id); + Assert.That(updated, Is.Not.Null); + Assert.That(updated!.Name.Length, Is.EqualTo(UgcLimits.TitleLimit)); + Assert.That(updated!.Description.Length, Is.EqualTo(UgcLimits.DescriptionLimit)); + } + + [Test] + public void TrimLbp3PlaylistStrings() + { + using TestContext context = this.GetServer(); + GameUser user = context.CreateUser(); + + HttpClient client = context.GetAuthenticatedClient(TokenType.Game, TokenGame.LittleBigPlanet3, TokenPlatform.PS3, user); + + // create root playlist + SerializedLbp3Playlist request = new() + { + Name = new string('*', UgcLimits.TitleLimit * 2), + Description = new string('*', UgcLimits.DescriptionLimit * 2), + }; + + HttpResponseMessage message = client.PostAsync("/lbp/playlists", new StringContent(request.AsXML())).Result; + Assert.That(message.StatusCode, Is.EqualTo(OK)); + + SerializedLbp3Playlist response = message.Content.ReadAsXML(); + Assert.That(response.Name, Is.Not.Null); + Assert.That(response.Description, Is.Not.Null); + Assert.That(response.Name!.Length, Is.EqualTo(UgcLimits.TitleLimit)); + Assert.That(response.Description!.Length, Is.EqualTo(UgcLimits.DescriptionLimit)); + + // Now update + request = new() + { + Name = new string('d', UgcLimits.TitleLimit * 2), + Description = new string('d', UgcLimits.DescriptionLimit * 2), + }; + message = client.PostAsync($"/lbp/playlists/{response.Id}", new StringContent(request.AsXML())).Result; + Assert.That(message.StatusCode, Is.EqualTo(OK)); + + context.Database.Refresh(); + + response = message.Content.ReadAsXML(); + Assert.That(response.Name, Is.Not.Null); + Assert.That(response.Description, Is.Not.Null); + Assert.That(response.Name!.Length, Is.EqualTo(UgcLimits.TitleLimit)); + Assert.That(response.Description!.Length, Is.EqualTo(UgcLimits.DescriptionLimit)); + } + + [Test] + [TestCase("", true, false)] + [TestCase("0", true, false)] + [TestCase(ValidIconGuid, true, false)] + [TestCase(InvalidIconGuid, false, false)] + [TestCase("hi", false, false)] + [TestCase(IconHash, false, false)] + [TestCase(IconHash, true, true)] + public void ValidatePlaylistIcon(string iconHash, bool isValid, bool uploadAsset) + { + using TestContext context = this.GetServer(); + GameUser user = context.CreateUser(); + + HttpClient client = context.GetAuthenticatedClient(TokenType.Game, TokenGame.LittleBigPlanet1, TokenPlatform.PS3, user); + + // If this is a server asset, upload it + if (uploadAsset) + { + HttpResponseMessage assetMessage = client.PostAsync($"/lbp/upload/{iconHash}", new ReadOnlyMemoryContent("TEX "u8.ToArray())).Result; + Assert.That(assetMessage.StatusCode, Is.EqualTo(OK)); + } + + // create playlist + SerializedLbp1Playlist request = new() + { + Name = "", + Icon = iconHash, + Description = "", + Location = new GameLocation(), + }; + + HttpResponseMessage message = client.PostAsync("/lbp/createPlaylist", new StringContent(request.AsXML())).Result; + Assert.That(message.StatusCode, Is.EqualTo(OK)); + + SerializedLbp1Playlist response = message.Content.ReadAsXML(); + Assert.That(response.Icon, Is.EqualTo(isValid ? iconHash : "0")); + + // Now update + message = client.PostAsync($"/lbp/setPlaylistMetaData/{response.Id}", new StringContent(request.AsXML())).Result; + Assert.That(message.StatusCode, Is.EqualTo(OK)); + + GamePlaylist? updated = context.Database.GetPlaylistById(response.Id); + Assert.That(updated, Is.Not.Null); + Assert.That(updated!.IconHash, Is.EqualTo(isValid ? iconHash : "0")); + } +} \ No newline at end of file