Skip to content

Commit 2ec2e74

Browse files
authored
Add configurable upload rate-limits for photos and playlists (#1080)
Does various renames and reworks to the configurable level upload rate-limit feature to make it easily usable on other kinds of entities aswell, and adds such rate-limits for photos and playlists. Also, user-related rate-limit data stored in DB is now stored in a separate table instead of `GameUsers`. Needs a few more tests before this can be merged, and while I have managed to fix the level rate-limit tests which were being skipped before, they have broken again now, so they need to also be fixed again before the merge.
2 parents e6cea77 + 32ddd58 commit 2ec2e74

28 files changed

Lines changed: 851 additions & 218 deletions
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
namespace Refresh.Core.Configuration;
2+
3+
public class EntityUploadRateLimitProperties
4+
{
5+
public EntityUploadRateLimitProperties() {}
6+
7+
/// <summary>
8+
/// Whether to rate-limit uploads of a certain entity (level/photo/playlist) using the database
9+
/// </summary>
10+
public bool Enabled { get; set; }
11+
/// <summary>
12+
/// The duration of this rate-limit
13+
/// </summary>
14+
public int TimeSpanHours { get; set; }
15+
/// <summary>
16+
/// The amount of entities the user is allowed to upload during the specified time span
17+
/// </summary>
18+
public int UploadQuota { get; set; }
19+
}

Refresh.Core/Configuration/GameServerConfig.cs

Lines changed: 21 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ namespace Refresh.Core.Configuration;
88
[SuppressMessage("ReSharper", "RedundantDefaultMemberInitializer")]
99
public class GameServerConfig : Config
1010
{
11-
public override int CurrentConfigVersion => 27;
11+
public override int CurrentConfigVersion => 28;
1212
public override int Version { get; set; } = 0;
1313

1414
protected override void Migrate(int oldVer, dynamic oldConfig)
@@ -82,13 +82,13 @@ protected override void Migrate(int oldVer, dynamic oldConfig)
8282
// Timed level upload limits were added in version 19.
8383
if (oldVer >= 19)
8484
{
85-
this.NormalUserPermissions.TimedLevelUploadLimits.Enabled = (bool)oldConfig.TimedLevelUploadLimits.Enabled;
86-
this.NormalUserPermissions.TimedLevelUploadLimits.TimeSpanHours = (int)oldConfig.TimedLevelUploadLimits.TimeSpanHours;
87-
this.NormalUserPermissions.TimedLevelUploadLimits.LevelQuota = (int)oldConfig.TimedLevelUploadLimits.LevelQuota;
85+
this.NormalUserPermissions.LevelUploadRateLimit.Enabled = (bool)oldConfig.TimedLevelUploadLimits.Enabled;
86+
this.NormalUserPermissions.LevelUploadRateLimit.TimeSpanHours = (int)oldConfig.TimedLevelUploadLimits.TimeSpanHours;
87+
this.NormalUserPermissions.LevelUploadRateLimit.UploadQuota = (int)oldConfig.TimedLevelUploadLimits.LevelQuota;
8888

89-
this.TrustedUserPermissions.TimedLevelUploadLimits.Enabled = (bool)oldConfig.TimedLevelUploadLimits.Enabled;
90-
this.TrustedUserPermissions.TimedLevelUploadLimits.TimeSpanHours = (int)oldConfig.TimedLevelUploadLimits.TimeSpanHours;
91-
this.TrustedUserPermissions.TimedLevelUploadLimits.LevelQuota = (int)oldConfig.TimedLevelUploadLimits.LevelQuota;
89+
this.TrustedUserPermissions.LevelUploadRateLimit.Enabled = (bool)oldConfig.TimedLevelUploadLimits.Enabled;
90+
this.TrustedUserPermissions.LevelUploadRateLimit.TimeSpanHours = (int)oldConfig.TimedLevelUploadLimits.TimeSpanHours;
91+
this.TrustedUserPermissions.LevelUploadRateLimit.UploadQuota = (int)oldConfig.TimedLevelUploadLimits.LevelQuota;
9292
}
9393

9494
// 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)
9898
this.TrustedUserPermissions.ReadOnlyMode = (bool)oldConfig.ReadonlyModeForTrustedUsers;
9999
}
100100
}
101+
102+
// In version 28, PhotoUploadRateLimit and PlaylistUploadRateLimit were added to RolePermissions, and various renamings related to level upload rate-limiting
103+
// were done to prepare for this: the class TimedLevelUploadLimitProperties was renamed to EntityUploadRateLimitProperties, its attribute LevelQuota was renamed to UploadQuota,
104+
// and RolePermissions' attribute TimedLevelUploadLimits was renamed to LevelUploadRateLimit
105+
else if (oldVer == 27)
106+
{
107+
this.NormalUserPermissions.LevelUploadRateLimit.Enabled = (bool)oldConfig.NormalUserPermissions.TimedLevelUploadLimits.Enabled;
108+
this.NormalUserPermissions.LevelUploadRateLimit.TimeSpanHours = (int)oldConfig.NormalUserPermissions.TimedLevelUploadLimits.TimeSpanHours;
109+
this.NormalUserPermissions.LevelUploadRateLimit.UploadQuota = (int)oldConfig.NormalUserPermissions.TimedLevelUploadLimits.LevelQuota;
110+
111+
this.TrustedUserPermissions.LevelUploadRateLimit.Enabled = (bool)oldConfig.TrustedUserPermissions.TimedLevelUploadLimits.Enabled;
112+
this.TrustedUserPermissions.LevelUploadRateLimit.TimeSpanHours = (int)oldConfig.TrustedUserPermissions.TimedLevelUploadLimits.TimeSpanHours;
113+
this.TrustedUserPermissions.LevelUploadRateLimit.UploadQuota = (int)oldConfig.TrustedUserPermissions.TimedLevelUploadLimits.LevelQuota;
114+
}
101115
}
102116

