diff --git a/Refresh.Core/Configuration/EntityUploadRateLimitProperties.cs b/Refresh.Core/Configuration/EntityUploadRateLimitProperties.cs new file mode 100644 index 000000000..2a9577f56 --- /dev/null +++ b/Refresh.Core/Configuration/EntityUploadRateLimitProperties.cs @@ -0,0 +1,19 @@ +namespace Refresh.Core.Configuration; + +public class EntityUploadRateLimitProperties +{ + public EntityUploadRateLimitProperties() {} + + /// + /// Whether to rate-limit uploads of a certain entity (level/photo/playlist) using the database + /// + public bool Enabled { get; set; } + /// + /// The duration of this rate-limit + /// + public int TimeSpanHours { get; set; } + /// + /// The amount of entities the user is allowed to upload during the specified time span + /// + public int UploadQuota { get; set; } +} \ No newline at end of file diff --git a/Refresh.Core/Configuration/GameServerConfig.cs b/Refresh.Core/Configuration/GameServerConfig.cs index fab3d295b..573d6aa1b 100644 --- a/Refresh.Core/Configuration/GameServerConfig.cs +++ b/Refresh.Core/Configuration/GameServerConfig.cs @@ -8,7 +8,7 @@ namespace Refresh.Core.Configuration; [SuppressMessage("ReSharper", "RedundantDefaultMemberInitializer")] public class GameServerConfig : Config { - public override int CurrentConfigVersion => 27; + public override int CurrentConfigVersion => 28; public override int Version { get; set; } = 0; protected override void Migrate(int oldVer, dynamic oldConfig) @@ -82,13 +82,13 @@ protected override void Migrate(int oldVer, dynamic oldConfig) // Timed level upload limits were added in version 19. if (oldVer >= 19) { - this.NormalUserPermissions.TimedLevelUploadLimits.Enabled = (bool)oldConfig.TimedLevelUploadLimits.Enabled; - this.NormalUserPermissions.TimedLevelUploadLimits.TimeSpanHours = (int)oldConfig.TimedLevelUploadLimits.TimeSpanHours; - this.NormalUserPermissions.TimedLevelUploadLimits.LevelQuota = (int)oldConfig.TimedLevelUploadLimits.LevelQuota; + this.NormalUserPermissions.LevelUploadRateLimit.Enabled = (bool)oldConfig.TimedLevelUploadLimits.Enabled; + this.NormalUserPermissions.LevelUploadRateLimit.TimeSpanHours = (int)oldConfig.TimedLevelUploadLimits.TimeSpanHours; + this.NormalUserPermissions.LevelUploadRateLimit.UploadQuota = (int)oldConfig.TimedLevelUploadLimits.LevelQuota; - this.TrustedUserPermissions.TimedLevelUploadLimits.Enabled = (bool)oldConfig.TimedLevelUploadLimits.Enabled; - this.TrustedUserPermissions.TimedLevelUploadLimits.TimeSpanHours = (int)oldConfig.TimedLevelUploadLimits.TimeSpanHours; - this.TrustedUserPermissions.TimedLevelUploadLimits.LevelQuota = (int)oldConfig.TimedLevelUploadLimits.LevelQuota; + this.TrustedUserPermissions.LevelUploadRateLimit.Enabled = (bool)oldConfig.TimedLevelUploadLimits.Enabled; + this.TrustedUserPermissions.LevelUploadRateLimit.TimeSpanHours = (int)oldConfig.TimedLevelUploadLimits.TimeSpanHours; + this.TrustedUserPermissions.LevelUploadRateLimit.UploadQuota = (int)oldConfig.TimedLevelUploadLimits.LevelQuota; } // Read-only mode was added for both normal and trusted users in version 20. @@ -98,6 +98,20 @@ protected override void Migrate(int oldVer, dynamic oldConfig) this.TrustedUserPermissions.ReadOnlyMode = (bool)oldConfig.ReadonlyModeForTrustedUsers; } } + + // In version 28, PhotoUploadRateLimit and PlaylistUploadRateLimit were added to RolePermissions, and various renamings related to level upload rate-limiting + // were done to prepare for this: the class TimedLevelUploadLimitProperties was renamed to EntityUploadRateLimitProperties, its attribute LevelQuota was renamed to UploadQuota, + // and RolePermissions' attribute TimedLevelUploadLimits was renamed to LevelUploadRateLimit + else if (oldVer == 27) + { + this.NormalUserPermissions.LevelUploadRateLimit.Enabled = (bool)oldConfig.NormalUserPermissions.TimedLevelUploadLimits.Enabled; + this.NormalUserPermissions.LevelUploadRateLimit.TimeSpanHours = (int)oldConfig.NormalUserPermissions.TimedLevelUploadLimits.TimeSpanHours; + this.NormalUserPermissions.LevelUploadRateLimit.UploadQuota = (int)oldConfig.NormalUserPermissions.TimedLevelUploadLimits.LevelQuota; + + this.TrustedUserPermissions.LevelUploadRateLimit.Enabled = (bool)oldConfig.TrustedUserPermissions.TimedLevelUploadLimits.Enabled; + this.TrustedUserPermissions.LevelUploadRateLimit.TimeSpanHours = (int)oldConfig.TrustedUserPermissions.TimedLevelUploadLimits.TimeSpanHours; + this.TrustedUserPermissions.LevelUploadRateLimit.UploadQuota = (int)oldConfig.TrustedUserPermissions.TimedLevelUploadLimits.LevelQuota; + } } public string LicenseText { get; set; } = "Welcome to Refresh!"; diff --git a/Refresh.Core/Configuration/RolePermissions.cs b/Refresh.Core/Configuration/RolePermissions.cs index e1cc44e59..f381c60fc 100644 --- a/Refresh.Core/Configuration/RolePermissions.cs +++ b/Refresh.Core/Configuration/RolePermissions.cs @@ -8,11 +8,25 @@ public RolePermissions() {} public bool ReadOnlyMode { get; set; } = false; public ConfigAssetFlags BlockedAssetFlags { get; set; } = new(AssetFlags.Dangerous | AssetFlags.Modded); - public TimedLevelUploadLimitProperties TimedLevelUploadLimits = new() + public EntityUploadRateLimitProperties LevelUploadRateLimit = new() { Enabled = false, TimeSpanHours = 24, - LevelQuota = 10, + UploadQuota = 10, + }; + + public EntityUploadRateLimitProperties PhotoUploadRateLimit = new() + { + Enabled = false, + TimeSpanHours = 24, + UploadQuota = 10, + }; + + public EntityUploadRateLimitProperties PlaylistUploadRateLimit = new() + { + Enabled = false, + TimeSpanHours = 24, + UploadQuota = 8, }; /// diff --git a/Refresh.Core/Configuration/TimedLevelUploadLimitProperties.cs b/Refresh.Core/Configuration/TimedLevelUploadLimitProperties.cs deleted file mode 100644 index 46eba081c..000000000 --- a/Refresh.Core/Configuration/TimedLevelUploadLimitProperties.cs +++ /dev/null @@ -1,19 +0,0 @@ -namespace Refresh.Core.Configuration; - -public class TimedLevelUploadLimitProperties -{ - public TimedLevelUploadLimitProperties() {} - - /// - /// Whether to enable the timed level uploading limits - /// - public bool Enabled { get; set; } - /// - /// The amount of time until level upload counts are reset in hours - /// - public int TimeSpanHours { get; set; } - /// - /// The amount of levels the user is allowed to upload during the configured time span before level uploads are blocked - /// - public int LevelQuota { get; set; } -} \ No newline at end of file diff --git a/Refresh.Core/RateLimits/Playlists/PlaylistCreationEndpointLimits.cs b/Refresh.Core/RateLimits/Playlists/PlaylistCreationEndpointLimits.cs index 78b819223..405b55cd2 100644 --- a/Refresh.Core/RateLimits/Playlists/PlaylistCreationEndpointLimits.cs +++ b/Refresh.Core/RateLimits/Playlists/PlaylistCreationEndpointLimits.cs @@ -5,10 +5,10 @@ namespace Refresh.Core.RateLimits.Playlists; /// public static class PlaylistCreationEndpointLimits { - public const int UploadTimeoutDuration = 450; - public const int MaxCreateAmount = 8; // should be enough - public const int MaxUpdateAmount = 12; - public const int UploadBlockDuration = 300; + public const int UploadTimeoutDuration = 300; + public const int MaxCreateAmount = 30; + public const int MaxUpdateAmount = 50; + public const int UploadBlockDuration = 240; public const string CreateBucket = "playlist-create"; public const string UpdateBucket = "playlist-update"; } \ No newline at end of file diff --git a/Refresh.Database/GameDatabaseContext.Users.cs b/Refresh.Database/GameDatabaseContext.Users.cs index 92b0b27ca..40832aa5a 100644 --- a/Refresh.Database/GameDatabaseContext.Users.cs +++ b/Refresh.Database/GameDatabaseContext.Users.cs @@ -10,6 +10,7 @@ using Refresh.Database.Models.Photos; using Refresh.Database.Models.Assets; using System.Diagnostics; +using Refresh.Database.Models; namespace Refresh.Database; @@ -620,7 +621,6 @@ public void MarkAllReuploads(GameUser user) }); } - public void SetUserPresenceAuthToken(GameUser user, string? token) { this.Write(() => @@ -628,23 +628,65 @@ public void SetUserPresenceAuthToken(GameUser user, string? token) user.PresenceServerAuthToken = token; }); } - - public void IncrementTimedLevelLimit(GameUser user, int hours) + + public EntityUploadRateLimit? GetUploadRateLimit(GameUser user, GameDatabaseEntity entity, bool save = true) { - this.Write(() => + EntityUploadRateLimit? limit = this.EntityUploadRateLimits.FirstOrDefault(r => r.UserId == user.UserId && r.Entity == entity); + + // remove if expired + if (limit != null && limit.ExpiryDate <= this._time.Now) { - // Set expiry date if the timed limits have been reset previously - user.TimedLevelUploadExpiryDate ??= this._time.Now + TimeSpan.FromHours(hours); - user.TimedLevelUploads++; - }); + this.EntityUploadRateLimits.Remove(limit); + if (save) this.SaveChanges(); + return null; + } + + return limit; } - public void ResetTimedLevelLimit(GameUser user) + /// + /// Time until expiry date if the corresponding upload rate-limit has been reached, otherwise null + /// + public TimeSpan? GetRemainingTimeIfUploadRateLimitReached(GameUser user, GameDatabaseEntity entity, int uploadQuota) { - this.Write(() => + EntityUploadRateLimit? limit = this.GetUploadRateLimit(user, entity); // will be null if expired already, see above + DateTimeOffset now = this._time.Now; + + if (limit != null && limit.UploadCount >= uploadQuota) { - user.TimedLevelUploadExpiryDate = null; - user.TimedLevelUploads = 0; - }); + return limit.ExpiryDate - now; + } + + return null; + } + + public void IncrementUploadRateLimitForEntity(GameUser user, GameDatabaseEntity entity, int timeSpanHours) + { + EntityUploadRateLimit? existingLimit = this.GetUploadRateLimit(user, entity, false); // will be null if expired already, see above + DateTimeOffset now = this._time.Now; + + if (existingLimit == null) + { + EntityUploadRateLimit newLimit = new() + { + Entity = entity, + User = user, + UploadCount = 1, + ExpiryDate = now + TimeSpan.FromHours(timeSpanHours), + }; + this.EntityUploadRateLimits.Add(newLimit); + } + else + { + this.EntityUploadRateLimits.Update(existingLimit); + existingLimit.UploadCount++; + } + + this.SaveChanges(); + } + + public void ResetUploadRateLimit(GameUser user, GameDatabaseEntity entity) + { + this.EntityUploadRateLimits.RemoveRange(r => r.UserId == user.UserId && r.Entity == entity); } } diff --git a/Refresh.Database/GameDatabaseContext.cs b/Refresh.Database/GameDatabaseContext.cs index 23c696def..c8c5e69b9 100644 --- a/Refresh.Database/GameDatabaseContext.cs +++ b/Refresh.Database/GameDatabaseContext.cs @@ -86,6 +86,7 @@ public partial class GameDatabaseContext : DbContext, IDatabaseContext internal DbSet JobStates { get; set; } internal DbSet GameLevelRevisions { get; set; } internal DbSet ModerationActions { get; set; } + internal DbSet EntityUploadRateLimits { get; set; } #pragma warning disable CS8618 // Non-nullable variable must contain a non-null value when exiting constructor. Consider declaring it as nullable. internal GameDatabaseContext(Logger logger, IDateTimeProvider time, IDatabaseConfig dbConfig) diff --git a/Refresh.Database/Migrations/20260421181503_SplitUploadRateLimitsToSeparateTable.cs b/Refresh.Database/Migrations/20260421181503_SplitUploadRateLimitsToSeparateTable.cs new file mode 100644 index 000000000..3f5e70d77 --- /dev/null +++ b/Refresh.Database/Migrations/20260421181503_SplitUploadRateLimitsToSeparateTable.cs @@ -0,0 +1,67 @@ +using System; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Refresh.Database.Migrations +{ + /// + [DbContext(typeof(GameDatabaseContext))] + [Migration("20260421181503_SplitUploadRateLimitsToSeparateTable")] + public partial class SplitUploadRateLimitsToSeparateTable : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + // probably no need to migrate these, they're temporary anyway + migrationBuilder.DropColumn( + name: "TimedLevelUploadExpiryDate", + table: "GameUsers"); + + migrationBuilder.DropColumn( + name: "TimedLevelUploads", + table: "GameUsers"); + + migrationBuilder.CreateTable( + name: "EntityUploadRateLimits", + columns: table => new + { + Entity = table.Column(type: "smallint", nullable: false), + UserId = table.Column(type: "text", nullable: false), + UploadCount = table.Column(type: "integer", nullable: false), + ExpiryDate = table.Column(type: "timestamp with time zone", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_EntityUploadRateLimits", x => new { x.UserId, x.Entity }); + table.ForeignKey( + name: "FK_EntityUploadRateLimits_GameUsers_UserId", + column: x => x.UserId, + principalTable: "GameUsers", + principalColumn: "UserId", + onDelete: ReferentialAction.Cascade); + }); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "EntityUploadRateLimits"); + + migrationBuilder.AddColumn( + name: "TimedLevelUploadExpiryDate", + table: "GameUsers", + type: "timestamp with time zone", + nullable: true); + + migrationBuilder.AddColumn( + name: "TimedLevelUploads", + table: "GameUsers", + type: "integer", + nullable: false, + defaultValue: 0); + } + } +} diff --git a/Refresh.Database/Migrations/GameDatabaseContextModelSnapshot.cs b/Refresh.Database/Migrations/GameDatabaseContextModelSnapshot.cs index a1ea66654..b04f829c6 100644 --- a/Refresh.Database/Migrations/GameDatabaseContextModelSnapshot.cs +++ b/Refresh.Database/Migrations/GameDatabaseContextModelSnapshot.cs @@ -1594,6 +1594,25 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.ToTable("EmailVerificationCodes"); }); + modelBuilder.Entity("Refresh.Database.Models.Users.EntityUploadRateLimit", b => + { + b.Property("UserId") + .HasColumnType("text"); + + b.Property("Entity") + .HasColumnType("smallint"); + + b.Property("UploadCount") + .HasColumnType("integer"); + + b.Property("ExpiryDate") + .HasColumnType("timestamp with time zone"); + + b.HasKey("UserId", "Entity"); + + b.ToTable("EntityUploadRateLimits"); + }); + modelBuilder.Entity("Refresh.Database.Models.Users.GameIpVerificationRequest", b => { b.Property("UserId") @@ -1735,12 +1754,6 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Property("StatisticsUserId") .HasColumnType("text"); - b.Property("TimedLevelUploadExpiryDate") - .HasColumnType("timestamp with time zone"); - - b.Property("TimedLevelUploads") - .HasColumnType("integer"); - b.Property("UnescapeXmlSequences") .HasColumnType("boolean"); @@ -2521,6 +2534,17 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Navigation("User"); }); + modelBuilder.Entity("Refresh.Database.Models.Users.EntityUploadRateLimit", b => + { + b.HasOne("Refresh.Database.Models.Users.GameUser", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + modelBuilder.Entity("Refresh.Database.Models.Users.GameIpVerificationRequest", b => { b.HasOne("Refresh.Database.Models.Users.GameUser", "User") diff --git a/Refresh.Database/Models/GameDatabaseEntity.cs b/Refresh.Database/Models/GameDatabaseEntity.cs new file mode 100644 index 000000000..29a6491cb --- /dev/null +++ b/Refresh.Database/Models/GameDatabaseEntity.cs @@ -0,0 +1,17 @@ +namespace Refresh.Database.Models; + +public enum GameDatabaseEntity : byte +{ + User, + Level, + Score, + RateLevelRelation, + Photo, + Review, + LevelComment, + UserComment, + Playlist, + Asset, + Challenge, + ChallengeScore, +} \ No newline at end of file diff --git a/Refresh.Database/Models/Users/EntityUploadRateLimit.cs b/Refresh.Database/Models/Users/EntityUploadRateLimit.cs new file mode 100644 index 000000000..4812f2c78 --- /dev/null +++ b/Refresh.Database/Models/Users/EntityUploadRateLimit.cs @@ -0,0 +1,26 @@ +using MongoDB.Bson; + +namespace Refresh.Database.Models.Users; + +#nullable disable + +[PrimaryKey(nameof(UserId), nameof(Entity))] +public partial class EntityUploadRateLimit +{ + public GameDatabaseEntity Entity { get; set; } + + [Required] + public ObjectId UserId { get; set; } + + [Required, ForeignKey(nameof(UserId))] + public GameUser User { get; set; } + + /// + /// How many entites of the specified type the user has uploaded during the rate-limit + /// + public int UploadCount { get; set; } + /// + /// When this rate-limit should be expired + /// + public DateTimeOffset ExpiryDate { get; set; } +} \ No newline at end of file diff --git a/Refresh.Database/Models/Users/GameUser.cs b/Refresh.Database/Models/Users/GameUser.cs index cb4263812..585a1cea4 100644 --- a/Refresh.Database/Models/Users/GameUser.cs +++ b/Refresh.Database/Models/Users/GameUser.cs @@ -50,21 +50,6 @@ public partial class GameUser : IRateLimitUser /// public int FilesizeQuotaUsage { get; set; } - #region Timed Level Limit - - /// - /// How many levels the user published/overwrote during the configured timed level limit, - /// if enabled. - /// - public int TimedLevelUploads { get; set; } - /// - /// The timestamp when this user's timed level limit will reset. - /// When that happens, set this property to null and reset TimedLevelUploads to 0. - /// - public DateTimeOffset? TimedLevelUploadExpiryDate { get; set; } - - #endregion - public string Description { get; set; } = ""; public int LocationX { get; set; } diff --git a/Refresh.Interfaces.APIv3/Endpoints/DataTypes/Response/ApiEntityRateLimitResponse.cs b/Refresh.Interfaces.APIv3/Endpoints/DataTypes/Response/ApiEntityRateLimitResponse.cs new file mode 100644 index 000000000..fbc20b76c --- /dev/null +++ b/Refresh.Interfaces.APIv3/Endpoints/DataTypes/Response/ApiEntityRateLimitResponse.cs @@ -0,0 +1,21 @@ +using Refresh.Core.Configuration; + +namespace Refresh.Interfaces.APIv3.Endpoints.DataTypes.Response; + +[JsonObject(NamingStrategyType = typeof(CamelCaseNamingStrategy))] +public class ApiEntityRateLimitResponse : IApiResponse +{ + public required int TimeSpanHours { get; set; } + public required int UploadQuota { get; set; } + + public static ApiEntityRateLimitResponse? FromOld(EntityUploadRateLimitProperties old) + { + if (!old.Enabled) return null; + + return new() + { + TimeSpanHours = old.TimeSpanHours, + UploadQuota = old.UploadQuota, + }; + } +} \ No newline at end of file diff --git a/Refresh.Interfaces.APIv3/Endpoints/DataTypes/Response/ApiRolePermissionsResponse.cs b/Refresh.Interfaces.APIv3/Endpoints/DataTypes/Response/ApiRolePermissionsResponse.cs index da60ec658..04b773967 100644 --- a/Refresh.Interfaces.APIv3/Endpoints/DataTypes/Response/ApiRolePermissionsResponse.cs +++ b/Refresh.Interfaces.APIv3/Endpoints/DataTypes/Response/ApiRolePermissionsResponse.cs @@ -7,7 +7,9 @@ public class ApiRolePermissionsResponse : IApiResponse { public required ConfigAssetFlags BlockedAssetFlags { get; set; } public required bool ReadOnlyMode { get; set; } - public required ApiTimedLevelLimitResponse? TimedLevelUploadLimits { get; set; } + public required ApiEntityRateLimitResponse? LevelUploadRateLimit { get; set; } + public required ApiEntityRateLimitResponse? PhotoUploadRateLimit { get; set; } + public required ApiEntityRateLimitResponse? PlaylistUploadRateLimit { get; set; } public required int UserFilesizeQuota { get; set; } public static ApiRolePermissionsResponse FromOld(RolePermissions old) @@ -16,11 +18,9 @@ public static ApiRolePermissionsResponse FromOld(RolePermissions old) { BlockedAssetFlags = old.BlockedAssetFlags, ReadOnlyMode = old.ReadOnlyMode, - TimedLevelUploadLimits = old.TimedLevelUploadLimits.Enabled ? new ApiTimedLevelLimitResponse() - { - TimeSpanHours = old.TimedLevelUploadLimits.TimeSpanHours, - LevelQuota = old.TimedLevelUploadLimits.LevelQuota, - } : null, + LevelUploadRateLimit = ApiEntityRateLimitResponse.FromOld(old.LevelUploadRateLimit), + PhotoUploadRateLimit = ApiEntityRateLimitResponse.FromOld(old.PhotoUploadRateLimit), + PlaylistUploadRateLimit = ApiEntityRateLimitResponse.FromOld(old.PlaylistUploadRateLimit), UserFilesizeQuota = old.UserFilesizeQuota, }; } diff --git a/Refresh.Interfaces.APIv3/Endpoints/DataTypes/Response/ApiTimedLevelLimitResponse.cs b/Refresh.Interfaces.APIv3/Endpoints/DataTypes/Response/ApiTimedLevelLimitResponse.cs deleted file mode 100644 index f9e2e2b52..000000000 --- a/Refresh.Interfaces.APIv3/Endpoints/DataTypes/Response/ApiTimedLevelLimitResponse.cs +++ /dev/null @@ -1,8 +0,0 @@ -namespace Refresh.Interfaces.APIv3.Endpoints.DataTypes.Response; - -[JsonObject(NamingStrategyType = typeof(CamelCaseNamingStrategy))] -public class ApiTimedLevelLimitResponse : IApiResponse -{ - public required int TimeSpanHours { get; set; } - public required int LevelQuota { get; set; } -} \ No newline at end of file diff --git a/Refresh.Interfaces.APIv3/Endpoints/PlaylistApiEndpoints.cs b/Refresh.Interfaces.APIv3/Endpoints/PlaylistApiEndpoints.cs index d5e2637b4..901f5505b 100644 --- a/Refresh.Interfaces.APIv3/Endpoints/PlaylistApiEndpoints.cs +++ b/Refresh.Interfaces.APIv3/Endpoints/PlaylistApiEndpoints.cs @@ -15,6 +15,7 @@ using Refresh.Interfaces.APIv3.Extensions; using Refresh.Core.RateLimits.Playlists; using Refresh.Core.Configuration; +using Refresh.Database.Models; namespace Refresh.Interfaces.APIv3.Endpoints; @@ -59,6 +60,20 @@ public ApiResponse CreatePlaylist(RequestContext contex { if (user.IsWriteBlocked(config)) return ApiAuthenticationError.ReadOnlyError; + + EntityUploadRateLimitProperties uploadLimit = user.GetRolePermissionsForUser(config).PlaylistUploadRateLimit; + if (uploadLimit.Enabled) + { + TimeSpan? rateLimitExpiresIn = dataContext.Database.GetRemainingTimeIfUploadRateLimitReached(user, GameDatabaseEntity.Playlist, uploadLimit.UploadQuota); + if (rateLimitExpiresIn != null) + { + return new ApiValidationError + ( + $"You have created too many playlists recently! Your limit is {uploadLimit.UploadQuota} playlists per {uploadLimit.TimeSpanHours} hours. "+ + $"Try again in {rateLimitExpiresIn.Value.Hours} hours and {rateLimitExpiresIn.Value.Minutes} minutes." + ); + } + } ApiError? error = this.ValidatePlaylist(body, dataContext); if (error != null) return error; @@ -85,6 +100,10 @@ public ApiResponse CreatePlaylist(RequestContext contex } GamePlaylist playlist = dataContext.Database.CreatePlaylist(user, body, false); + if (uploadLimit.Enabled) + { + dataContext.Database.IncrementUploadRateLimitForEntity(user, GameDatabaseEntity.Playlist, uploadLimit.TimeSpanHours); + } dataContext.Database.AddPlaylistToPlaylist(playlist, parent); return ApiGamePlaylistResponse.FromOld(playlist, dataContext); diff --git a/Refresh.Interfaces.Game/Endpoints/Levels/PublishEndpoints.cs b/Refresh.Interfaces.Game/Endpoints/Levels/PublishEndpoints.cs index 2d12ffa50..9fbfc2765 100644 --- a/Refresh.Interfaces.Game/Endpoints/Levels/PublishEndpoints.cs +++ b/Refresh.Interfaces.Game/Endpoints/Levels/PublishEndpoints.cs @@ -12,6 +12,7 @@ using Refresh.Core.Configuration; using Refresh.Core.Types.Data; using Refresh.Database; +using Refresh.Database.Models; using Refresh.Database.Models.Assets; using Refresh.Database.Models.Authentication; using Refresh.Database.Models.Levels; @@ -78,38 +79,24 @@ private static bool VerifyLevel(GameLevelRequest body, DataContext dataContext) return true; } - private static bool IsTimedLevelLimitReached(DataContext dataContext, GameUser user, string levelTitle, TimedLevelUploadLimitProperties config, DateTimeOffset now) + private static bool IsTimedLevelLimitReached(DataContext dataContext, GameUser user, string levelTitle, EntityUploadRateLimitProperties levelLimit) { - if (!config.Enabled || user.TimedLevelUploads <= 0 || user.TimedLevelUploadExpiryDate == null) - { - return false; - } + if (!levelLimit.Enabled) return false; - DateTimeOffset expiryDate = user.TimedLevelUploadExpiryDate.Value; - - // If the expiration date has expired (less than now), reset user's limit and continue. - if (now >= expiryDate) - { - dataContext.Database.ResetTimedLevelLimit(user); - return false; - } - // If expiration date has not expired yet and the user has reached the limit, block. - else if (user.TimedLevelUploads >= config.LevelQuota) + TimeSpan? rateLimitExpiresIn = dataContext.Database.GetRemainingTimeIfUploadRateLimitReached(user, GameDatabaseEntity.Level, levelLimit.UploadQuota); + if (rateLimitExpiresIn != null) { - TimeSpan remainingTime = expiryDate - now; dataContext.Database.AddPublishFailNotification ( - $"You have reached the timed level upload limit of {config.LevelQuota} levels per {config.TimeSpanHours} hours. " + - $"Your limit will expire in around {remainingTime.Hours} hours and {remainingTime.Minutes} minutes. After that, try publishing your level again!", - levelTitle, + $"You have published too many levels recently! Your limit is {levelLimit.UploadQuota} levels per {levelLimit.TimeSpanHours} hours. " + + $"Try again in {rateLimitExpiresIn.Value.Hours} hours and {rateLimitExpiresIn.Value.Minutes} minutes.", + levelTitle, user ); return true; } - else - { - return false; - } + + return false; } [GameEndpoint("startPublish", ContentType.Xml, HttpMethods.Post)] @@ -119,7 +106,6 @@ public Response StartPublish(RequestContext context, GameLevelRequest body, DataContext dataContext, GameServerConfig config, - IDateTimeProvider dateTimeProvider, GameUser user) { if (dataContext.User!.IsWriteBlocked(config)) @@ -128,7 +114,7 @@ public Response StartPublish(RequestContext context, return Unauthorized; } - if (IsTimedLevelLimitReached(dataContext, dataContext.User!, body.Title, user.GetRolePermissionsForUser(config).TimedLevelUploadLimits, dateTimeProvider.Now)) + if (IsTimedLevelLimitReached(dataContext, dataContext.User!, body.Title, user.GetRolePermissionsForUser(config).LevelUploadRateLimit)) return Unauthorized; //If verifying the request fails, return BadRequest @@ -177,14 +163,13 @@ public Response PublishLevel(RequestContext context, GameLevelRequest body, DataContext dataContext, GameUser user, - GameServerConfig config, - IDateTimeProvider dateTimeProvider) + GameServerConfig config) { if (user.IsWriteBlocked(config)) return Unauthorized; - TimedLevelUploadLimitProperties timedLevelLimit = user.GetRolePermissionsForUser(config).TimedLevelUploadLimits; - if (IsTimedLevelLimitReached(dataContext, user, body.Title, timedLevelLimit, dateTimeProvider.Now)) + EntityUploadRateLimitProperties timedLevelLimit = user.GetRolePermissionsForUser(config).LevelUploadRateLimit; + if (IsTimedLevelLimitReached(dataContext, user, body.Title, timedLevelLimit)) return Unauthorized; //If verifying the request fails, return BadRequest @@ -260,7 +245,7 @@ public Response PublishLevel(RequestContext context, // don't want to increment for failed uploads if (timedLevelLimit.Enabled) { - dataContext.Database.IncrementTimedLevelLimit(user, timedLevelLimit.TimeSpanHours); + dataContext.Database.IncrementUploadRateLimitForEntity(user, GameDatabaseEntity.Level, timedLevelLimit.TimeSpanHours); } // Update the modded status of the level diff --git a/Refresh.Interfaces.Game/Endpoints/PhotoEndpoints.cs b/Refresh.Interfaces.Game/Endpoints/PhotoEndpoints.cs index 24bedbac2..144d2b825 100644 --- a/Refresh.Interfaces.Game/Endpoints/PhotoEndpoints.cs +++ b/Refresh.Interfaces.Game/Endpoints/PhotoEndpoints.cs @@ -11,6 +11,7 @@ using Refresh.Core.Services; using Refresh.Core.Types.Data; using Refresh.Database; +using Refresh.Database.Models; using Refresh.Database.Models.Assets; using Refresh.Database.Models.Levels; using Refresh.Database.Models.Photos; @@ -23,7 +24,7 @@ public class PhotoEndpoints : EndpointGroup { [GameEndpoint("uploadPhoto", HttpMethods.Post, ContentType.Xml)] [RequireEmailVerified] - [RateLimitSettings(500, 12, 400, "upload-photo")] + [RateLimitSettings(300, 30, 240, "upload-photo")] public Response UploadPhoto(RequestContext context, SerializedPhoto body, GameDatabaseContext database, GameUser user, IDataStore dataStore, DataContext dataContext, AipiService aipi, GameServerConfig config) @@ -31,6 +32,23 @@ public Response UploadPhoto(RequestContext context, SerializedPhoto body, GameDa if (user.IsWriteBlocked(config)) return Unauthorized; + EntityUploadRateLimitProperties uploadLimit = user.GetRolePermissionsForUser(config).PhotoUploadRateLimit; + if (uploadLimit.Enabled) + { + TimeSpan? rateLimitExpiresIn = database.GetRemainingTimeIfUploadRateLimitReached(user, GameDatabaseEntity.Photo, uploadLimit.UploadQuota); + if (rateLimitExpiresIn != null) + { + dataContext.Database.AddErrorNotification + ( + "Photo upload failed", + $"You have uploaded too many photos recently! Your limit is {uploadLimit.UploadQuota} photos per {uploadLimit.TimeSpanHours} hours. " + + $"Try again in {rateLimitExpiresIn.Value.Hours} hours and {rateLimitExpiresIn.Value.Minutes} minutes.", + user + ); + return Unauthorized; + } + } + if (!dataStore.ExistsInStore(body.SmallHash) || !dataStore.ExistsInStore(body.MediumHash) || !dataStore.ExistsInStore(body.LargeHash) || @@ -75,6 +93,11 @@ public Response UploadPhoto(RequestContext context, SerializedPhoto body, GameDa database.UploadPhoto(body, body.PhotoSubjects, user, level); if (level != null) dataContext.Cache.IncrementLevelPhotosByUser(user, level, 1, database); + + if (uploadLimit.Enabled) + { + database.IncrementUploadRateLimitForEntity(user, GameDatabaseEntity.Photo, uploadLimit.TimeSpanHours); + } return OK; } diff --git a/Refresh.Interfaces.Game/Endpoints/Playlists/Lbp1PlaylistEndpoints.cs b/Refresh.Interfaces.Game/Endpoints/Playlists/Lbp1PlaylistEndpoints.cs index a844fdb42..3f3532fb3 100644 --- a/Refresh.Interfaces.Game/Endpoints/Playlists/Lbp1PlaylistEndpoints.cs +++ b/Refresh.Interfaces.Game/Endpoints/Playlists/Lbp1PlaylistEndpoints.cs @@ -11,6 +11,7 @@ using Refresh.Core.RateLimits.Playlists; using Refresh.Core.Types.Data; using Refresh.Database; +using Refresh.Database.Models; using Refresh.Database.Models.Levels; using Refresh.Database.Models.Playlists; using Refresh.Database.Models.Users; @@ -61,10 +62,23 @@ public Response CreatePlaylist(RequestContext context, DataContext dataContext, GamePlaylist? parent = null; GamePlaylist? rootPlaylist = dataContext.Database.GetUserRootPlaylist(user); + EntityUploadRateLimitProperties uploadLimit = user.GetRolePermissionsForUser(config).PlaylistUploadRateLimit; // Don't block root playlist creation, as the game will otherwise spam requests and softlock - if (user.IsWriteBlocked(config) && rootPlaylist != null) - return Unauthorized; + if (rootPlaylist != null) + { + if (user.IsWriteBlocked(config)) return Unauthorized; + + if (uploadLimit.Enabled) + { + TimeSpan? rateLimitExpiresIn = dataContext.Database.GetRemainingTimeIfUploadRateLimitReached(user, GameDatabaseEntity.Playlist, uploadLimit.UploadQuota); + if (rateLimitExpiresIn != null) + { + // no need for a notification, because playlists are cheap and i don't really want the game's obscure spam bugs to cause these notifs to be spammed + return Unauthorized; + } + } + } // If the parent ID is specified, try to parse that out if (int.TryParse(context.QueryString["parent_id"], out int parentId)) @@ -97,6 +111,11 @@ public Response CreatePlaylist(RequestContext context, DataContext 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); + if (rootPlaylist != null && uploadLimit.Enabled) + { + dataContext.Database.IncrementUploadRateLimitForEntity(user, GameDatabaseEntity.Playlist, uploadLimit.TimeSpanHours); + } + // If there is a parent, add the new playlist to the parent if (parent != null) dataContext.Database.AddPlaylistToPlaylist(playlist, parent); diff --git a/Refresh.Interfaces.Game/Endpoints/Playlists/Lbp3PlaylistEndpoints.cs b/Refresh.Interfaces.Game/Endpoints/Playlists/Lbp3PlaylistEndpoints.cs index 9a93d8f73..31bd16442 100644 --- a/Refresh.Interfaces.Game/Endpoints/Playlists/Lbp3PlaylistEndpoints.cs +++ b/Refresh.Interfaces.Game/Endpoints/Playlists/Lbp3PlaylistEndpoints.cs @@ -17,6 +17,7 @@ using Refresh.Interfaces.Game.Types.Playlists; using Refresh.Core.RateLimits.Playlists; using Refresh.Core.RateLimits.Relations; +using Refresh.Database.Models; namespace Refresh.Interfaces.Game.Endpoints.Playlists; @@ -30,6 +31,17 @@ public Response CreatePlaylist(RequestContext context, GameServerConfig config, { if (user.IsWriteBlocked(config)) return Unauthorized; + + EntityUploadRateLimitProperties uploadLimit = user.GetRolePermissionsForUser(config).PlaylistUploadRateLimit; + if (uploadLimit.Enabled) + { + TimeSpan? rateLimitExpiresIn = dataContext.Database.GetRemainingTimeIfUploadRateLimitReached(user, GameDatabaseEntity.Playlist, uploadLimit.UploadQuota); + if (rateLimitExpiresIn != null) + { + // no need for a notification, because playlists are cheap, especially in lbp3 + return Unauthorized; + } + } GamePlaylist? rootPlaylist = dataContext.Database.GetUserRootPlaylist(user); @@ -45,6 +57,10 @@ public Response CreatePlaylist(RequestContext context, GameServerConfig config, // create the actual playlist and add it to the root playlist GamePlaylist playlist = dataContext.Database.CreatePlaylist(user, body); + if (uploadLimit.Enabled) + { + dataContext.Database.IncrementUploadRateLimitForEntity(user, GameDatabaseEntity.Playlist, uploadLimit.TimeSpanHours); + } dataContext.Database.AddPlaylistToPlaylist(playlist, rootPlaylist!); // return the playlist we just created to have the game open to it immediately diff --git a/RefreshTests.GameServer/GameServer/Configuration/TestEntityUploadRateLimitProperties.cs b/RefreshTests.GameServer/GameServer/Configuration/TestEntityUploadRateLimitProperties.cs new file mode 100644 index 000000000..94992b05d --- /dev/null +++ b/RefreshTests.GameServer/GameServer/Configuration/TestEntityUploadRateLimitProperties.cs @@ -0,0 +1,10 @@ +using Refresh.Core.Configuration; + +namespace RefreshTests.GameServer.GameServer.Configuration; + +public class TestEntityUploadRateLimitProperties : EntityUploadRateLimitProperties +{ + public TestEntityUploadRateLimitProperties() {} + + public int LevelQuota { get; set; } +} \ No newline at end of file diff --git a/RefreshTests.GameServer/GameServer/TestGameServerConfig.cs b/RefreshTests.GameServer/GameServer/Configuration/TestGameServerConfig.cs similarity index 85% rename from RefreshTests.GameServer/GameServer/TestGameServerConfig.cs rename to RefreshTests.GameServer/GameServer/Configuration/TestGameServerConfig.cs index 539b61f82..3dee4c6e7 100644 --- a/RefreshTests.GameServer/GameServer/TestGameServerConfig.cs +++ b/RefreshTests.GameServer/GameServer/Configuration/TestGameServerConfig.cs @@ -1,7 +1,7 @@ using Refresh.Core.Configuration; using Refresh.Database.Models.Assets; -namespace RefreshTests.GameServer.GameServer; +namespace RefreshTests.GameServer.GameServer.Configuration; public class TestGameServerConfig : GameServerConfig { @@ -15,7 +15,7 @@ public void TestMigration() public ConfigAssetFlags BlockedAssetFlagsForTrustedUsers { get; set; } = new(AssetFlags.Dangerous | AssetFlags.Modded); public bool ReadOnlyMode { get; set; } = false; public bool ReadonlyModeForTrustedUsers { get; set; } = false; - public TimedLevelUploadLimitProperties TimedLevelUploadLimits { get; set; } = new() + public TestEntityUploadRateLimitProperties TimedLevelUploadLimits { get; set; } = new() { Enabled = false, TimeSpanHours = 24, diff --git a/RefreshTests.GameServer/GameServer/Configuration/TestRolePermissions.cs b/RefreshTests.GameServer/GameServer/Configuration/TestRolePermissions.cs new file mode 100644 index 000000000..80d99f230 --- /dev/null +++ b/RefreshTests.GameServer/GameServer/Configuration/TestRolePermissions.cs @@ -0,0 +1,16 @@ +using Refresh.Core.Configuration; +using Refresh.Database.Models.Assets; + +namespace RefreshTests.GameServer.GameServer.Configuration; + +public class TestRolePermissions : RolePermissions +{ + public TestRolePermissions() {} + + public TestEntityUploadRateLimitProperties TimedLevelUploadLimits = new() + { + Enabled = false, + TimeSpanHours = 24, + LevelQuota = 8, + }; +} \ No newline at end of file diff --git a/RefreshTests.GameServer/Tests/ApiV3/PlaylistApiTests.cs b/RefreshTests.GameServer/Tests/ApiV3/PlaylistApiTests.cs index 2620bbc78..2d76c0c4c 100644 --- a/RefreshTests.GameServer/Tests/ApiV3/PlaylistApiTests.cs +++ b/RefreshTests.GameServer/Tests/ApiV3/PlaylistApiTests.cs @@ -9,6 +9,9 @@ using Refresh.Common.Constants; using Refresh.Common.Extensions; using Refresh.Database.Models.Assets; +using Refresh.Core.Configuration; +using Refresh.Database.Models; +using System.Net; namespace RefreshTests.GameServer.Tests.ApiV3; @@ -221,6 +224,88 @@ public void TestPlaylistIcons(string icon, bool success, bool isRemoteAsset) } } + [Test] + [TestCase(4, 4, 1)] + public void PlaylistUploadsGetRateLimitedTemporarily(int playlistQuota, int uploadAttemptsAfterExceeding, int timeSpanHours) + { + using TestContext context = this.GetServer(); + GameUser user = context.CreateUser("PlaylistOTrolle"); + + // Prepare config + GameServerConfig config = context.Server.Value.GameServerConfig; + EntityUploadRateLimitProperties uploadConfig = new() + { + Enabled = true, + UploadQuota = playlistQuota, + TimeSpanHours = timeSpanHours, + }; + config.NormalUserPermissions.PlaylistUploadRateLimit = uploadConfig; + + using HttpClient client = context.GetAuthenticatedClient(TokenType.Api, user); + int publishAttempts = 0; + + // Not blocked yet + Assert.That(context.Database.GetUploadRateLimit(user, GameDatabaseEntity.Playlist), Is.Null); + Assert.That(context.Database.GetRemainingTimeIfUploadRateLimitReached(user, GameDatabaseEntity.Playlist, uploadConfig.UploadQuota), Is.Null); + + // Fill up half of quota + publishAttempts += SpamUploadPlaylists(uploadConfig.UploadQuota / 2, client); + context.Database.Refresh(); + + // There is rate-limit data in DB, but the rate-limit hasn't been triggered yet + EntityUploadRateLimit? uploadLimit = context.Database.GetUploadRateLimit(user, GameDatabaseEntity.Playlist); + Assert.That(uploadLimit, Is.Not.Null); + Assert.That(uploadLimit!.UploadCount, Is.EqualTo(uploadConfig.UploadQuota / 2)); + Assert.That(context.Database.GetRemainingTimeIfUploadRateLimitReached(user, GameDatabaseEntity.Playlist, uploadConfig.UploadQuota), Is.Null); + + // Try to upload more playlists + publishAttempts += SpamUploadPlaylists(uploadConfig.UploadQuota / 2, client); + context.Database.Refresh(); + + // There is rate-limit data in DB, and the rate-limit has been triggered + uploadLimit = context.Database.GetUploadRateLimit(user, GameDatabaseEntity.Playlist); + Assert.That(uploadLimit, Is.Not.Null); + Assert.That(uploadLimit!.UploadCount, Is.EqualTo(uploadConfig.UploadQuota)); + Assert.That(context.Database.GetRemainingTimeIfUploadRateLimitReached(user, GameDatabaseEntity.Playlist, uploadConfig.UploadQuota), Is.Not.Null); + + // Playlists are blocked + publishAttempts += SpamUploadPlaylists(uploadAttemptsAfterExceeding, client, BadRequest); + context.Database.Refresh(); + + // Check amount of playlists + Assert.That(context.Database.GetTotalPlaylistsByAuthor(user), Is.EqualTo(uploadConfig.UploadQuota)); + + // Expire limit naturally by trying to publish again later + context.Time.TimestampMilliseconds = 1000 * 60 * 60 * timeSpanHours + 10; + publishAttempts += SpamUploadPlaylists(uploadAttemptsAfterExceeding, client); + context.Database.Refresh(); + + // there are more playlists now + Assert.That(context.Database.GetTotalPlaylistsByAuthor(user), Is.EqualTo(uploadConfig.UploadQuota * 2)); + } + + private int SpamUploadPlaylists(int uploads, HttpClient client, HttpStatusCode expectedStatus = OK) + { + for (int i = 0; i < uploads; i++) + { + // Playlist requests currently don't need to reference unique assets + ApiPlaylistCreationRequest playlist = new() + { + Name = "hi", + Icon = "0", + Description = "hi", + Location = GameLocation.Random, + }; + + bool expectSuccess = expectedStatus == OK; + ApiResponse? response = client.PostData($"/api/v3/playlists", playlist, expectSuccess, !expectSuccess); + if (expectSuccess) Assert.That(response?.Data, Is.Not.Null); + else Assert.That(response?.Error, Is.Not.Null); + } + + return uploads; + } + [Test] public async Task GetAndDeletePlaylist() { diff --git a/RefreshTests.GameServer/Tests/Configs/GameServerConfigTests.cs b/RefreshTests.GameServer/Tests/Configs/GameServerConfigTests.cs index a99569f92..df8b29741 100644 --- a/RefreshTests.GameServer/Tests/Configs/GameServerConfigTests.cs +++ b/RefreshTests.GameServer/Tests/Configs/GameServerConfigTests.cs @@ -1,4 +1,5 @@ using Refresh.Database.Models.Assets; +using RefreshTests.GameServer.GameServer.Configuration; namespace RefreshTests.GameServer.Tests.Configs; @@ -28,14 +29,14 @@ public void MigratesRolePermsFromVersion26() Assert.That(config.NormalUserPermissions.ReadOnlyMode, Is.True); Assert.That(config.TrustedUserPermissions.ReadOnlyMode, Is.False); - Assert.That(config.NormalUserPermissions.TimedLevelUploadLimits.Enabled, Is.True); - Assert.That(config.TrustedUserPermissions.TimedLevelUploadLimits.Enabled, Is.True); + Assert.That(config.NormalUserPermissions.LevelUploadRateLimit.Enabled, Is.True); + Assert.That(config.TrustedUserPermissions.LevelUploadRateLimit.Enabled, Is.True); - Assert.That(config.NormalUserPermissions.TimedLevelUploadLimits.TimeSpanHours, Is.EqualTo(67)); - Assert.That(config.TrustedUserPermissions.TimedLevelUploadLimits.TimeSpanHours, Is.EqualTo(67)); + Assert.That(config.NormalUserPermissions.LevelUploadRateLimit.TimeSpanHours, Is.EqualTo(67)); + Assert.That(config.TrustedUserPermissions.LevelUploadRateLimit.TimeSpanHours, Is.EqualTo(67)); - Assert.That(config.NormalUserPermissions.TimedLevelUploadLimits.LevelQuota, Is.EqualTo(2)); - Assert.That(config.TrustedUserPermissions.TimedLevelUploadLimits.LevelQuota, Is.EqualTo(2)); + Assert.That(config.NormalUserPermissions.LevelUploadRateLimit.UploadQuota, Is.EqualTo(2)); + Assert.That(config.TrustedUserPermissions.LevelUploadRateLimit.UploadQuota, Is.EqualTo(2)); Assert.That(config.NormalUserPermissions.BlockedAssetFlags.ToAssetFlags(), Is.EqualTo(AssetFlags.Dangerous | AssetFlags.Media)); Assert.That(config.TrustedUserPermissions.BlockedAssetFlags.ToAssetFlags(), Is.EqualTo(AssetFlags.Modded)); @@ -44,6 +45,43 @@ public void MigratesRolePermsFromVersion26() Assert.That(config.TrustedUserPermissions.UserFilesizeQuota, Is.EqualTo(141)); } + [Test] + public void MigratesEntityUploadRateLimitsFromVersion27() + { + TestGameServerConfig config = new() + { + Version = 27, + NormalUserPermissions = new TestRolePermissions() + { + TimedLevelUploadLimits = new() + { + Enabled = true, + TimeSpanHours = 1234567, + LevelQuota = 852094, + }, + }, + TrustedUserPermissions = new TestRolePermissions() + { + TimedLevelUploadLimits = new() + { + Enabled = false, + TimeSpanHours = 230, + LevelQuota = 7122036, + }, + }, + }; + + config.TestMigration(); + + Assert.That(config.NormalUserPermissions.LevelUploadRateLimit.Enabled, Is.True); + Assert.That(config.NormalUserPermissions.LevelUploadRateLimit.TimeSpanHours, Is.EqualTo(1234567)); + Assert.That(config.NormalUserPermissions.LevelUploadRateLimit.UploadQuota, Is.EqualTo(852094)); + + Assert.That(config.TrustedUserPermissions.LevelUploadRateLimit.Enabled, Is.False); + Assert.That(config.TrustedUserPermissions.LevelUploadRateLimit.TimeSpanHours, Is.EqualTo(230)); + Assert.That(config.TrustedUserPermissions.LevelUploadRateLimit.UploadQuota, Is.EqualTo(7122036)); + } + [Test] public void MigratesRolePermsFromVersion17() { diff --git a/RefreshTests.GameServer/Tests/Levels/PublishEndpointsTests.cs b/RefreshTests.GameServer/Tests/Levels/PublishEndpointsTests.cs index 85ea1741a..51a9d3cd0 100644 --- a/RefreshTests.GameServer/Tests/Levels/PublishEndpointsTests.cs +++ b/RefreshTests.GameServer/Tests/Levels/PublishEndpointsTests.cs @@ -12,6 +12,9 @@ using Refresh.Interfaces.Game.Endpoints.DataTypes.Request; using Refresh.Interfaces.Game.Endpoints.DataTypes.Response; using Refresh.Interfaces.Game.Types.Levels; +using System.Security.Cryptography; +using System.Text; +using System.Net; namespace RefreshTests.GameServer.Tests.Levels; @@ -494,116 +497,103 @@ public void CantUnpublishSomeoneElsesLevel() } [Test] - [TestCase(2, 2)] - [Ignore("needs to change lvl hash every iteration")] // TODO - public void CantPublishAfterExceedingTimedLevelLimit(int levelQuota, int uploadAttemptsAfterExceeding) + [TestCase(4, 4, 1)] + public void LevelUploadsGetRateLimitedTemporarily(int levelQuota, int uploadAttemptsAfterExceeding, int timeSpanHours) { using TestContext context = this.GetServer(); GameUser user = context.CreateUser("thepublisher"); // Prepare config GameServerConfig config = context.Server.Value.GameServerConfig; - TimedLevelUploadLimitProperties timedLevelLimit = new() + EntityUploadRateLimitProperties uploadConfig = new() { Enabled = true, - LevelQuota = levelQuota, - TimeSpanHours = 1, + UploadQuota = levelQuota, + TimeSpanHours = timeSpanHours, }; - config.NormalUserPermissions.TimedLevelUploadLimits = timedLevelLimit; + config.NormalUserPermissions.LevelUploadRateLimit = uploadConfig; using HttpClient client = context.GetAuthenticatedClient(TokenType.Game, user); + int publishAttempts = 0; - // Upload level asset - HttpResponseMessage assetUploadMessage = client.PostAsync($"/lbp/upload/{TEST_ASSET_HASH}", new ReadOnlyMemoryContent("LVLb"u8.ToArray())).Result; - Assert.That(assetUploadMessage.StatusCode, Is.EqualTo(OK)); + // Not blocked yet + Assert.That(context.Database.GetUploadRateLimit(user, GameDatabaseEntity.Level), Is.Null); + Assert.That(context.Database.GetRemainingTimeIfUploadRateLimitReached(user, GameDatabaseEntity.Level, uploadConfig.UploadQuota), Is.Null); - // Fill up quota - SpamSuccessfulUploads(timedLevelLimit.LevelQuota, client); - - // Try to upload more levels after exceeding quota - for (int i = 0; i < uploadAttemptsAfterExceeding; i++) - { - GameLevelRequest level = CreateValidTestLevel(i + 1); - - HttpResponseMessage message = client.PostAsync("/lbp/startPublish", new StringContent(level.AsXML())).Result; - Assert.That(message.StatusCode, Is.EqualTo(Unauthorized)); - message = client.PostAsync("/lbp/publish", new StringContent(level.AsXML())).Result; - Assert.That(message.StatusCode, Is.EqualTo(Unauthorized)); - } - - // Check amount of levels - DatabaseList levelsByUser = context.Database.GetLevelsByUser(user, 1000, 0, new(TokenGame.LittleBigPlanet3), user); - Assert.That(levelsByUser.TotalItems, Is.EqualTo(timedLevelLimit.LevelQuota)); - - // Ensure there were error notifications sent for each blocked request to both /startPublish and /publish - DatabaseList newNotifications = context.Database.GetNotificationsByUser(user, 1000, 0); - Assert.That(newNotifications.TotalItems, Is.EqualTo(uploadAttemptsAfterExceeding * 2)); - } + // Fill up half of quota + publishAttempts += SpamPublishUniqueLevels(uploadConfig.UploadQuota / 2, client, publishAttempts); + context.Database.Refresh(); - [Test] - [TestCase(2, 2)] - [Ignore("needs to change lvl hash every iteration")] // TODO - public void ResetTimedLevelLimitAfterExpiry(int levelQuota, int uploadAttemptsAfterExceeding) - { - using TestContext context = this.GetServer(); - GameUser user = context.CreateUser("thepublisher"); + // There is rate-limit data in DB, but the rate-limit hasn't been triggered yet + EntityUploadRateLimit? uploadLimit = context.Database.GetUploadRateLimit(user, GameDatabaseEntity.Level); + Assert.That(uploadLimit, Is.Not.Null); + Assert.That(uploadLimit!.UploadCount, Is.EqualTo(uploadConfig.UploadQuota / 2)); + Assert.That(context.Database.GetRemainingTimeIfUploadRateLimitReached(user, GameDatabaseEntity.Level, uploadConfig.UploadQuota), Is.Null); + + // Try to upload more levels to reach quota + publishAttempts += SpamPublishUniqueLevels(uploadConfig.UploadQuota / 2, client, publishAttempts); + context.Database.Refresh(); - // Prepare config - GameServerConfig config = context.Server.Value.GameServerConfig; - TimedLevelUploadLimitProperties timedLevelLimit = new() - { - Enabled = true, - LevelQuota = levelQuota, - // Having this be 0 causes the server to always set the expiry date to now, making the expiry date be not null but always expired, - // causing both /startPublish and /publish to always reset the limit after setting it in a previous /publish request, and allowing publish requests - TimeSpanHours = 0, - }; - config.NormalUserPermissions.TimedLevelUploadLimits = timedLevelLimit; + // Ensure there were no notifications sent + Assert.That(context.Database.GetNotificationCountByUser(user), Is.Zero); - using HttpClient client = context.GetAuthenticatedClient(TokenType.Game, user); + // There is rate-limit data in DB, and the rate-limit has been triggered + uploadLimit = context.Database.GetUploadRateLimit(user, GameDatabaseEntity.Level); + Assert.That(uploadLimit, Is.Not.Null); + Assert.That(uploadLimit!.UploadCount, Is.EqualTo(uploadConfig.UploadQuota)); + Assert.That(context.Database.GetRemainingTimeIfUploadRateLimitReached(user, GameDatabaseEntity.Level, uploadConfig.UploadQuota), Is.Not.Null); - // Upload level asset - HttpResponseMessage message = client.PostAsync($"/lbp/upload/{TEST_ASSET_HASH}", new ReadOnlyMemoryContent("LVLb"u8.ToArray())).Result; - Assert.That(message.StatusCode, Is.EqualTo(OK)); + // levels are blocked + publishAttempts += SpamPublishUniqueLevels(uploadAttemptsAfterExceeding, client, publishAttempts, Unauthorized); + context.Database.Refresh(); - // Fill up quota - SpamSuccessfulUploads(timedLevelLimit.LevelQuota, client); - - // Try to upload more levels after exceeding quota - SpamSuccessfulUploads(uploadAttemptsAfterExceeding, client); + // Check amount of levels, and ensure there were notifications sent for every failed level + Assert.That(context.Database.GetTotalLevelsByUser(user), Is.EqualTo(uploadConfig.UploadQuota)); + Assert.That(context.Database.GetNotificationCountByUser(user), Is.EqualTo(uploadAttemptsAfterExceeding * 2)); - // Check amount of levels - DatabaseList levelsByUser = context.Database.GetLevelsByUser(user, 1000, 0, new(TokenGame.LittleBigPlanet3), user); - Assert.That(levelsByUser.TotalItems, Is.EqualTo(timedLevelLimit.LevelQuota + uploadAttemptsAfterExceeding)); + // Expire limit naturally by trying to publish again later + context.Time.TimestampMilliseconds = 1000 * 60 * 60 * timeSpanHours + 10; + publishAttempts += SpamPublishUniqueLevels(uploadAttemptsAfterExceeding, client, publishAttempts); + context.Database.Refresh(); - // Ensure there were no notifications sent - DatabaseList newNotifications = context.Database.GetNotificationsByUser(user, 1000, 0); - Assert.That(newNotifications.TotalItems, Is.EqualTo(0)); + // there are more levels now, and no new notifications + Assert.That(context.Database.GetTotalLevelsByUser(user), Is.EqualTo(uploadConfig.UploadQuota * 2)); + Assert.That(context.Database.GetNotificationCountByUser(user), Is.EqualTo(uploadAttemptsAfterExceeding * 2)); } - private void SpamSuccessfulUploads(int uploads, HttpClient client) + private int SpamPublishUniqueLevels(int uploads, HttpClient client, int startIndex, HttpStatusCode expectedStatus = OK) { for (int i = 0; i < uploads; i++) { - GameLevelRequest level = CreateValidTestLevel(i + 1); - // Upload level + GameLevelRequest level = PrepareUniqueLevelPublishRequest(client, startIndex + i); + HttpResponseMessage message = client.PostAsync("/lbp/startPublish", new StringContent(level.AsXML())).Result; - Assert.That(message.StatusCode, Is.EqualTo(OK)); + Assert.That(message.StatusCode, Is.EqualTo(expectedStatus)); message = client.PostAsync("/lbp/publish", new StringContent(level.AsXML())).Result; - Assert.That(message.StatusCode, Is.EqualTo(OK)); + Assert.That(message.StatusCode, Is.EqualTo(expectedStatus)); } + + return uploads; } - private GameLevelRequest CreateValidTestLevel(int id) - => new() + private GameLevelRequest PrepareUniqueLevelPublishRequest(HttpClient client, int uniqueValue) + { + // upload root asset + ReadOnlySpan rootResource = new(Encoding.ASCII.GetBytes($"LVLb {uniqueValue}")); + string rootHash = BitConverter.ToString(SHA1.HashData(rootResource)).Replace("-", "").ToLower(); + + HttpResponseMessage message = client.PostAsync($"/lbp/upload/{rootHash}", new ReadOnlyMemoryContent(rootResource.ToArray())).Result; + Assert.That(message.StatusCode, Is.EqualTo(OK)); + + // prepare request body + return new() { - Title = "Test level! " + id, - IconHash = "g0", + Title = "Test level! " + uniqueValue, Description = "Test description", - Location = new GameLocation(), - RootResource = TEST_ASSET_HASH, + RootResource = rootHash, }; + } [Test] public void CanPublishLevelWithSkillRewards() diff --git a/RefreshTests.GameServer/Tests/Photos/PhotoEndpointsTests.cs b/RefreshTests.GameServer/Tests/Photos/PhotoEndpointsTests.cs index 8774b0a17..0a32206dc 100644 --- a/RefreshTests.GameServer/Tests/Photos/PhotoEndpointsTests.cs +++ b/RefreshTests.GameServer/Tests/Photos/PhotoEndpointsTests.cs @@ -8,6 +8,10 @@ using Refresh.Interfaces.Game.Types.Lists; using Refresh.Database.Helpers; using System.Security.Cryptography; +using Refresh.Core.Configuration; +using Refresh.Database.Models; +using System.Text; +using System.Net; namespace RefreshTests.GameServer.Tests.Photos; @@ -508,4 +512,115 @@ public void CannotUploadPhotoIfDuplicatePlan() Assert.That(message.StatusCode, Is.EqualTo(BadRequest)); Assert.That(context.Database.GetNotificationCountByUser(user), Is.EqualTo(1)); } + + [Test] + [TestCase(4, 4, 1)] + public void PhotoUploadsGetRateLimitedTemporarily(int photoQuota, int uploadAttemptsAfterExceeding, int timeSpanHours) + { + using TestContext context = this.GetServer(); + GameUser user = context.CreateUser("thepublisher"); + GameLevel level = context.CreateLevel(user); + + // Prepare config + GameServerConfig config = context.Server.Value.GameServerConfig; + EntityUploadRateLimitProperties uploadConfig = new() + { + Enabled = true, + UploadQuota = photoQuota, + TimeSpanHours = timeSpanHours, + }; + config.NormalUserPermissions.PhotoUploadRateLimit = uploadConfig; + + using HttpClient client = context.GetAuthenticatedClient(TokenType.Game, user); + int publishAttempts = 0; + + // Not blocked yet + Assert.That(context.Database.GetUploadRateLimit(user, GameDatabaseEntity.Photo), Is.Null); + Assert.That(context.Database.GetRemainingTimeIfUploadRateLimitReached(user, GameDatabaseEntity.Photo, uploadConfig.UploadQuota), Is.Null); + + // Fill up half of quota + publishAttempts += SpamUploadUniquePhotos(uploadConfig.UploadQuota / 2, client, publishAttempts, level); + context.Database.Refresh(); + + // There is rate-limit data in DB, but the rate-limit hasn't been triggered yet + EntityUploadRateLimit? uploadLimit = context.Database.GetUploadRateLimit(user, GameDatabaseEntity.Photo); + Assert.That(uploadLimit, Is.Not.Null); + Assert.That(uploadLimit!.UploadCount, Is.EqualTo(uploadConfig.UploadQuota / 2)); + Assert.That(context.Database.GetRemainingTimeIfUploadRateLimitReached(user, GameDatabaseEntity.Photo, uploadConfig.UploadQuota), Is.Null); + + // Try to upload more photos to reach quota + publishAttempts += SpamUploadUniquePhotos(uploadConfig.UploadQuota / 2, client, publishAttempts, level); + context.Database.Refresh(); + + // Ensure there were no notifications sent + Assert.That(context.Database.GetNotificationCountByUser(user), Is.Zero); + + // There is rate-limit data in DB, and the rate-limit has been triggered + uploadLimit = context.Database.GetUploadRateLimit(user, GameDatabaseEntity.Photo); + Assert.That(uploadLimit, Is.Not.Null); + Assert.That(uploadLimit!.UploadCount, Is.EqualTo(uploadConfig.UploadQuota)); + Assert.That(context.Database.GetRemainingTimeIfUploadRateLimitReached(user, GameDatabaseEntity.Photo, uploadConfig.UploadQuota), Is.Not.Null); + + // photos are blocked + publishAttempts += SpamUploadUniquePhotos(uploadAttemptsAfterExceeding, client, publishAttempts, level, Unauthorized); + context.Database.Refresh(); + + // Check amount of photos, and ensure there were notifications sent for every failed photo + Assert.That(context.Database.GetTotalPhotosByUser(user), Is.EqualTo(uploadConfig.UploadQuota)); + Assert.That(context.Database.GetNotificationCountByUser(user), Is.EqualTo(uploadAttemptsAfterExceeding)); + + // Expire limit naturally by trying to publish again later + context.Time.TimestampMilliseconds = 1000 * 60 * 60 * timeSpanHours + 10; + publishAttempts += SpamUploadUniquePhotos(uploadAttemptsAfterExceeding, client, publishAttempts, level); + context.Database.Refresh(); + + // there are more levels now, and no new notifications + Assert.That(context.Database.GetTotalPhotosByUser(user), Is.EqualTo(uploadConfig.UploadQuota * 2)); + Assert.That(context.Database.GetNotificationCountByUser(user), Is.EqualTo(uploadAttemptsAfterExceeding)); + } + + private int SpamUploadUniquePhotos(int uploads, HttpClient client, int startIndex, GameLevel level, HttpStatusCode expectedStatus = OK) + { + for (int i = 0; i < uploads; i++) + { + // Upload level + SerializedPhoto photo = PrepareUniquePhotoUploadRequest(client, level, startIndex + i); + + HttpResponseMessage message = client.PostAsync($"/lbp/uploadPhoto", new StringContent(photo.AsXML())).Result; + Assert.That(message.StatusCode, Is.EqualTo(expectedStatus)); + } + + return uploads; + } + + private SerializedPhoto PrepareUniquePhotoUploadRequest(HttpClient client, GameLevel level, int uniqueValue) + { + // upload """photo""" + ReadOnlySpan imageResource = new(Encoding.ASCII.GetBytes($"TEX {uniqueValue}")); + string imageHash = BitConverter.ToString(SHA1.HashData(imageResource)).Replace("-", "").ToLower(); + HttpResponseMessage message = client.PostAsync($"/lbp/upload/{imageHash}", new ReadOnlyMemoryContent(imageResource.ToArray())).Result; + Assert.That(message.StatusCode, Is.EqualTo(OK)); + + // upload """plan""" + ReadOnlySpan planResource = new(Encoding.ASCII.GetBytes($"PLNb {uniqueValue}")); + string planHash = BitConverter.ToString(SHA1.HashData(planResource)).Replace("-", "").ToLower(); + message = client.PostAsync($"/lbp/upload/{planHash}", new ReadOnlyMemoryContent(planResource.ToArray())).Result; + Assert.That(message.StatusCode, Is.EqualTo(OK)); + + // prepare request body + return new() + { + Timestamp = DateTimeOffset.UtcNow.ToUnixTimeSeconds(), + SmallHash = imageHash, + MediumHash = imageHash, + LargeHash = imageHash, + PlanHash = planHash, + Level = new SerializedPhotoLevel + { + LevelId = level.LevelId, + Title = level.Title, + Type = "user", + }, + }; + } } \ No newline at end of file diff --git a/RefreshTests.GameServer/Tests/Playlists/PlaylistUploadTests.cs b/RefreshTests.GameServer/Tests/Playlists/PlaylistUploadTests.cs index 70d459b70..c5ec88161 100644 --- a/RefreshTests.GameServer/Tests/Playlists/PlaylistUploadTests.cs +++ b/RefreshTests.GameServer/Tests/Playlists/PlaylistUploadTests.cs @@ -1,3 +1,4 @@ +using System.Net; using Refresh.Common.Constants; using Refresh.Core.Configuration; using Refresh.Database; @@ -25,22 +26,10 @@ public void CreateAndUpdateLbp1Playlist() 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")); + GamePlaylist root = SuccessfullyUploadRootPlaylistViaLBP1(client, user, context.Database); // Create actual playlist - request = new() + SerializedLbp1Playlist request = new() { Name = "real", Icon = ValidIconGuid, @@ -48,18 +37,13 @@ public void CreateAndUpdateLbp1Playlist() Location = new GameLocation(), }; - message = client.PostAsync($"/lbp/createPlaylist?parent_id={rootResponse.Id}", new StringContent(request.AsXML())).Result; + HttpResponseMessage message = client.PostAsync($"/lbp/createPlaylist?parent_id={root.PlaylistId}", 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)); - + // Ensure the playlists are properly fetchable (root by itself is already asserted in its helper method) DatabaseList playlists = context.Database.GetPlaylistsByAuthor(user, 0, 10); Assert.That(playlists.Items.Count, Is.EqualTo(1)); @@ -85,6 +69,31 @@ public void CreateAndUpdateLbp1Playlist() Assert.That(updated!.Name, Is.EqualTo("legit")); } + private GamePlaylist SuccessfullyUploadRootPlaylistViaLBP1(HttpClient client, GameUser user, GameDatabaseContext database) + { + 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")); + + GamePlaylist? root = database.GetUserRootPlaylist(user); + Assert.That(root, Is.Not.Null); + Assert.That(root!.IsRoot, Is.True); + Assert.That(root!.PlaylistId, Is.EqualTo(rootResponse.Id)); + Assert.That(root!.Name, Is.EqualTo("root")); + + return root; + } + [Test] public void CreateSubPlaylist() { @@ -350,4 +359,109 @@ public void ValidatePlaylistIcon(string iconHash, bool isValid, bool uploadAsset Assert.That(updated, Is.Not.Null); Assert.That(updated!.IconHash, Is.EqualTo(isValid ? iconHash : "0")); } + + [Test] + [TestCase(4, 4, 1)] + public void PlaylistUploadsGetRateLimitedTemporarily(int playlistQuota, int uploadAttemptsAfterExceeding, int timeSpanHours) + { + using TestContext context = this.GetServer(); + GameUser user = context.CreateUser("PlaylistOTrolle"); + + // Prepare config + GameServerConfig config = context.Server.Value.GameServerConfig; + EntityUploadRateLimitProperties uploadConfig = new() + { + Enabled = true, + UploadQuota = playlistQuota, + TimeSpanHours = timeSpanHours, + }; + config.NormalUserPermissions.PlaylistUploadRateLimit = uploadConfig; + + using HttpClient client = context.GetAuthenticatedClient(TokenType.Game, user); + int publishAttempts = 0; + + // create root via LBP1 first; since root doesn't count towards the rate-limit, we can, this way, both ensure that the rate-limit properly + // starts tracking the spam uploads below, and we can properly ensure that roots do, infact, not count towards the rate-limit here + SuccessfullyUploadRootPlaylistViaLBP1(client, user, context.Database); + + // Not blocked yet + Assert.That(context.Database.GetUploadRateLimit(user, GameDatabaseEntity.Playlist), Is.Null); + Assert.That(context.Database.GetRemainingTimeIfUploadRateLimitReached(user, GameDatabaseEntity.Playlist, uploadConfig.UploadQuota), Is.Null); + + // Fill up half of quota in lbp1 + publishAttempts += SpamUploadPlaylistsInLBP1(uploadConfig.UploadQuota / 2, client); + context.Database.Refresh(); + + // There is rate-limit data in DB, but the rate-limit hasn't been triggered yet + EntityUploadRateLimit? uploadLimit = context.Database.GetUploadRateLimit(user, GameDatabaseEntity.Playlist); + Assert.That(uploadLimit, Is.Not.Null); + Assert.That(uploadLimit!.UploadCount, Is.EqualTo(uploadConfig.UploadQuota / 2)); + Assert.That(context.Database.GetRemainingTimeIfUploadRateLimitReached(user, GameDatabaseEntity.Playlist, uploadConfig.UploadQuota), Is.Null); + + // Try to upload more playlists (in lbp3 now) to reach quota, this way we also ensure that both endpoints share the same rate-limit + publishAttempts += SpamUploadPlaylistsInLBP3(uploadConfig.UploadQuota / 2, client); + context.Database.Refresh(); + + // Ensure there were no notifications sent + Assert.That(context.Database.GetNotificationCountByUser(user), Is.Zero); + + // There is rate-limit data in DB, and the rate-limit has been triggered + uploadLimit = context.Database.GetUploadRateLimit(user, GameDatabaseEntity.Playlist); + Assert.That(uploadLimit, Is.Not.Null); + Assert.That(uploadLimit!.UploadCount, Is.EqualTo(uploadConfig.UploadQuota)); + Assert.That(context.Database.GetRemainingTimeIfUploadRateLimitReached(user, GameDatabaseEntity.Playlist, uploadConfig.UploadQuota), Is.Not.Null); + + // Playlists are blocked + publishAttempts += SpamUploadPlaylistsInLBP1(uploadAttemptsAfterExceeding, client, Unauthorized); + context.Database.Refresh(); + + // Check amount of playlists + Assert.That(context.Database.GetTotalPlaylistsByAuthor(user), Is.EqualTo(uploadConfig.UploadQuota)); + + // Expire limit naturally by trying to publish again later + context.Time.TimestampMilliseconds = 1000 * 60 * 60 * timeSpanHours + 10; + publishAttempts += SpamUploadPlaylistsInLBP3(uploadAttemptsAfterExceeding, client); + context.Database.Refresh(); + + // there are more playlists now + Assert.That(context.Database.GetTotalPlaylistsByAuthor(user), Is.EqualTo(uploadConfig.UploadQuota * 2)); + } + + private int SpamUploadPlaylistsInLBP1(int uploads, HttpClient client, HttpStatusCode expectedStatus = OK) + { + for (int i = 0; i < uploads; i++) + { + // Playlist requests currently don't need to reference unique assets + SerializedLbp1Playlist playlist = new() + { + Name = "hi", + Icon = "0", + Description = "i'm not gonna stop spamming playlists", + Location = GameLocation.Random, + }; + + HttpResponseMessage message = client.PostAsync($"/lbp/createPlaylist", new StringContent(playlist.AsXML())).Result; + Assert.That(message.StatusCode, Is.EqualTo(expectedStatus)); + } + + return uploads; + } + + private int SpamUploadPlaylistsInLBP3(int uploads, HttpClient client, HttpStatusCode expectedStatus = OK) + { + for (int i = 0; i < uploads; i++) + { + // Playlist requests currently don't need to reference unique assets + SerializedLbp3Playlist playlist = new() + { + Name = "hi", + Description = "i didn't stop spamming playlists", + }; + + HttpResponseMessage message = client.PostAsync($"/lbp/playlists", new StringContent(playlist.AsXML())).Result; + Assert.That(message.StatusCode, Is.EqualTo(expectedStatus)); + } + + return uploads; + } } \ No newline at end of file