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