103117
public string LicenseText { get; set; } = "Welcome to Refresh!";

Refresh.Core/Configuration/RolePermissions.cs

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,11 +8,25 @@ public RolePermissions() {}
88

99
public bool ReadOnlyMode { get; set; } = false;
1010
public ConfigAssetFlags BlockedAssetFlags { get; set; } = new(AssetFlags.Dangerous | AssetFlags.Modded);
11-
public TimedLevelUploadLimitProperties TimedLevelUploadLimits = new()
11+
public EntityUploadRateLimitProperties LevelUploadRateLimit = new()
1212
{
1313
Enabled = false,
1414
TimeSpanHours = 24,
15-
LevelQuota = 10,
15+
UploadQuota = 10,
16+
};
17+
18+
public EntityUploadRateLimitProperties PhotoUploadRateLimit = new()
19+
{
20+
Enabled = false,
21+
TimeSpanHours = 24,
22+
UploadQuota = 10,
23+
};
24+
25+
public EntityUploadRateLimitProperties PlaylistUploadRateLimit = new()
26+
{
27+
Enabled = false,
28+
TimeSpanHours = 24,
29+
UploadQuota = 8,
1630
};
1731

1832
/// <summary>

Refresh.Core/Configuration/TimedLevelUploadLimitProperties.cs

Lines changed: 0 additions & 19 deletions
This file was deleted.

Refresh.Core/RateLimits/Playlists/PlaylistCreationEndpointLimits.cs

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -5,10 +5,10 @@ namespace Refresh.Core.RateLimits.Playlists;
55
/// </summary>
66
public static class PlaylistCreationEndpointLimits
77
{
8-
public const int UploadTimeoutDuration = 450;
9-
public const int MaxCreateAmount = 8; // should be enough
10-
public const int MaxUpdateAmount = 12;
11-
public const int UploadBlockDuration = 300;
8+
public const int UploadTimeoutDuration = 300;
9+
public const int MaxCreateAmount = 30;
10+
public const int MaxUpdateAmount = 50;
11+
public const int UploadBlockDuration = 240;
1212
public const string CreateBucket = "playlist-create";
1313
public const string UpdateBucket = "playlist-update";
1414
}

Refresh.Database/GameDatabaseContext.Users.cs

Lines changed: 55 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
using Refresh.Database.Models.Photos;
1111
using Refresh.Database.Models.Assets;
1212
using System.Diagnostics;
13+
using Refresh.Database.Models;
1314

1415
namespace Refresh.Database;
1516

@@ -620,31 +621,72 @@ public void MarkAllReuploads(GameUser user)
620621
});
621622
}
622623

