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