From d96d41dabde844fa1cdaa5d6e75f888da426ea92 Mon Sep 17 00:00:00 2001 From: Toaster2 Date: Tue, 21 Apr 2026 20:19:22 +0200 Subject: [PATCH 01/15] Split upload rate-limits to separate table (wip) --- .../EntityUploadRateLimitProperties.cs | 19 ++++++ Refresh.Core/Configuration/RolePermissions.cs | 18 ++++- .../TimedLevelUploadLimitProperties.cs | 19 ------ Refresh.Database/GameDatabaseContext.Users.cs | 40 +++++++---- Refresh.Database/GameDatabaseContext.cs | 1 + ...03_SplitUploadRateLimitsToSeparateTable.cs | 67 +++++++++++++++++++ .../GameDatabaseContextModelSnapshot.cs | 36 ++++++++-- Refresh.Database/Models/GameDatabaseEntity.cs | 16 +++++ .../Models/Users/EntityUploadRateLimit.cs | 26 +++++++ Refresh.Database/Models/Users/GameUser.cs | 15 ----- .../Endpoints/Levels/PublishEndpoints.cs | 29 ++++---- 11 files changed, 216 insertions(+), 70 deletions(-) create mode 100644 Refresh.Core/Configuration/EntityUploadRateLimitProperties.cs delete mode 100644 Refresh.Core/Configuration/TimedLevelUploadLimitProperties.cs create mode 100644 Refresh.Database/Migrations/20260421181503_SplitUploadRateLimitsToSeparateTable.cs create mode 100644 Refresh.Database/Models/GameDatabaseEntity.cs create mode 100644 Refresh.Database/Models/Users/EntityUploadRateLimit.cs diff --git a/Refresh.Core/Configuration/EntityUploadRateLimitProperties.cs b/Refresh.Core/Configuration/EntityUploadRateLimitProperties.cs new file mode 100644 index 000000000..d3445364f --- /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 (levels/photos/playlists) the user is allowed to upload during the specified time span + /// + public int EntityQuota { get; set; } +} \ No newline at end of file diff --git a/Refresh.Core/Configuration/RolePermissions.cs b/Refresh.Core/Configuration/RolePermissions.cs index e1cc44e59..7f2c26a2e 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, + EntityQuota = 10, + }; + + public EntityUploadRateLimitProperties PhotoUploadRateLimit = new() + { + Enabled = false, + TimeSpanHours = 24, + EntityQuota = 10, + }; + + public EntityUploadRateLimitProperties PlaylistUploadRateLimit = new() + { + Enabled = false, + TimeSpanHours = 24, + EntityQuota = 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.Database/GameDatabaseContext.Users.cs b/Refresh.Database/GameDatabaseContext.Users.cs index 92b0b27ca..3bce834e8 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,37 @@ public void SetUserPresenceAuthToken(GameUser user, string? token) user.PresenceServerAuthToken = token; }); } + + public EntityUploadRateLimit? GetUploadRateLimit(GameUser user, GameDatabaseEntity entity) + => this.EntityUploadRateLimits.FirstOrDefault(r => r.UserId == user.UserId && r.Entity == entity); - public void IncrementTimedLevelLimit(GameUser user, int hours) + public void IncrementUploadRateLimitForEntity(GameUser user, GameDatabaseEntity entity, int timeSpanHours) { - this.Write(() => + EntityUploadRateLimit? existingLimit = this.GetUploadRateLimit(user, entity); + DateTimeOffset now = this._time.Now; + + if (existingLimit == null || existingLimit.ExpiryDate <= now) { - // Set expiry date if the timed limits have been reset previously - user.TimedLevelUploadExpiryDate ??= this._time.Now + TimeSpan.FromHours(hours); - user.TimedLevelUploads++; - }); + EntityUploadRateLimit newLimit = new() + { + Entity = entity, + User = user, + EntityQuota = 1, + ExpiryDate = now + TimeSpan.FromHours(timeSpanHours), + }; + this.EntityUploadRateLimits.Add(newLimit); + } + else + { + this.EntityUploadRateLimits.Update(existingLimit); + existingLimit.EntityQuota++; + } + + this.SaveChanges(); } - public void ResetTimedLevelLimit(GameUser user) + public void ResetUploadRateLimit(GameUser user, GameDatabaseEntity entity) { - this.Write(() => - { - user.TimedLevelUploadExpiryDate = null; - user.TimedLevelUploads = 0; - }); + 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..4c4369671 --- /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), + EntityQuota = 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..8b89041a3 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("EntityQuota") + .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..d8c31a37f --- /dev/null +++ b/Refresh.Database/Models/GameDatabaseEntity.cs @@ -0,0 +1,16 @@ +namespace Refresh.Database.Models; + +public enum GameDatabaseEntity : byte +{ + User, + Level, + Score, + 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..00fa33156 --- /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 EntityQuota { 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.Game/Endpoints/Levels/PublishEndpoints.cs b/Refresh.Interfaces.Game/Endpoints/Levels/PublishEndpoints.cs index 2d12ffa50..1a2ebe4c6 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,29 +79,27 @@ 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 config, DateTimeOffset now) { - if (!config.Enabled || user.TimedLevelUploads <= 0 || user.TimedLevelUploadExpiryDate == null) - { - return false; - } + if (!config.Enabled) return false; - DateTimeOffset expiryDate = user.TimedLevelUploadExpiryDate.Value; + EntityUploadRateLimit? rateLimit = dataContext.Database.GetUploadRateLimit(user, GameDatabaseEntity.Level); + if (rateLimit == null) return false; // If the expiration date has expired (less than now), reset user's limit and continue. - if (now >= expiryDate) + if (now >= rateLimit.ExpiryDate) { - dataContext.Database.ResetTimedLevelLimit(user); + dataContext.Database.ResetUploadRateLimit(user, GameDatabaseEntity.Level); return false; } // If expiration date has not expired yet and the user has reached the limit, block. - else if (user.TimedLevelUploads >= config.LevelQuota) + else if (rateLimit.EntityQuota >= config.EntityQuota) { - TimeSpan remainingTime = expiryDate - now; + TimeSpan remainingTime = rateLimit.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!", + $"You have reached your timed level upload limit of {config.EntityQuota} levels per {config.TimeSpanHours} hours. " + + $"Your limit will expire in {remainingTime.Hours} hours and {remainingTime.Minutes} minutes. After that, try publishing your level again!", levelTitle, user ); @@ -128,7 +127,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, dateTimeProvider.Now)) return Unauthorized; //If verifying the request fails, return BadRequest @@ -183,7 +182,7 @@ public Response PublishLevel(RequestContext context, if (user.IsWriteBlocked(config)) return Unauthorized; - TimedLevelUploadLimitProperties timedLevelLimit = user.GetRolePermissionsForUser(config).TimedLevelUploadLimits; + EntityUploadRateLimitProperties timedLevelLimit = user.GetRolePermissionsForUser(config).LevelUploadRateLimit; if (IsTimedLevelLimitReached(dataContext, user, body.Title, timedLevelLimit, dateTimeProvider.Now)) return Unauthorized; @@ -260,7 +259,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 From 9d5f208df99ea3139b85da7ef718bcc0415ffe17 Mon Sep 17 00:00:00 2001 From: Toaster2 Date: Wed, 22 Apr 2026 14:29:19 +0200 Subject: [PATCH 02/15] Apply upload rate-limit changes to GameServerConfig --- .../Configuration/GameServerConfig.cs | 28 ++++++++++++++----- 1 file changed, 21 insertions(+), 7 deletions(-) diff --git a/Refresh.Core/Configuration/GameServerConfig.cs b/Refresh.Core/Configuration/GameServerConfig.cs index fab3d295b..7a275e829 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.EntityQuota = (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.EntityQuota = (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 EntityQuota, + // 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.EntityQuota = (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.EntityQuota = (int)oldConfig.TrustedUserPermissions.TimedLevelUploadLimits.LevelQuota; + } } public string LicenseText { get; set; } = "Welcome to Refresh!"; From 54fef02a64d219b3313190b4e78bab5d2e89f901 Mon Sep 17 00:00:00 2001 From: Toaster2 Date: Wed, 22 Apr 2026 14:39:40 +0200 Subject: [PATCH 03/15] Slightly alter unreleased APIv3 spec --- .../Response/ApiEntityRateLimitResponse.cs | 21 +++++++++++++++++++ .../Response/ApiRolePermissionsResponse.cs | 12 +++++------ .../Response/ApiTimedLevelLimitResponse.cs | 8 ------- 3 files changed, 27 insertions(+), 14 deletions(-) create mode 100644 Refresh.Interfaces.APIv3/Endpoints/DataTypes/Response/ApiEntityRateLimitResponse.cs delete mode 100644 Refresh.Interfaces.APIv3/Endpoints/DataTypes/Response/ApiTimedLevelLimitResponse.cs 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..7e3f1b5f8 --- /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 EntityQuota { get; set; } + + public static ApiEntityRateLimitResponse? FromOld(EntityUploadRateLimitProperties old) + { + if (!old.Enabled) return null; + + return new() + { + TimeSpanHours = old.TimeSpanHours, + EntityQuota = old.EntityQuota, + }; + } +} \ 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 From c8f983139e40dfbff06aec888896749478a7a00a Mon Sep 17 00:00:00 2001 From: Toaster2 Date: Wed, 22 Apr 2026 14:41:05 +0200 Subject: [PATCH 04/15] Adjust tests, add new test for entity upload migration --- .../TestEntityUploadRateLimitProperties.cs | 10 ++++ .../TestGameServerConfig.cs | 4 +- .../Configuration/TestRolePermissions.cs | 16 ++++++ .../Tests/Configs/GameServerConfigTests.cs | 50 ++++++++++++++++--- .../Tests/Levels/PublishEndpointsTests.cs | 20 ++++---- 5 files changed, 82 insertions(+), 18 deletions(-) create mode 100644 RefreshTests.GameServer/GameServer/Configuration/TestEntityUploadRateLimitProperties.cs rename RefreshTests.GameServer/GameServer/{ => Configuration}/TestGameServerConfig.cs (85%) create mode 100644 RefreshTests.GameServer/GameServer/Configuration/TestRolePermissions.cs 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/Configs/GameServerConfigTests.cs b/RefreshTests.GameServer/Tests/Configs/GameServerConfigTests.cs index a99569f92..d1cb949f6 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.EntityQuota, Is.EqualTo(2)); + Assert.That(config.TrustedUserPermissions.LevelUploadRateLimit.EntityQuota, 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.EntityQuota, 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.EntityQuota, 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..854f50260 100644 --- a/RefreshTests.GameServer/Tests/Levels/PublishEndpointsTests.cs +++ b/RefreshTests.GameServer/Tests/Levels/PublishEndpointsTests.cs @@ -503,13 +503,13 @@ public void CantPublishAfterExceedingTimedLevelLimit(int levelQuota, int uploadA // Prepare config GameServerConfig config = context.Server.Value.GameServerConfig; - TimedLevelUploadLimitProperties timedLevelLimit = new() + EntityUploadRateLimitProperties timedLevelLimit = new() { Enabled = true, - LevelQuota = levelQuota, + EntityQuota = levelQuota, TimeSpanHours = 1, }; - config.NormalUserPermissions.TimedLevelUploadLimits = timedLevelLimit; + config.NormalUserPermissions.LevelUploadRateLimit = timedLevelLimit; using HttpClient client = context.GetAuthenticatedClient(TokenType.Game, user); @@ -518,7 +518,7 @@ public void CantPublishAfterExceedingTimedLevelLimit(int levelQuota, int uploadA Assert.That(assetUploadMessage.StatusCode, Is.EqualTo(OK)); // Fill up quota - SpamSuccessfulUploads(timedLevelLimit.LevelQuota, client); + SpamSuccessfulUploads(timedLevelLimit.EntityQuota, client); // Try to upload more levels after exceeding quota for (int i = 0; i < uploadAttemptsAfterExceeding; i++) @@ -533,7 +533,7 @@ public void CantPublishAfterExceedingTimedLevelLimit(int levelQuota, int uploadA // Check amount of levels DatabaseList levelsByUser = context.Database.GetLevelsByUser(user, 1000, 0, new(TokenGame.LittleBigPlanet3), user); - Assert.That(levelsByUser.TotalItems, Is.EqualTo(timedLevelLimit.LevelQuota)); + Assert.That(levelsByUser.TotalItems, Is.EqualTo(timedLevelLimit.EntityQuota)); // Ensure there were error notifications sent for each blocked request to both /startPublish and /publish DatabaseList newNotifications = context.Database.GetNotificationsByUser(user, 1000, 0); @@ -550,15 +550,15 @@ public void ResetTimedLevelLimitAfterExpiry(int levelQuota, int uploadAttemptsAf // Prepare config GameServerConfig config = context.Server.Value.GameServerConfig; - TimedLevelUploadLimitProperties timedLevelLimit = new() + EntityUploadRateLimitProperties timedLevelLimit = new() { Enabled = true, - LevelQuota = levelQuota, + EntityQuota = 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; + config.NormalUserPermissions.LevelUploadRateLimit = timedLevelLimit; using HttpClient client = context.GetAuthenticatedClient(TokenType.Game, user); @@ -567,14 +567,14 @@ public void ResetTimedLevelLimitAfterExpiry(int levelQuota, int uploadAttemptsAf Assert.That(message.StatusCode, Is.EqualTo(OK)); // Fill up quota - SpamSuccessfulUploads(timedLevelLimit.LevelQuota, client); + SpamSuccessfulUploads(timedLevelLimit.EntityQuota, client); // Try to upload more levels after exceeding quota SpamSuccessfulUploads(uploadAttemptsAfterExceeding, client); // 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)); + Assert.That(levelsByUser.TotalItems, Is.EqualTo(timedLevelLimit.EntityQuota + uploadAttemptsAfterExceeding)); // Ensure there were no notifications sent DatabaseList newNotifications = context.Database.GetNotificationsByUser(user, 1000, 0); From 47b3a04449bd4045bd040229baeb09685571ec31 Mon Sep 17 00:00:00 2001 From: Toaster2 Date: Wed, 22 Apr 2026 15:04:52 +0200 Subject: [PATCH 05/15] Finally fix level upload rate-limit tests --- .../Tests/Levels/PublishEndpointsTests.cs | 41 ++++++++++++------- 1 file changed, 26 insertions(+), 15 deletions(-) diff --git a/RefreshTests.GameServer/Tests/Levels/PublishEndpointsTests.cs b/RefreshTests.GameServer/Tests/Levels/PublishEndpointsTests.cs index 854f50260..c38f53ed2 100644 --- a/RefreshTests.GameServer/Tests/Levels/PublishEndpointsTests.cs +++ b/RefreshTests.GameServer/Tests/Levels/PublishEndpointsTests.cs @@ -12,6 +12,8 @@ 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; namespace RefreshTests.GameServer.Tests.Levels; @@ -495,7 +497,6 @@ public void CantUnpublishSomeoneElsesLevel() [Test] [TestCase(2, 2)] - [Ignore("needs to change lvl hash every iteration")] // TODO public void CantPublishAfterExceedingTimedLevelLimit(int levelQuota, int uploadAttemptsAfterExceeding) { using TestContext context = this.GetServer(); @@ -518,12 +519,12 @@ public void CantPublishAfterExceedingTimedLevelLimit(int levelQuota, int uploadA Assert.That(assetUploadMessage.StatusCode, Is.EqualTo(OK)); // Fill up quota - SpamSuccessfulUploads(timedLevelLimit.EntityQuota, client); + SpamSuccessfulUploads(timedLevelLimit.EntityQuota, client, 0); // Try to upload more levels after exceeding quota for (int i = 0; i < uploadAttemptsAfterExceeding; i++) { - GameLevelRequest level = CreateValidTestLevel(i + 1); + GameLevelRequest level = PrepareLevelPublishRequest(client, i + timedLevelLimit.EntityQuota); HttpResponseMessage message = client.PostAsync("/lbp/startPublish", new StringContent(level.AsXML())).Result; Assert.That(message.StatusCode, Is.EqualTo(Unauthorized)); @@ -542,7 +543,6 @@ public void CantPublishAfterExceedingTimedLevelLimit(int levelQuota, int uploadA [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(); @@ -567,10 +567,10 @@ public void ResetTimedLevelLimitAfterExpiry(int levelQuota, int uploadAttemptsAf Assert.That(message.StatusCode, Is.EqualTo(OK)); // Fill up quota - SpamSuccessfulUploads(timedLevelLimit.EntityQuota, client); + SpamSuccessfulUploads(timedLevelLimit.EntityQuota, client, 0); // Try to upload more levels after exceeding quota - SpamSuccessfulUploads(uploadAttemptsAfterExceeding, client); + SpamSuccessfulUploads(uploadAttemptsAfterExceeding, client, timedLevelLimit.EntityQuota); // Check amount of levels DatabaseList levelsByUser = context.Database.GetLevelsByUser(user, 1000, 0, new(TokenGame.LittleBigPlanet3), user); @@ -581,13 +581,13 @@ public void ResetTimedLevelLimitAfterExpiry(int levelQuota, int uploadAttemptsAf Assert.That(newNotifications.TotalItems, Is.EqualTo(0)); } - private void SpamSuccessfulUploads(int uploads, HttpClient client) + private void SpamSuccessfulUploads(int uploads, HttpClient client, int startIndex) { for (int i = 0; i < uploads; i++) { - GameLevelRequest level = CreateValidTestLevel(i + 1); - // Upload level + GameLevelRequest level = PrepareLevelPublishRequest(client, startIndex + i); + HttpResponseMessage message = client.PostAsync("/lbp/startPublish", new StringContent(level.AsXML())).Result; Assert.That(message.StatusCode, Is.EqualTo(OK)); message = client.PostAsync("/lbp/publish", new StringContent(level.AsXML())).Result; @@ -595,15 +595,26 @@ private void SpamSuccessfulUploads(int uploads, HttpClient client) } } - private GameLevelRequest CreateValidTestLevel(int id) - => new() + private GameLevelRequest PrepareLevelPublishRequest(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() From 6eeb0fe7e8bd045a188898e31c01ea642a351ecb Mon Sep 17 00:00:00 2001 From: Toaster2 Date: Wed, 22 Apr 2026 16:44:23 +0200 Subject: [PATCH 06/15] Add RateLevelRelation to GameDatabaseEntity --- Refresh.Database/Models/GameDatabaseEntity.cs | 1 + 1 file changed, 1 insertion(+) diff --git a/Refresh.Database/Models/GameDatabaseEntity.cs b/Refresh.Database/Models/GameDatabaseEntity.cs index d8c31a37f..29a6491cb 100644 --- a/Refresh.Database/Models/GameDatabaseEntity.cs +++ b/Refresh.Database/Models/GameDatabaseEntity.cs @@ -5,6 +5,7 @@ public enum GameDatabaseEntity : byte User, Level, Score, + RateLevelRelation, Photo, Review, LevelComment, From 4eb37f4eb121561bc5f95c6502697d0d2da87a61 Mon Sep 17 00:00:00 2001 From: Toaster2 Date: Mon, 27 Apr 2026 17:11:40 +0200 Subject: [PATCH 07/15] Auto-remove expired rate-limits --- Refresh.Database/GameDatabaseContext.Users.cs | 40 ++++++++++++++++--- 1 file changed, 34 insertions(+), 6 deletions(-) diff --git a/Refresh.Database/GameDatabaseContext.Users.cs b/Refresh.Database/GameDatabaseContext.Users.cs index 3bce834e8..40832aa5a 100644 --- a/Refresh.Database/GameDatabaseContext.Users.cs +++ b/Refresh.Database/GameDatabaseContext.Users.cs @@ -629,21 +629,49 @@ public void SetUserPresenceAuthToken(GameUser user, string? token) }); } - public EntityUploadRateLimit? GetUploadRateLimit(GameUser user, GameDatabaseEntity entity) - => this.EntityUploadRateLimits.FirstOrDefault(r => r.UserId == user.UserId && r.Entity == entity); + public EntityUploadRateLimit? GetUploadRateLimit(GameUser user, GameDatabaseEntity entity, bool save = true) + { + EntityUploadRateLimit? limit = this.EntityUploadRateLimits.FirstOrDefault(r => r.UserId == user.UserId && r.Entity == entity); + + // remove if expired + if (limit != null && limit.ExpiryDate <= this._time.Now) + { + this.EntityUploadRateLimits.Remove(limit); + if (save) this.SaveChanges(); + return null; + } + + return limit; + } + + /// + /// Time until expiry date if the corresponding upload rate-limit has been reached, otherwise null + /// + public TimeSpan? GetRemainingTimeIfUploadRateLimitReached(GameUser user, GameDatabaseEntity entity, int uploadQuota) + { + 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) + { + return limit.ExpiryDate - now; + } + + return null; + } public void IncrementUploadRateLimitForEntity(GameUser user, GameDatabaseEntity entity, int timeSpanHours) { - EntityUploadRateLimit? existingLimit = this.GetUploadRateLimit(user, entity); + EntityUploadRateLimit? existingLimit = this.GetUploadRateLimit(user, entity, false); // will be null if expired already, see above DateTimeOffset now = this._time.Now; - if (existingLimit == null || existingLimit.ExpiryDate <= now) + if (existingLimit == null) { EntityUploadRateLimit newLimit = new() { Entity = entity, User = user, - EntityQuota = 1, + UploadCount = 1, ExpiryDate = now + TimeSpan.FromHours(timeSpanHours), }; this.EntityUploadRateLimits.Add(newLimit); @@ -651,7 +679,7 @@ public void IncrementUploadRateLimitForEntity(GameUser user, GameDatabaseEntity else { this.EntityUploadRateLimits.Update(existingLimit); - existingLimit.EntityQuota++; + existingLimit.UploadCount++; } this.SaveChanges(); From d1ab943e57835cc61b51413d25219bad555fe5b0 Mon Sep 17 00:00:00 2001 From: Toaster2 Date: Mon, 27 Apr 2026 17:13:38 +0200 Subject: [PATCH 08/15] Actually block playlists/photos if too rapidly uploaded, minor level block rework --- .../Endpoints/Levels/PublishEndpoints.cs | 38 ++++++------------- .../Endpoints/PhotoEndpoints.cs | 18 +++++++++ .../Playlists/Lbp1PlaylistEndpoints.cs | 18 ++++++++- .../Playlists/Lbp3PlaylistEndpoints.cs | 12 ++++++ 4 files changed, 58 insertions(+), 28 deletions(-) diff --git a/Refresh.Interfaces.Game/Endpoints/Levels/PublishEndpoints.cs b/Refresh.Interfaces.Game/Endpoints/Levels/PublishEndpoints.cs index 1a2ebe4c6..2fe2f8f67 100644 --- a/Refresh.Interfaces.Game/Endpoints/Levels/PublishEndpoints.cs +++ b/Refresh.Interfaces.Game/Endpoints/Levels/PublishEndpoints.cs @@ -79,36 +79,24 @@ private static bool VerifyLevel(GameLevelRequest body, DataContext dataContext) return true; } - private static bool IsTimedLevelLimitReached(DataContext dataContext, GameUser user, string levelTitle, EntityUploadRateLimitProperties config, DateTimeOffset now) + private static bool IsTimedLevelLimitReached(DataContext dataContext, GameUser user, string levelTitle, EntityUploadRateLimitProperties levelLimit) { - if (!config.Enabled) return false; + if (!levelLimit.Enabled) return false; - EntityUploadRateLimit? rateLimit = dataContext.Database.GetUploadRateLimit(user, GameDatabaseEntity.Level); - if (rateLimit == null) return false; - - // If the expiration date has expired (less than now), reset user's limit and continue. - if (now >= rateLimit.ExpiryDate) + TimeSpan? rateLimitExpiresIn = dataContext.Database.GetRemainingTimeIfUploadRateLimitReached(user, GameDatabaseEntity.Photo, levelLimit.EntityQuota); + if (rateLimitExpiresIn != null) { - dataContext.Database.ResetUploadRateLimit(user, GameDatabaseEntity.Level); - return false; - } - // If expiration date has not expired yet and the user has reached the limit, block. - else if (rateLimit.EntityQuota >= config.EntityQuota) - { - TimeSpan remainingTime = rateLimit.ExpiryDate - now; dataContext.Database.AddPublishFailNotification ( - $"You have reached your timed level upload limit of {config.EntityQuota} levels per {config.TimeSpanHours} hours. " + - $"Your limit will expire in {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.EntityQuota} 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)] @@ -118,7 +106,6 @@ public Response StartPublish(RequestContext context, GameLevelRequest body, DataContext dataContext, GameServerConfig config, - IDateTimeProvider dateTimeProvider, GameUser user) { if (dataContext.User!.IsWriteBlocked(config)) @@ -127,7 +114,7 @@ public Response StartPublish(RequestContext context, return Unauthorized; } - if (IsTimedLevelLimitReached(dataContext, dataContext.User!, body.Title, user.GetRolePermissionsForUser(config).LevelUploadRateLimit, dateTimeProvider.Now)) + if (IsTimedLevelLimitReached(dataContext, dataContext.User!, body.Title, user.GetRolePermissionsForUser(config).LevelUploadRateLimit)) return Unauthorized; //If verifying the request fails, return BadRequest @@ -176,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; EntityUploadRateLimitProperties timedLevelLimit = user.GetRolePermissionsForUser(config).LevelUploadRateLimit; - if (IsTimedLevelLimitReached(dataContext, user, body.Title, timedLevelLimit, dateTimeProvider.Now)) + if (IsTimedLevelLimitReached(dataContext, user, body.Title, timedLevelLimit)) return Unauthorized; //If verifying the request fails, return BadRequest diff --git a/Refresh.Interfaces.Game/Endpoints/PhotoEndpoints.cs b/Refresh.Interfaces.Game/Endpoints/PhotoEndpoints.cs index 24bedbac2..50846931b 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; @@ -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.EntityQuota); + if (rateLimitExpiresIn != null) + { + dataContext.Database.AddErrorNotification + ( + "Photo upload failed", + $"You have uploaded too many photos recently! Your limit is {uploadLimit.EntityQuota} 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) || diff --git a/Refresh.Interfaces.Game/Endpoints/Playlists/Lbp1PlaylistEndpoints.cs b/Refresh.Interfaces.Game/Endpoints/Playlists/Lbp1PlaylistEndpoints.cs index a844fdb42..722e690cb 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; @@ -63,8 +64,21 @@ public Response CreatePlaylist(RequestContext context, DataContext dataContext, GamePlaylist? rootPlaylist = dataContext.Database.GetUserRootPlaylist(user); // 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; + + EntityUploadRateLimitProperties uploadLimit = user.GetRolePermissionsForUser(config).PlaylistUploadRateLimit; + if (uploadLimit.Enabled) + { + TimeSpan? rateLimitExpiresIn = dataContext.Database.GetRemainingTimeIfUploadRateLimitReached(user, GameDatabaseEntity.Playlist, uploadLimit.EntityQuota); + 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)) diff --git a/Refresh.Interfaces.Game/Endpoints/Playlists/Lbp3PlaylistEndpoints.cs b/Refresh.Interfaces.Game/Endpoints/Playlists/Lbp3PlaylistEndpoints.cs index 9a93d8f73..a9f1f1e8d 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.EntityQuota); + if (rateLimitExpiresIn != null) + { + // no need for a notification, because playlists are cheap, especially in lbp3 + return Unauthorized; + } + } GamePlaylist? rootPlaylist = dataContext.Database.GetUserRootPlaylist(user); From 1d82b798944e15122172ef5c1adfbdbdb948b866 Mon Sep 17 00:00:00 2001 From: Toaster2 Date: Mon, 27 Apr 2026 17:25:42 +0200 Subject: [PATCH 09/15] Loosen endpoint rate-limits for photo/playlist creation, now that proper upload limits exist --- .../Playlists/PlaylistCreationEndpointLimits.cs | 8 ++++---- Refresh.Interfaces.Game/Endpoints/PhotoEndpoints.cs | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) 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.Interfaces.Game/Endpoints/PhotoEndpoints.cs b/Refresh.Interfaces.Game/Endpoints/PhotoEndpoints.cs index 50846931b..30e5329c1 100644 --- a/Refresh.Interfaces.Game/Endpoints/PhotoEndpoints.cs +++ b/Refresh.Interfaces.Game/Endpoints/PhotoEndpoints.cs @@ -24,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) From df533644ff45da76361254d6b61fa4878acccf67 Mon Sep 17 00:00:00 2001 From: Toaster2 Date: Mon, 27 Apr 2026 17:36:53 +0200 Subject: [PATCH 10/15] More renames --- .../EntityUploadRateLimitProperties.cs | 4 ++-- Refresh.Core/Configuration/GameServerConfig.cs | 10 +++++----- Refresh.Core/Configuration/RolePermissions.cs | 6 +++--- ...81503_SplitUploadRateLimitsToSeparateTable.cs | 2 +- .../GameDatabaseContextModelSnapshot.cs | 2 +- .../Models/Users/EntityUploadRateLimit.cs | 2 +- .../Response/ApiEntityRateLimitResponse.cs | 4 ++-- .../Endpoints/Levels/PublishEndpoints.cs | 4 ++-- .../Endpoints/PhotoEndpoints.cs | 4 ++-- .../Endpoints/Playlists/Lbp1PlaylistEndpoints.cs | 2 +- .../Endpoints/Playlists/Lbp3PlaylistEndpoints.cs | 2 +- .../Tests/Configs/GameServerConfigTests.cs | 8 ++++---- .../Tests/Levels/PublishEndpointsTests.cs | 16 ++++++++-------- 13 files changed, 33 insertions(+), 33 deletions(-) diff --git a/Refresh.Core/Configuration/EntityUploadRateLimitProperties.cs b/Refresh.Core/Configuration/EntityUploadRateLimitProperties.cs index d3445364f..2a9577f56 100644 --- a/Refresh.Core/Configuration/EntityUploadRateLimitProperties.cs +++ b/Refresh.Core/Configuration/EntityUploadRateLimitProperties.cs @@ -13,7 +13,7 @@ public EntityUploadRateLimitProperties() {} /// public int TimeSpanHours { get; set; } /// - /// The amount of entities (levels/photos/playlists) the user is allowed to upload during the specified time span + /// The amount of entities the user is allowed to upload during the specified time span /// - public int EntityQuota { get; set; } + 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 7a275e829..573d6aa1b 100644 --- a/Refresh.Core/Configuration/GameServerConfig.cs +++ b/Refresh.Core/Configuration/GameServerConfig.cs @@ -84,11 +84,11 @@ protected override void Migrate(int oldVer, dynamic oldConfig) { this.NormalUserPermissions.LevelUploadRateLimit.Enabled = (bool)oldConfig.TimedLevelUploadLimits.Enabled; this.NormalUserPermissions.LevelUploadRateLimit.TimeSpanHours = (int)oldConfig.TimedLevelUploadLimits.TimeSpanHours; - this.NormalUserPermissions.LevelUploadRateLimit.EntityQuota = (int)oldConfig.TimedLevelUploadLimits.LevelQuota; + this.NormalUserPermissions.LevelUploadRateLimit.UploadQuota = (int)oldConfig.TimedLevelUploadLimits.LevelQuota; this.TrustedUserPermissions.LevelUploadRateLimit.Enabled = (bool)oldConfig.TimedLevelUploadLimits.Enabled; this.TrustedUserPermissions.LevelUploadRateLimit.TimeSpanHours = (int)oldConfig.TimedLevelUploadLimits.TimeSpanHours; - this.TrustedUserPermissions.LevelUploadRateLimit.EntityQuota = (int)oldConfig.TimedLevelUploadLimits.LevelQuota; + this.TrustedUserPermissions.LevelUploadRateLimit.UploadQuota = (int)oldConfig.TimedLevelUploadLimits.LevelQuota; } // Read-only mode was added for both normal and trusted users in version 20. @@ -100,17 +100,17 @@ protected override void Migrate(int oldVer, dynamic oldConfig) } // 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 EntityQuota, + // 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.EntityQuota = (int)oldConfig.NormalUserPermissions.TimedLevelUploadLimits.LevelQuota; + 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.EntityQuota = (int)oldConfig.TrustedUserPermissions.TimedLevelUploadLimits.LevelQuota; + this.TrustedUserPermissions.LevelUploadRateLimit.UploadQuota = (int)oldConfig.TrustedUserPermissions.TimedLevelUploadLimits.LevelQuota; } } diff --git a/Refresh.Core/Configuration/RolePermissions.cs b/Refresh.Core/Configuration/RolePermissions.cs index 7f2c26a2e..f381c60fc 100644 --- a/Refresh.Core/Configuration/RolePermissions.cs +++ b/Refresh.Core/Configuration/RolePermissions.cs @@ -12,21 +12,21 @@ public RolePermissions() {} { Enabled = false, TimeSpanHours = 24, - EntityQuota = 10, + UploadQuota = 10, }; public EntityUploadRateLimitProperties PhotoUploadRateLimit = new() { Enabled = false, TimeSpanHours = 24, - EntityQuota = 10, + UploadQuota = 10, }; public EntityUploadRateLimitProperties PlaylistUploadRateLimit = new() { Enabled = false, TimeSpanHours = 24, - EntityQuota = 8, + UploadQuota = 8, }; /// diff --git a/Refresh.Database/Migrations/20260421181503_SplitUploadRateLimitsToSeparateTable.cs b/Refresh.Database/Migrations/20260421181503_SplitUploadRateLimitsToSeparateTable.cs index 4c4369671..3f5e70d77 100644 --- a/Refresh.Database/Migrations/20260421181503_SplitUploadRateLimitsToSeparateTable.cs +++ b/Refresh.Database/Migrations/20260421181503_SplitUploadRateLimitsToSeparateTable.cs @@ -29,7 +29,7 @@ protected override void Up(MigrationBuilder migrationBuilder) { Entity = table.Column(type: "smallint", nullable: false), UserId = table.Column(type: "text", nullable: false), - EntityQuota = table.Column(type: "integer", nullable: false), + UploadCount = table.Column(type: "integer", nullable: false), ExpiryDate = table.Column(type: "timestamp with time zone", nullable: false) }, constraints: table => diff --git a/Refresh.Database/Migrations/GameDatabaseContextModelSnapshot.cs b/Refresh.Database/Migrations/GameDatabaseContextModelSnapshot.cs index 8b89041a3..b04f829c6 100644 --- a/Refresh.Database/Migrations/GameDatabaseContextModelSnapshot.cs +++ b/Refresh.Database/Migrations/GameDatabaseContextModelSnapshot.cs @@ -1602,7 +1602,7 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Property("Entity") .HasColumnType("smallint"); - b.Property("EntityQuota") + b.Property("UploadCount") .HasColumnType("integer"); b.Property("ExpiryDate") diff --git a/Refresh.Database/Models/Users/EntityUploadRateLimit.cs b/Refresh.Database/Models/Users/EntityUploadRateLimit.cs index 00fa33156..4812f2c78 100644 --- a/Refresh.Database/Models/Users/EntityUploadRateLimit.cs +++ b/Refresh.Database/Models/Users/EntityUploadRateLimit.cs @@ -18,7 +18,7 @@ public partial class EntityUploadRateLimit /// /// How many entites of the specified type the user has uploaded during the rate-limit /// - public int EntityQuota { get; set; } + public int UploadCount { get; set; } /// /// When this rate-limit should be expired /// diff --git a/Refresh.Interfaces.APIv3/Endpoints/DataTypes/Response/ApiEntityRateLimitResponse.cs b/Refresh.Interfaces.APIv3/Endpoints/DataTypes/Response/ApiEntityRateLimitResponse.cs index 7e3f1b5f8..fbc20b76c 100644 --- a/Refresh.Interfaces.APIv3/Endpoints/DataTypes/Response/ApiEntityRateLimitResponse.cs +++ b/Refresh.Interfaces.APIv3/Endpoints/DataTypes/Response/ApiEntityRateLimitResponse.cs @@ -6,7 +6,7 @@ namespace Refresh.Interfaces.APIv3.Endpoints.DataTypes.Response; public class ApiEntityRateLimitResponse : IApiResponse { public required int TimeSpanHours { get; set; } - public required int EntityQuota { get; set; } + public required int UploadQuota { get; set; } public static ApiEntityRateLimitResponse? FromOld(EntityUploadRateLimitProperties old) { @@ -15,7 +15,7 @@ public class ApiEntityRateLimitResponse : IApiResponse return new() { TimeSpanHours = old.TimeSpanHours, - EntityQuota = old.EntityQuota, + UploadQuota = old.UploadQuota, }; } } \ No newline at end of file diff --git a/Refresh.Interfaces.Game/Endpoints/Levels/PublishEndpoints.cs b/Refresh.Interfaces.Game/Endpoints/Levels/PublishEndpoints.cs index 2fe2f8f67..52ca4bafb 100644 --- a/Refresh.Interfaces.Game/Endpoints/Levels/PublishEndpoints.cs +++ b/Refresh.Interfaces.Game/Endpoints/Levels/PublishEndpoints.cs @@ -83,12 +83,12 @@ private static bool IsTimedLevelLimitReached(DataContext dataContext, GameUser u { if (!levelLimit.Enabled) return false; - TimeSpan? rateLimitExpiresIn = dataContext.Database.GetRemainingTimeIfUploadRateLimitReached(user, GameDatabaseEntity.Photo, levelLimit.EntityQuota); + TimeSpan? rateLimitExpiresIn = dataContext.Database.GetRemainingTimeIfUploadRateLimitReached(user, GameDatabaseEntity.Photo, levelLimit.UploadQuota); if (rateLimitExpiresIn != null) { dataContext.Database.AddPublishFailNotification ( - $"You have published too many levels recently! Your limit is {levelLimit.EntityQuota} levels per {levelLimit.TimeSpanHours} hours. " + + $"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 diff --git a/Refresh.Interfaces.Game/Endpoints/PhotoEndpoints.cs b/Refresh.Interfaces.Game/Endpoints/PhotoEndpoints.cs index 30e5329c1..6b89cb008 100644 --- a/Refresh.Interfaces.Game/Endpoints/PhotoEndpoints.cs +++ b/Refresh.Interfaces.Game/Endpoints/PhotoEndpoints.cs @@ -35,13 +35,13 @@ public Response UploadPhoto(RequestContext context, SerializedPhoto body, GameDa EntityUploadRateLimitProperties uploadLimit = user.GetRolePermissionsForUser(config).PhotoUploadRateLimit; if (uploadLimit.Enabled) { - TimeSpan? rateLimitExpiresIn = database.GetRemainingTimeIfUploadRateLimitReached(user, GameDatabaseEntity.Photo, uploadLimit.EntityQuota); + 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.EntityQuota} photos per {uploadLimit.TimeSpanHours} hours. " + + $"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 ); diff --git a/Refresh.Interfaces.Game/Endpoints/Playlists/Lbp1PlaylistEndpoints.cs b/Refresh.Interfaces.Game/Endpoints/Playlists/Lbp1PlaylistEndpoints.cs index 722e690cb..deb9e5a25 100644 --- a/Refresh.Interfaces.Game/Endpoints/Playlists/Lbp1PlaylistEndpoints.cs +++ b/Refresh.Interfaces.Game/Endpoints/Playlists/Lbp1PlaylistEndpoints.cs @@ -71,7 +71,7 @@ public Response CreatePlaylist(RequestContext context, DataContext dataContext, EntityUploadRateLimitProperties uploadLimit = user.GetRolePermissionsForUser(config).PlaylistUploadRateLimit; if (uploadLimit.Enabled) { - TimeSpan? rateLimitExpiresIn = dataContext.Database.GetRemainingTimeIfUploadRateLimitReached(user, GameDatabaseEntity.Playlist, uploadLimit.EntityQuota); + 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 diff --git a/Refresh.Interfaces.Game/Endpoints/Playlists/Lbp3PlaylistEndpoints.cs b/Refresh.Interfaces.Game/Endpoints/Playlists/Lbp3PlaylistEndpoints.cs index a9f1f1e8d..b83ab1ae4 100644 --- a/Refresh.Interfaces.Game/Endpoints/Playlists/Lbp3PlaylistEndpoints.cs +++ b/Refresh.Interfaces.Game/Endpoints/Playlists/Lbp3PlaylistEndpoints.cs @@ -35,7 +35,7 @@ public Response CreatePlaylist(RequestContext context, GameServerConfig config, EntityUploadRateLimitProperties uploadLimit = user.GetRolePermissionsForUser(config).PlaylistUploadRateLimit; if (uploadLimit.Enabled) { - TimeSpan? rateLimitExpiresIn = dataContext.Database.GetRemainingTimeIfUploadRateLimitReached(user, GameDatabaseEntity.Playlist, uploadLimit.EntityQuota); + 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 diff --git a/RefreshTests.GameServer/Tests/Configs/GameServerConfigTests.cs b/RefreshTests.GameServer/Tests/Configs/GameServerConfigTests.cs index d1cb949f6..df8b29741 100644 --- a/RefreshTests.GameServer/Tests/Configs/GameServerConfigTests.cs +++ b/RefreshTests.GameServer/Tests/Configs/GameServerConfigTests.cs @@ -35,8 +35,8 @@ public void MigratesRolePermsFromVersion26() Assert.That(config.NormalUserPermissions.LevelUploadRateLimit.TimeSpanHours, Is.EqualTo(67)); Assert.That(config.TrustedUserPermissions.LevelUploadRateLimit.TimeSpanHours, Is.EqualTo(67)); - Assert.That(config.NormalUserPermissions.LevelUploadRateLimit.EntityQuota, Is.EqualTo(2)); - Assert.That(config.TrustedUserPermissions.LevelUploadRateLimit.EntityQuota, 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)); @@ -75,11 +75,11 @@ public void MigratesEntityUploadRateLimitsFromVersion27() Assert.That(config.NormalUserPermissions.LevelUploadRateLimit.Enabled, Is.True); Assert.That(config.NormalUserPermissions.LevelUploadRateLimit.TimeSpanHours, Is.EqualTo(1234567)); - Assert.That(config.NormalUserPermissions.LevelUploadRateLimit.EntityQuota, Is.EqualTo(852094)); + 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.EntityQuota, Is.EqualTo(7122036)); + Assert.That(config.TrustedUserPermissions.LevelUploadRateLimit.UploadQuota, Is.EqualTo(7122036)); } [Test] diff --git a/RefreshTests.GameServer/Tests/Levels/PublishEndpointsTests.cs b/RefreshTests.GameServer/Tests/Levels/PublishEndpointsTests.cs index c38f53ed2..6420ff845 100644 --- a/RefreshTests.GameServer/Tests/Levels/PublishEndpointsTests.cs +++ b/RefreshTests.GameServer/Tests/Levels/PublishEndpointsTests.cs @@ -507,7 +507,7 @@ public void CantPublishAfterExceedingTimedLevelLimit(int levelQuota, int uploadA EntityUploadRateLimitProperties timedLevelLimit = new() { Enabled = true, - EntityQuota = levelQuota, + UploadQuota = levelQuota, TimeSpanHours = 1, }; config.NormalUserPermissions.LevelUploadRateLimit = timedLevelLimit; @@ -519,12 +519,12 @@ public void CantPublishAfterExceedingTimedLevelLimit(int levelQuota, int uploadA Assert.That(assetUploadMessage.StatusCode, Is.EqualTo(OK)); // Fill up quota - SpamSuccessfulUploads(timedLevelLimit.EntityQuota, client, 0); + SpamSuccessfulUploads(timedLevelLimit.UploadQuota, client, 0); // Try to upload more levels after exceeding quota for (int i = 0; i < uploadAttemptsAfterExceeding; i++) { - GameLevelRequest level = PrepareLevelPublishRequest(client, i + timedLevelLimit.EntityQuota); + GameLevelRequest level = PrepareLevelPublishRequest(client, i + timedLevelLimit.UploadQuota); HttpResponseMessage message = client.PostAsync("/lbp/startPublish", new StringContent(level.AsXML())).Result; Assert.That(message.StatusCode, Is.EqualTo(Unauthorized)); @@ -534,7 +534,7 @@ public void CantPublishAfterExceedingTimedLevelLimit(int levelQuota, int uploadA // Check amount of levels DatabaseList levelsByUser = context.Database.GetLevelsByUser(user, 1000, 0, new(TokenGame.LittleBigPlanet3), user); - Assert.That(levelsByUser.TotalItems, Is.EqualTo(timedLevelLimit.EntityQuota)); + Assert.That(levelsByUser.TotalItems, Is.EqualTo(timedLevelLimit.UploadQuota)); // Ensure there were error notifications sent for each blocked request to both /startPublish and /publish DatabaseList newNotifications = context.Database.GetNotificationsByUser(user, 1000, 0); @@ -553,7 +553,7 @@ public void ResetTimedLevelLimitAfterExpiry(int levelQuota, int uploadAttemptsAf EntityUploadRateLimitProperties timedLevelLimit = new() { Enabled = true, - EntityQuota = levelQuota, + UploadQuota = 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, @@ -567,14 +567,14 @@ public void ResetTimedLevelLimitAfterExpiry(int levelQuota, int uploadAttemptsAf Assert.That(message.StatusCode, Is.EqualTo(OK)); // Fill up quota - SpamSuccessfulUploads(timedLevelLimit.EntityQuota, client, 0); + SpamSuccessfulUploads(timedLevelLimit.UploadQuota, client, 0); // Try to upload more levels after exceeding quota - SpamSuccessfulUploads(uploadAttemptsAfterExceeding, client, timedLevelLimit.EntityQuota); + SpamSuccessfulUploads(uploadAttemptsAfterExceeding, client, timedLevelLimit.UploadQuota); // Check amount of levels DatabaseList levelsByUser = context.Database.GetLevelsByUser(user, 1000, 0, new(TokenGame.LittleBigPlanet3), user); - Assert.That(levelsByUser.TotalItems, Is.EqualTo(timedLevelLimit.EntityQuota + uploadAttemptsAfterExceeding)); + Assert.That(levelsByUser.TotalItems, Is.EqualTo(timedLevelLimit.UploadQuota + uploadAttemptsAfterExceeding)); // Ensure there were no notifications sent DatabaseList newNotifications = context.Database.GetNotificationsByUser(user, 1000, 0); From 7075d47125e737246d9c81c551ccdb082f01eb52 Mon Sep 17 00:00:00 2001 From: Toaster2 Date: Tue, 28 Apr 2026 10:40:45 +0200 Subject: [PATCH 11/15] lol --- Refresh.Interfaces.Game/Endpoints/Levels/PublishEndpoints.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Refresh.Interfaces.Game/Endpoints/Levels/PublishEndpoints.cs b/Refresh.Interfaces.Game/Endpoints/Levels/PublishEndpoints.cs index 52ca4bafb..9fbfc2765 100644 --- a/Refresh.Interfaces.Game/Endpoints/Levels/PublishEndpoints.cs +++ b/Refresh.Interfaces.Game/Endpoints/Levels/PublishEndpoints.cs @@ -83,7 +83,7 @@ private static bool IsTimedLevelLimitReached(DataContext dataContext, GameUser u { if (!levelLimit.Enabled) return false; - TimeSpan? rateLimitExpiresIn = dataContext.Database.GetRemainingTimeIfUploadRateLimitReached(user, GameDatabaseEntity.Photo, levelLimit.UploadQuota); + TimeSpan? rateLimitExpiresIn = dataContext.Database.GetRemainingTimeIfUploadRateLimitReached(user, GameDatabaseEntity.Level, levelLimit.UploadQuota); if (rateLimitExpiresIn != null) { dataContext.Database.AddPublishFailNotification From 29515924fe7a563cc72d1b0489cf6e04b3e14ce3 Mon Sep 17 00:00:00 2001 From: Toaster2 Date: Tue, 28 Apr 2026 11:51:42 +0200 Subject: [PATCH 12/15] Forgot to enforce playlist limit on API endpoint --- .../Endpoints/PlaylistApiEndpoints.cs | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) 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); From 4460cb7c858d480148b4b291a0661bb7f6ce8d4c Mon Sep 17 00:00:00 2001 From: Toaster2 Date: Tue, 28 Apr 2026 11:53:16 +0200 Subject: [PATCH 13/15] Forgot to increment upload limits on game endpoints --- Refresh.Interfaces.Game/Endpoints/PhotoEndpoints.cs | 5 +++++ .../Endpoints/Playlists/Lbp1PlaylistEndpoints.cs | 9 +++++++-- .../Endpoints/Playlists/Lbp3PlaylistEndpoints.cs | 4 ++++ 3 files changed, 16 insertions(+), 2 deletions(-) diff --git a/Refresh.Interfaces.Game/Endpoints/PhotoEndpoints.cs b/Refresh.Interfaces.Game/Endpoints/PhotoEndpoints.cs index 6b89cb008..144d2b825 100644 --- a/Refresh.Interfaces.Game/Endpoints/PhotoEndpoints.cs +++ b/Refresh.Interfaces.Game/Endpoints/PhotoEndpoints.cs @@ -93,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 deb9e5a25..3f3532fb3 100644 --- a/Refresh.Interfaces.Game/Endpoints/Playlists/Lbp1PlaylistEndpoints.cs +++ b/Refresh.Interfaces.Game/Endpoints/Playlists/Lbp1PlaylistEndpoints.cs @@ -62,13 +62,13 @@ 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 (rootPlaylist != null) { 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); @@ -111,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 b83ab1ae4..31bd16442 100644 --- a/Refresh.Interfaces.Game/Endpoints/Playlists/Lbp3PlaylistEndpoints.cs +++ b/Refresh.Interfaces.Game/Endpoints/Playlists/Lbp3PlaylistEndpoints.cs @@ -57,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 From 3a7ea22cf55af77d11eeb13e0fb864cd12643656 Mon Sep 17 00:00:00 2001 From: Toaster2 Date: Wed, 29 Apr 2026 19:46:56 +0200 Subject: [PATCH 14/15] Actually test upload rate-limits for levels/photos/playlists --- .../Tests/Levels/PublishEndpointsTests.cs | 121 ++++++-------- .../Tests/Photos/PhotoEndpointsTests.cs | 115 +++++++++++++ .../Tests/Playlists/PlaylistUploadTests.cs | 156 +++++++++++++++--- 3 files changed, 300 insertions(+), 92 deletions(-) diff --git a/RefreshTests.GameServer/Tests/Levels/PublishEndpointsTests.cs b/RefreshTests.GameServer/Tests/Levels/PublishEndpointsTests.cs index 6420ff845..51a9d3cd0 100644 --- a/RefreshTests.GameServer/Tests/Levels/PublishEndpointsTests.cs +++ b/RefreshTests.GameServer/Tests/Levels/PublishEndpointsTests.cs @@ -14,6 +14,7 @@ using Refresh.Interfaces.Game.Types.Levels; using System.Security.Cryptography; using System.Text; +using System.Net; namespace RefreshTests.GameServer.Tests.Levels; @@ -496,113 +497,91 @@ public void CantUnpublishSomeoneElsesLevel() } [Test] - [TestCase(2, 2)] - 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; - EntityUploadRateLimitProperties timedLevelLimit = new() + EntityUploadRateLimitProperties uploadConfig = new() { Enabled = true, UploadQuota = levelQuota, - TimeSpanHours = 1, + TimeSpanHours = timeSpanHours, }; - config.NormalUserPermissions.LevelUploadRateLimit = 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.UploadQuota, client, 0); - - // Try to upload more levels after exceeding quota - for (int i = 0; i < uploadAttemptsAfterExceeding; i++) - { - GameLevelRequest level = PrepareLevelPublishRequest(client, i + timedLevelLimit.UploadQuota); - - 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.UploadQuota)); - - // 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)] - 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; - EntityUploadRateLimitProperties timedLevelLimit = new() - { - Enabled = true, - UploadQuota = 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.LevelUploadRateLimit = 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.UploadQuota, client, 0); - - // Try to upload more levels after exceeding quota - SpamSuccessfulUploads(uploadAttemptsAfterExceeding, client, timedLevelLimit.UploadQuota); + // 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.UploadQuota + 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, int startIndex) + private int SpamPublishUniqueLevels(int uploads, HttpClient client, int startIndex, HttpStatusCode expectedStatus = OK) { for (int i = 0; i < uploads; i++) { // Upload level - GameLevelRequest level = PrepareLevelPublishRequest(client, startIndex + i); + 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 PrepareLevelPublishRequest(HttpClient client, int uniqueValue) + 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(); + 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)); 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 From 32ddd587af397b7ec5b6db46e15cb7b121b05bbe Mon Sep 17 00:00:00 2001 From: Toaster2 Date: Wed, 29 Apr 2026 19:53:47 +0200 Subject: [PATCH 15/15] Test the playlist API aswell --- .../Tests/ApiV3/PlaylistApiTests.cs | 85 +++++++++++++++++++ 1 file changed, 85 insertions(+) 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() {