623-
624624
public void SetUserPresenceAuthToken(GameUser user, string? token)
625625
{
626626
this.Write(() =>
627627
{
628628
user.PresenceServerAuthToken = token;
629629
});
630630
}
631-
632-
public void IncrementTimedLevelLimit(GameUser user, int hours)
631+
632+
public EntityUploadRateLimit? GetUploadRateLimit(GameUser user, GameDatabaseEntity entity, bool save = true)
633633
{
634-
this.Write(() =>
634+
EntityUploadRateLimit? limit = this.EntityUploadRateLimits.FirstOrDefault(r => r.UserId == user.UserId && r.Entity == entity);
635+
636+
// remove if expired
637+
if (limit != null && limit.ExpiryDate <= this._time.Now)
635638
{
636-
// Set expiry date if the timed limits have been reset previously
637-
user.TimedLevelUploadExpiryDate ??= this._time.Now + TimeSpan.FromHours(hours);
638-
user.TimedLevelUploads++;
639-
});
639+
this.EntityUploadRateLimits.Remove(limit);
640+
if (save) this.SaveChanges();
641+
return null;
642+
}
643+
644+
return limit;
640645
}
641646

642-
public void ResetTimedLevelLimit(GameUser user)
647+
/// <returns>
648+
/// Time until expiry date if the corresponding upload rate-limit has been reached, otherwise null
649+
/// </returns>
650+
public TimeSpan? GetRemainingTimeIfUploadRateLimitReached(GameUser user, GameDatabaseEntity entity, int uploadQuota)
643651
{
644-
this.Write(() =>
652+
EntityUploadRateLimit? limit = this.GetUploadRateLimit(user, entity); // will be null if expired already, see above
653+
DateTimeOffset now = this._time.Now;
654+
655+
if (limit != null && limit.UploadCount >= uploadQuota)
645656
{
646-
user.TimedLevelUploadExpiryDate = null;
647-
user.TimedLevelUploads = 0;
648-
});
657+
return limit.ExpiryDate - now;
658+
}
659+
660+
return null;
661+
}
662+
663+
public void IncrementUploadRateLimitForEntity(GameUser user, GameDatabaseEntity entity, int timeSpanHours)
664+
{
665+
EntityUploadRateLimit? existingLimit = this.GetUploadRateLimit(user, entity, false); // will be null if expired already, see above
666+
DateTimeOffset now = this._time.Now;
667+
668+
if (existingLimit == null)
669+
{
670+
EntityUploadRateLimit newLimit = new()
671+
{
672+
Entity = entity,
673+
User = user,
674+
UploadCount = 1,
675+
ExpiryDate = now + TimeSpan.FromHours(timeSpanHours),
676+
};
677+
this.EntityUploadRateLimits.Add(newLimit);
678+
}
679+
else
680+
{
681+
this.EntityUploadRateLimits.Update(existingLimit);
682+
existingLimit.UploadCount++;
683+
}
684+
685+
this.SaveChanges();
686+
}
687+
688+
public void ResetUploadRateLimit(GameUser user, GameDatabaseEntity entity)
689+
{
690+
this.EntityUploadRateLimits.RemoveRange(r => r.UserId == user.UserId && r.Entity == entity);
649691
}
650692
}

Refresh.Database/GameDatabaseContext.cs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,7 @@ public partial class GameDatabaseContext : DbContext, IDatabaseContext
8686
internal DbSet<PersistentJobState> JobStates { get; set; }
8787
internal DbSet<GameLevelRevision> GameLevelRevisions { get; set; }
8888
internal DbSet<ModerationAction> ModerationActions { get; set; }
89+
internal DbSet<EntityUploadRateLimit> EntityUploadRateLimits { get; set; }
8990

9091
#pragma warning disable CS8618 // Non-nullable variable must contain a non-null value when exiting constructor. Consider declaring it as nullable.
9192
internal GameDatabaseContext(Logger logger, IDateTimeProvider time, IDatabaseConfig dbConfig)
Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
using System;
2+
using Microsoft.EntityFrameworkCore.Infrastructure;
3+
using Microsoft.EntityFrameworkCore.Migrations;
4+
5+
#nullable disable
6+
7+
namespace Refresh.Database.Migrations
8+
{
9+
/// <inheritdoc />
10+
[DbContext(typeof(GameDatabaseContext))]
11+
[Migration("20260421181503_SplitUploadRateLimitsToSeparateTable")]
12+
public partial class SplitUploadRateLimitsToSeparateTable : Migration
13+
{
14+
/// <inheritdoc />
15+
protected override void Up(MigrationBuilder migrationBuilder)
16+
{
17+
// probably no need to migrate these, they're temporary anyway
18+
migrationBuilder.DropColumn(
19+
name: "TimedLevelUploadExpiryDate",
20+
table: "GameUsers");
21+
22+
migrationBuilder.DropColumn(
23+
name: "TimedLevelUploads",
24+
table: "GameUsers");
25+
26+
migrationBuilder.CreateTable(
27+
name: "EntityUploadRateLimits",
28+
columns: table => new
29+
{
30+
Entity = table.Column<byte>(type: "smallint", nullable: false),
31+
UserId = table.Column<string>(type: "text", nullable: false),
32+
UploadCount = table.Column<int>(type: "integer", nullable: false),
33+
ExpiryDate = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false)
34+
},
35+
constraints: table =>
36+
{
37+
table.PrimaryKey("PK_EntityUploadRateLimits", x => new { x.UserId, x.Entity });
38+
table.ForeignKey(
39+
name: "FK_EntityUploadRateLimits_GameUsers_UserId",
40+
column: x => x.UserId,
41+
principalTable: "GameUsers",
42+
principalColumn: "UserId",
43+
onDelete: ReferentialAction.Cascade);
44+
});
45+
}
46+
47+
/// <inheritdoc />
48+
protected override void Down(MigrationBuilder migrationBuilder)
49+
{
50+
migrationBuilder.DropTable(
51+
name: "EntityUploadRateLimits");
52+
53+
migrationBuilder.AddColumn<DateTimeOffset>(
54+
name: "TimedLevelUploadExpiryDate",
55+
table: "GameUsers",
56+
type: "timestamp with time zone",
57+
nullable: true);
58+
59+
migrationBuilder.AddColumn<int>(
60+
name: "TimedLevelUploads",
61+
table: "GameUsers",
62+
type: "integer",
63+
nullable: false,
64+
defaultValue: 0);
65+
}
66+
}
67+
}

Refresh.Database/Migrations/GameDatabaseContextModelSnapshot.cs

Lines changed: 30 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1594,6 +1594,25 @@ protected override void BuildModel(ModelBuilder modelBuilder)
15941594
b.ToTable("EmailVerificationCodes");
15951595
});
15961596

1597+
modelBuilder.Entity("Refresh.Database.Models.Users.EntityUploadRateLimit", b =>
1598+
{
1599+
b.Property<string>("UserId")
1600+
.HasColumnType("text");
1601+
1602+
b.Property<byte>("Entity")
1603+
.HasColumnType("smallint");
1604+
1605+
b.Property<int>("UploadCount")
1606+
.HasColumnType("integer");
1607+
1608+
b.Property<DateTimeOffset>("ExpiryDate")
1609+
.HasColumnType("timestamp with time zone");
1610+
1611+
b.HasKey("UserId", "Entity");
1612+
1613+
b.ToTable("EntityUploadRateLimits");
1614+
});
1615+
15971616
modelBuilder.Entity("Refresh.Database.Models.Users.GameIpVerificationRequest", b =>
15981617
{
15991618
b.Property<string>("UserId")
@@ -1735,12 +1754,6 @@ protected override void BuildModel(ModelBuilder modelBuilder)
17351754
b.Property<string>("StatisticsUserId")
17361755
.HasColumnType("text");
17371756

1738-
b.Property<DateTimeOffset?>("TimedLevelUploadExpiryDate")
1739-
.HasColumnType("timestamp with time zone");
1740-
1741-
b.Property<int>("TimedLevelUploads")
1742-
.HasColumnType("integer");
1743-
17441757
b.Property<bool>("UnescapeXmlSequences")
17451758
.HasColumnType("boolean");
17461759

@@ -2521,6 +2534,17 @@ protected override void BuildModel(ModelBuilder modelBuilder)
25212534
b.Navigation("User");
25222535
});
25232536

2537+
modelBuilder.Entity("Refresh.Database.Models.Users.EntityUploadRateLimit", b =>
2538+
{
2539+
b.HasOne("Refresh.Database.Models.Users.GameUser", "User")
2540+
.WithMany()
2541+
.HasForeignKey("UserId")
2542+
.OnDelete(DeleteBehavior.Cascade)
2543+
.IsRequired();
2544+
2545+
b.Navigation("User");
2546+
});
2547+
25242548
modelBuilder.Entity("Refresh.Database.Models.Users.GameIpVerificationRequest", b =>
25252549
{
25262550
b.HasOne("Refresh.Database.Models.Users.GameUser", "User")
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
namespace Refresh.Database.Models;
2+
3+
public enum GameDatabaseEntity : byte
4+
{
5+
User,
6+
Level,
7+
Score,
8+
RateLevelRelation,
9+
Photo,
10+
Review,
11+
LevelComment,
12+
UserComment,
13+
Playlist,
14+
Asset,
15+
Challenge,
16+
ChallengeScore,
17+
}

0 commit comments

Comments
 (0)