From b7556203dd90e349aa55dd4a08fce6b7bed45dfd Mon Sep 17 00:00:00 2001 From: Toaster2 Date: Wed, 15 Jul 2026 11:56:02 +0200 Subject: [PATCH 01/17] Add NewUser role and configs --- .../Configuration/GameServerConfig.cs | 55 +++++++++++++++++-- Refresh.Database/Models/Users/GameUser.cs | 4 +- Refresh.Database/Models/Users/GameUserRole.cs | 5 ++ 3 files changed, 56 insertions(+), 8 deletions(-) diff --git a/Refresh.Core/Configuration/GameServerConfig.cs b/Refresh.Core/Configuration/GameServerConfig.cs index 573d6aa1b..6c8ecdbae 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 => 28; + public override int CurrentConfigVersion => 29; public override int Version { get; set; } = 0; protected override void Migrate(int oldVer, dynamic oldConfig) @@ -17,14 +17,17 @@ protected override void Migrate(int oldVer, dynamic oldConfig) // to more cleanly split the perms between certain roles, and to make their enforcement easier. if (oldVer < 27) { + this.NewUserPermissions = new(); this.NormalUserPermissions = new(); this.TrustedUserPermissions = new(); // filesize quota limit was added during version 11, but the version wasn't bumped, so catch error to be safe + // Migrate filesize quota if (oldVer >= 11) { try { + this.NewUserPermissions.UserFilesizeQuota = (int)oldConfig.UserFilesizeQuota; this.NormalUserPermissions.UserFilesizeQuota = (int)oldConfig.UserFilesizeQuota; this.TrustedUserPermissions.UserFilesizeQuota = (int)oldConfig.UserFilesizeQuota; } @@ -34,8 +37,13 @@ protected override void Migrate(int oldVer, dynamic oldConfig) } } + // Migrate asset flags/safety level if (oldVer >= 18) { + this.NewUserPermissions.BlockedAssetFlags.Dangerous = (bool)oldConfig.BlockedAssetFlags.Dangerous; + this.NewUserPermissions.BlockedAssetFlags.Media = (bool)oldConfig.BlockedAssetFlags.Media; + this.NewUserPermissions.BlockedAssetFlags.Modded = (bool)oldConfig.BlockedAssetFlags.Modded; + this.NormalUserPermissions.BlockedAssetFlags.Dangerous = (bool)oldConfig.BlockedAssetFlags.Dangerous; this.NormalUserPermissions.BlockedAssetFlags.Media = (bool)oldConfig.BlockedAssetFlags.Media; this.NormalUserPermissions.BlockedAssetFlags.Modded = (bool)oldConfig.BlockedAssetFlags.Modded; @@ -50,12 +58,14 @@ protected override void Migrate(int oldVer, dynamic oldConfig) if (oldVer >= 2) { int oldSafetyLevel = (int)oldConfig.MaximumAssetSafetyLevel; - this.NormalUserPermissions.BlockedAssetFlags = new ConfigAssetFlags + ConfigAssetFlags fromSafetyLevel = new ConfigAssetFlags { Dangerous = oldSafetyLevel < 3, Modded = oldSafetyLevel < 2, Media = oldSafetyLevel < 1, }; + this.NormalUserPermissions.BlockedAssetFlags = fromSafetyLevel; + this.NewUserPermissions.BlockedAssetFlags = fromSafetyLevel; } // Asset safety level for trusted users was added in config version 12, so dont try to migrate if we are coming from a version older than that @@ -80,8 +90,13 @@ protected override void Migrate(int oldVer, dynamic oldConfig) } // Timed level upload limits were added in version 19. + // Migrate level limits if (oldVer >= 19) { + this.NewUserPermissions.LevelUploadRateLimit.Enabled = (bool)oldConfig.TimedLevelUploadLimits.Enabled; + this.NewUserPermissions.LevelUploadRateLimit.TimeSpanHours = (int)oldConfig.TimedLevelUploadLimits.TimeSpanHours; + this.NewUserPermissions.LevelUploadRateLimit.UploadQuota = (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; @@ -94,30 +109,44 @@ protected override void Migrate(int oldVer, dynamic oldConfig) // Read-only mode was added for both normal and trusted users in version 20. if (oldVer >= 20) { + this.NewUserPermissions.ReadOnlyMode = (bool)oldConfig.ReadOnlyMode; this.NormalUserPermissions.ReadOnlyMode = (bool)oldConfig.ReadOnlyMode; 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 + // In version 28, PhotoUploadRateLimit and PlaylistUploadRateLimit were added to RolePermissions + // and various attributes related to level rate-limiting were renamed 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.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; } + + // In version 29, role perms for new users were added + else if (oldVer < 29) + { + this.NewUserPermissions = oldConfig.NormalUserPermissions; + } } public string LicenseText { get; set; } = "Welcome to Refresh!"; /// - /// Role-specific permissions for normal users and below + /// Role-specific permissions for new users. + /// + public RolePermissions NewUserPermissions = new(); + /// + /// Role-specific permissions for normal, not-new users and restricted users (if applicable) /// public RolePermissions NormalUserPermissions = new(); /// @@ -125,6 +154,13 @@ protected override void Migrate(int oldVer, dynamic oldConfig) /// public RolePermissions TrustedUserPermissions = new(); + /// + /// How long we should wait (in hours) until we should mark a new account as no longer new. + /// Once their account hits this age, we will start applying NormalUserPermissions instead of NewUserPermissions + /// as their role-perms. + /// + public int HoursUntilNewAccountNoLongerNew { get; set; } = 48; // TODO better naming probably + public bool AllowUsersToUseIpAuthentication { get; set; } = false; public bool PermitPsnLogin { get; set; } = true; public bool PermitRpcnLogin { get; set; } = true; @@ -181,5 +217,12 @@ protected override void Migrate(int oldVer, dynamic oldConfig) public string[] HmacDigestKeys = ["CustomServerDigest"]; public bool PermitShowingOnlineUsers { get; set; } = true; + + /// + /// Whether users that are considered "new" should be shown on user categories, and whether their + /// rooms should be exposed via API. + /// + public bool PermitShowingNewUsers { get; set; } = true; + public bool EnableDiveIn { get; set; } = true; } \ No newline at end of file diff --git a/Refresh.Database/Models/Users/GameUser.cs b/Refresh.Database/Models/Users/GameUser.cs index 585a1cea4..b95091a32 100644 --- a/Refresh.Database/Models/Users/GameUser.cs +++ b/Refresh.Database/Models/Users/GameUser.cs @@ -105,8 +105,8 @@ public partial class GameUser : IRateLimitUser /// If `true`, unescape XML tags sent to /filter /// public bool UnescapeXmlSequences { get; set; } - - public GameUserRole Role { get; set; } + + public GameUserRole Role { get; set; } = GameUserRole.NewUser; /// /// Whether planets containing mods or VoiceRecordings should be shown in-game diff --git a/Refresh.Database/Models/Users/GameUserRole.cs b/Refresh.Database/Models/Users/GameUserRole.cs index 62076330c..c4f9c66d3 100644 --- a/Refresh.Database/Models/Users/GameUserRole.cs +++ b/Refresh.Database/Models/Users/GameUserRole.cs @@ -30,6 +30,11 @@ public enum GameUserRole : sbyte /// User = 0, /// + /// A newly registered user. Can have different, usually more restrictive configurable perms than regular users, to make spam harder. + /// The duration in which an account's age makes it "new" is defined by config. + /// + NewUser = -32, + /// /// A user with read-only permissions. May log in and play, but cannot do things such as publish levels or post comments. /// Restricted = -126, From 5cd9cea282fe2f02d61900718270406f986687ce Mon Sep 17 00:00:00 2001 From: Toaster2 Date: Wed, 15 Jul 2026 13:39:02 +0200 Subject: [PATCH 02/17] Add job to automatically promote users from NewUser -> User --- Refresh.GameServer/RefreshGameServer.cs | 2 + .../Repeating/NewUserJob.cs | 38 +++++++++++++++++++ 2 files changed, 40 insertions(+) create mode 100644 Refresh.Interfaces.Workers/Repeating/NewUserJob.cs diff --git a/Refresh.GameServer/RefreshGameServer.cs b/Refresh.GameServer/RefreshGameServer.cs index 92bb94dcc..2b0d25979 100644 --- a/Refresh.GameServer/RefreshGameServer.cs +++ b/Refresh.GameServer/RefreshGameServer.cs @@ -192,6 +192,8 @@ protected virtual void SetupWorkers() { this.WorkerManager.AddJob(new DiscordIntegrationJob(this._configStore.Integration, this._configStore.GameServer)); } + + this.WorkerManager.AddJob(new NewUserJob(this._configStore.GameServer.HoursUntilNewAccountNoLongerNew)); } /// diff --git a/Refresh.Interfaces.Workers/Repeating/NewUserJob.cs b/Refresh.Interfaces.Workers/Repeating/NewUserJob.cs new file mode 100644 index 000000000..7c4ea6932 --- /dev/null +++ b/Refresh.Interfaces.Workers/Repeating/NewUserJob.cs @@ -0,0 +1,38 @@ +using Refresh.Common; +using Refresh.Database; +using Refresh.Database.Models.Users; +using Refresh.Workers; + +namespace Refresh.Interfaces.Workers.Repeating; + +/// +/// A worker that handles setting new users as regular users, depending on their account age and the server config. +/// +// TODO also set users back as "new" if duration in config is updated to result in user being "new" again +public class NewUserJob : RepeatingJob +{ + private int _requiredAccountAge; + protected override int Interval => 60_000 * 5; // 5 minutes, no need to execute too often + + public NewUserJob(int requiredAccountAge) + { + this._requiredAccountAge = requiredAccountAge; + } + + public override void ExecuteJob(WorkContext context) + { + DateTimeOffset now = DateTimeOffset.Now; // TODO use IDateTimeProvider for getting time in jobs + DatabaseList newUsers = context.Database.GetAllUsersWithRole(GameUserRole.NewUser); + + foreach (GameUser user in newUsers.Items) + { + // If an account is, e.g., 2 hours and 40 minutes old, and max age for new users is 3 hours, we wouldn't + // consider max to be reached yet, so floor the difference. + long accountAge = (long)Math.Floor(now.Subtract(user.JoinDate).TotalHours); + if (accountAge < this._requiredAccountAge) continue; // Don't promote user if they haven't reached max age yet + + context.Logger.LogInfo(RefreshContext.Worker, $"Promoting {user} to regular user since their account is {accountAge}/{this._requiredAccountAge} hours old now."); + context.Database.SetUserRole(user, GameUserRole.User); + } + } +} \ No newline at end of file From 916e9bfcfc972583046a44854d537e1acfd83262 Mon Sep 17 00:00:00 2001 From: Toaster2 Date: Wed, 15 Jul 2026 13:57:13 +0200 Subject: [PATCH 03/17] Better GetRolePermissionsForUser() handling --- Refresh.Core/Configuration/RolePermissions.cs | 21 +++++++++++++++++++ Refresh.Core/Extensions/GameUserExtensions.cs | 11 ++++++---- 2 files changed, 28 insertions(+), 4 deletions(-) diff --git a/Refresh.Core/Configuration/RolePermissions.cs b/Refresh.Core/Configuration/RolePermissions.cs index f381c60fc..d10638e35 100644 --- a/Refresh.Core/Configuration/RolePermissions.cs +++ b/Refresh.Core/Configuration/RolePermissions.cs @@ -33,4 +33,25 @@ public RolePermissions() {} /// The amount of data the user is allowed to upload before all resource uploads get blocked, defaults to 100mb. /// public int UserFilesizeQuota { get; set; } = 100 * 1_048_576; + + // Not configurable because "Restricted" and "Banned" already imply a user has no uploading perms. + public static RolePermissions FromRestrictedUser => new() + { + ReadOnlyMode = true, + BlockedAssetFlags = new(), + // Not enabled because restricted may not upload UGC anyway + LevelUploadRateLimit = new() + { + Enabled = false, + }, + PhotoUploadRateLimit = new() + { + Enabled = false, + }, + PlaylistUploadRateLimit = new() + { + Enabled = false, + }, + UserFilesizeQuota = 0, + }; } \ No newline at end of file diff --git a/Refresh.Core/Extensions/GameUserExtensions.cs b/Refresh.Core/Extensions/GameUserExtensions.cs index e3f9c3683..6ac2917ff 100644 --- a/Refresh.Core/Extensions/GameUserExtensions.cs +++ b/Refresh.Core/Extensions/GameUserExtensions.cs @@ -26,9 +26,12 @@ public static bool MayModifyUser(this GameUser user, GameUser targetUser) public static RolePermissions GetRolePermissionsForUser(this GameUser user, GameServerConfig config) { - if (user.Role >= GameUserRole.Trusted) - return config.TrustedUserPermissions; - - return config.NormalUserPermissions; + return user.Role switch + { + >= GameUserRole.Trusted => config.TrustedUserPermissions, + GameUserRole.User => config.NormalUserPermissions, + GameUserRole.NewUser => config.NewUserPermissions, + _ => RolePermissions.FromRestrictedUser, + }; } } \ No newline at end of file From 242c68721208336c3c8214b9215e2b653f7f7603 Mon Sep 17 00:00:00 2001 From: Toaster2 Date: Wed, 15 Jul 2026 13:58:30 +0200 Subject: [PATCH 04/17] Better publish block by read-only notif --- 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 9fbfc2765..a3526673d 100644 --- a/Refresh.Interfaces.Game/Endpoints/Levels/PublishEndpoints.cs +++ b/Refresh.Interfaces.Game/Endpoints/Levels/PublishEndpoints.cs @@ -110,7 +110,7 @@ public Response StartPublish(RequestContext context, { if (dataContext.User!.IsWriteBlocked(config)) { - dataContext.Database.AddPublishFailNotification("The server is in read-only mode.", body.Title, dataContext.User!); + dataContext.Database.AddPublishFailNotification($"Your user role ({user.Role}) is currently set to read-only by the server.", body.Title, dataContext.User!); return Unauthorized; } From ed7538d83de4d3bf3ace6b9270075a12b03d9556 Mon Sep 17 00:00:00 2001 From: Toaster2 Date: Wed, 15 Jul 2026 14:07:19 +0200 Subject: [PATCH 05/17] Unconditionally write-block Restricted and Banned --- Refresh.Core/Configuration/RolePermissions.cs | 4 ++-- Refresh.Core/Extensions/GameUserExtensions.cs | 8 +++++++- 2 files changed, 9 insertions(+), 3 deletions(-) diff --git a/Refresh.Core/Configuration/RolePermissions.cs b/Refresh.Core/Configuration/RolePermissions.cs index d10638e35..f9e322bd5 100644 --- a/Refresh.Core/Configuration/RolePermissions.cs +++ b/Refresh.Core/Configuration/RolePermissions.cs @@ -38,8 +38,8 @@ public RolePermissions() {} public static RolePermissions FromRestrictedUser => new() { ReadOnlyMode = true, - BlockedAssetFlags = new(), - // Not enabled because restricted may not upload UGC anyway + BlockedAssetFlags = new(AssetFlags.Dangerous | AssetFlags.Modded | AssetFlags.Media), + // Not enabled because restricted may not upload UGC anyway, also we already enable read-only mode for them LevelUploadRateLimit = new() { Enabled = false, diff --git a/Refresh.Core/Extensions/GameUserExtensions.cs b/Refresh.Core/Extensions/GameUserExtensions.cs index 6ac2917ff..257317d1b 100644 --- a/Refresh.Core/Extensions/GameUserExtensions.cs +++ b/Refresh.Core/Extensions/GameUserExtensions.cs @@ -7,8 +7,14 @@ public static class GameUserExtensions { public static bool IsWriteBlocked(this GameUser user, GameServerConfig config) { + // Admins may always bypass this if (user.Role == GameUserRole.Admin) return false; - return GetRolePermissionsForUser(user, config).ReadOnlyMode; + + // Restricted and Banned may not upload/edit any UGC, they also have no role perms because unnecessary + else if (user.Role <= GameUserRole.Restricted) return true; + + // Determine based on role perms + else return GetRolePermissionsForUser(user, config).ReadOnlyMode; } public static bool MayModifyUser(this GameUser user, GameUser targetUser) From 5e930afeb018343f1e5b0e8a198586accdebdf20 Mon Sep 17 00:00:00 2001 From: Toaster2 Date: Wed, 15 Jul 2026 16:32:25 +0200 Subject: [PATCH 06/17] Prevent manually restricting using SetUserRole() for consistency --- Refresh.Database/GameDatabaseContext.Users.cs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/Refresh.Database/GameDatabaseContext.Users.cs b/Refresh.Database/GameDatabaseContext.Users.cs index 40832aa5a..4087bda76 100644 --- a/Refresh.Database/GameDatabaseContext.Users.cs +++ b/Refresh.Database/GameDatabaseContext.Users.cs @@ -315,7 +315,9 @@ public int GetActiveUserCount() public void SetUserRole(GameUser user, GameUserRole role) { + // TODO allow restricting/banning/pardoning via CLI if(role == GameUserRole.Banned) throw new InvalidOperationException($"Cannot ban a user with this method. Please use {nameof(this.BanUser)}()."); + if(role == GameUserRole.Restricted) throw new InvalidOperationException($"Cannot restrict a user with this method. Please use {nameof(this.RestrictUser)}()."); if (user.Role is GameUserRole.Banned or GameUserRole.Restricted) { From f5ac99b7c036da6a70dcb3022088725249628795 Mon Sep 17 00:00:00 2001 From: Toaster2 Date: Wed, 15 Jul 2026 16:32:46 +0200 Subject: [PATCH 07/17] Test user perms generally --- .../Tests/Levels/PublishEndpointsTests.cs | 50 ++++++++++ .../Tests/Users/UserRoleTests.cs | 94 +++++++++++++++++++ 2 files changed, 144 insertions(+) create mode 100644 RefreshTests.GameServer/Tests/Users/UserRoleTests.cs diff --git a/RefreshTests.GameServer/Tests/Levels/PublishEndpointsTests.cs b/RefreshTests.GameServer/Tests/Levels/PublishEndpointsTests.cs index 51a9d3cd0..11c19ad55 100644 --- a/RefreshTests.GameServer/Tests/Levels/PublishEndpointsTests.cs +++ b/RefreshTests.GameServer/Tests/Levels/PublishEndpointsTests.cs @@ -707,4 +707,54 @@ public void ReuploadStatusPreserved() Assert.That(dbLevel.OriginalPublisher, Is.EqualTo("glotchmeister69")); } } + + [Test] + public void CannotPublishLevelIfRestricted() + { + using TestContext context = this.GetServer(); + GameUser user = context.CreateUser(role: GameUserRole.User, verifyEmail: true); + context.Database.RestrictUser(user, "no", DateTimeOffset.MaxValue); + + using HttpClient client = context.GetAuthenticatedClient(TokenType.Game, user); + + GameLevelRequest level = new() + { + RootResource = TEST_ASSET_HASH, + }; + + HttpResponseMessage message = client.PostAsync("/lbp/startPublish", new StringContent(level.AsXML())).Result; + Assert.That(message.StatusCode, Is.EqualTo(Unauthorized)); + + //Upload our """level""" (even though we got an error) + message = client.PostAsync($"/lbp/upload/{TEST_ASSET_HASH}", new ReadOnlyMemoryContent("LVLb"u8.ToArray())).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)); + } + + [Test] + public void CannotPublishLevelIfBanned() + { + using TestContext context = this.GetServer(); + GameUser user = context.CreateUser(role: GameUserRole.User, verifyEmail: true); + context.Database.BanUser(user, "no", DateTimeOffset.MaxValue); + + using HttpClient client = context.GetAuthenticatedClient(TokenType.Game, user); + + GameLevelRequest level = new() + { + RootResource = TEST_ASSET_HASH, + }; + + HttpResponseMessage message = client.PostAsync("/lbp/startPublish", new StringContent(level.AsXML())).Result; + Assert.That(message.StatusCode, Is.EqualTo(Unauthorized)); + + //Upload our """level""" (even though we got an error) + message = client.PostAsync($"/lbp/upload/{TEST_ASSET_HASH}", new ReadOnlyMemoryContent("LVLb"u8.ToArray())).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)); + } } \ No newline at end of file diff --git a/RefreshTests.GameServer/Tests/Users/UserRoleTests.cs b/RefreshTests.GameServer/Tests/Users/UserRoleTests.cs new file mode 100644 index 000000000..72eed7d98 --- /dev/null +++ b/RefreshTests.GameServer/Tests/Users/UserRoleTests.cs @@ -0,0 +1,94 @@ +using Refresh.Core.Configuration; +using Refresh.Core.Extensions; +using Refresh.Database.Models.Users; + +namespace RefreshTests.GameServer.Tests.Users; + +public class UserRoleTests : GameServerTest +{ + [Test] + public void EnsureUsersUseCorrectRolePerms() + { + using TestContext context = this.GetServer(); + GameServerConfig config = context.Server.Value.GameServerConfig; + + config.NewUserPermissions.UserFilesizeQuota = 24; + config.NewUserPermissions.ReadOnlyMode = false; + + config.NormalUserPermissions.UserFilesizeQuota = 67; + config.NormalUserPermissions.ReadOnlyMode = true; + + config.TrustedUserPermissions.UserFilesizeQuota = 23456; + config.TrustedUserPermissions.ReadOnlyMode = false; + + // New user + GameUser user = context.CreateUser(); + Assert.That(user.Role, Is.EqualTo(GameUserRole.NewUser)); + + RolePermissions perms = user.GetRolePermissionsForUser(config); + Assert.That(perms.UserFilesizeQuota, Is.EqualTo(24)); + Assert.That(perms.ReadOnlyMode, Is.False); + Assert.That(user.IsWriteBlocked(config), Is.False); + + // Normal user + context.Database.SetUserRole(user, GameUserRole.User); + context.Database.Refresh(); + Assert.That(user.Role, Is.EqualTo(GameUserRole.User)); + + perms = user.GetRolePermissionsForUser(config); + Assert.That(perms.UserFilesizeQuota, Is.EqualTo(67)); + Assert.That(perms.ReadOnlyMode, Is.True); + Assert.That(user.IsWriteBlocked(config), Is.True); + + // Trusted user + context.Database.SetUserRole(user, GameUserRole.Trusted); + context.Database.Refresh(); + Assert.That(user.Role, Is.EqualTo(GameUserRole.Trusted)); + + perms = user.GetRolePermissionsForUser(config); + Assert.That(perms.UserFilesizeQuota, Is.EqualTo(23456)); + Assert.That(perms.ReadOnlyMode, Is.False); + Assert.That(user.IsWriteBlocked(config), Is.False); + + // Curator user + context.Database.SetUserRole(user, GameUserRole.Curator); + context.Database.Refresh(); + Assert.That(user.Role, Is.EqualTo(GameUserRole.Curator)); + + perms = user.GetRolePermissionsForUser(config); + Assert.That(perms.UserFilesizeQuota, Is.EqualTo(23456)); + Assert.That(perms.ReadOnlyMode, Is.False); + Assert.That(user.IsWriteBlocked(config), Is.False); + + // Restricted user + context.Database.RestrictUser(user, "lol", DateTimeOffset.MaxValue); + context.Database.Refresh(); + Assert.That(user.Role, Is.EqualTo(GameUserRole.Restricted)); + + perms = user.GetRolePermissionsForUser(config); + Assert.That(perms.UserFilesizeQuota, Is.EqualTo(0)); + Assert.That(perms.ReadOnlyMode, Is.True); + Assert.That(user.IsWriteBlocked(config), Is.True); + + // Banned user + context.Database.BanUser(user, "lel", DateTimeOffset.MaxValue); + context.Database.Refresh(); + Assert.That(user.Role, Is.EqualTo(GameUserRole.Banned)); + + perms = user.GetRolePermissionsForUser(config); + Assert.That(perms.UserFilesizeQuota, Is.EqualTo(0)); + Assert.That(perms.ReadOnlyMode, Is.True); + Assert.That(user.IsWriteBlocked(config), Is.True); + } + + [Test] + public void EnsureSettingRoleToRestrictedOrBannedManuallyThrows() + { + using TestContext context = this.GetServer(); + GameUser user = context.CreateUser(); + Assert.That(user.Role, Is.EqualTo(GameUserRole.NewUser)); + + Assert.That(() => context.Database.SetUserRole(user, GameUserRole.Restricted), Throws.TypeOf()); + Assert.That(() => context.Database.SetUserRole(user, GameUserRole.Banned), Throws.TypeOf()); + } +} \ No newline at end of file From ecde5c32a8d2c7267daa119e9ad23c2b78a64344 Mon Sep 17 00:00:00 2001 From: Toaster2 Date: Thu, 16 Jul 2026 11:07:18 +0200 Subject: [PATCH 08/17] Add IDateTimeProvider attribute to WorkContext --- Refresh.GameServer/RefreshGameServer.cs | 2 +- Refresh.Interfaces.Workers/RefreshWorkerManager.cs | 5 +++-- Refresh.Interfaces.Workers/Repeating/NewUserJob.cs | 2 +- Refresh.WorkerManager/Program.cs | 3 ++- Refresh.Workers/WorkContext.cs | 2 ++ Refresh.Workers/WorkerManager.cs | 6 +++++- RefreshTests.GameServer/TestContext.cs | 1 + .../Tests/Workers/JobStateTests.cs | 12 ++++++------ 8 files changed, 21 insertions(+), 12 deletions(-) diff --git a/Refresh.GameServer/RefreshGameServer.cs b/Refresh.GameServer/RefreshGameServer.cs index 2b0d25979..13865f9f0 100644 --- a/Refresh.GameServer/RefreshGameServer.cs +++ b/Refresh.GameServer/RefreshGameServer.cs @@ -186,7 +186,7 @@ protected override void SetupServices() protected virtual void SetupWorkers() { - this.WorkerManager = RefreshWorkerManager.Create(this.Logger, this._dataStore, this._databaseProvider); + this.WorkerManager = RefreshWorkerManager.Create(this.Logger, this._dataStore, this._databaseProvider, this.GetTimeProvider()); if (this._configStore.Integration.DiscordWebhookEnabled && this._configStore.GameServer.PermitShowingOnlineUsers) { diff --git a/Refresh.Interfaces.Workers/RefreshWorkerManager.cs b/Refresh.Interfaces.Workers/RefreshWorkerManager.cs index 88dfc5d06..25b185d9c 100644 --- a/Refresh.Interfaces.Workers/RefreshWorkerManager.cs +++ b/Refresh.Interfaces.Workers/RefreshWorkerManager.cs @@ -1,5 +1,6 @@ using Bunkum.Core.Storage; using NotEnoughLogs; +using Refresh.Common.Time; using Refresh.Database; using Refresh.Interfaces.Workers.Migrations; using Refresh.Interfaces.Workers.Repeating; @@ -9,9 +10,9 @@ namespace Refresh.Interfaces.Workers; public static class RefreshWorkerManager { - public static WorkerManager Create(Logger logger, IDataStore dataStore, GameDatabaseProvider databaseProvider) + public static WorkerManager Create(Logger logger, IDataStore dataStore, GameDatabaseProvider databaseProvider, IDateTimeProvider timeProvider) { - WorkerManager manager = new(logger, dataStore, databaseProvider); + WorkerManager manager = new(logger, dataStore, databaseProvider, timeProvider); manager.AddJob(); manager.AddJob(); diff --git a/Refresh.Interfaces.Workers/Repeating/NewUserJob.cs b/Refresh.Interfaces.Workers/Repeating/NewUserJob.cs index 7c4ea6932..af256df83 100644 --- a/Refresh.Interfaces.Workers/Repeating/NewUserJob.cs +++ b/Refresh.Interfaces.Workers/Repeating/NewUserJob.cs @@ -21,7 +21,7 @@ public NewUserJob(int requiredAccountAge) public override void ExecuteJob(WorkContext context) { - DateTimeOffset now = DateTimeOffset.Now; // TODO use IDateTimeProvider for getting time in jobs + DateTimeOffset now = context.TimeProvider.Now; DatabaseList newUsers = context.Database.GetAllUsersWithRole(GameUserRole.NewUser); foreach (GameUser user in newUsers.Items) diff --git a/Refresh.WorkerManager/Program.cs b/Refresh.WorkerManager/Program.cs index c458fa38c..2a8932ac8 100644 --- a/Refresh.WorkerManager/Program.cs +++ b/Refresh.WorkerManager/Program.cs @@ -2,6 +2,7 @@ using Bunkum.Core.Storage; using NotEnoughLogs; using NotEnoughLogs.Behaviour; +using Refresh.Common.Time; using Refresh.Core.Configuration; using Refresh.Database; using Refresh.Database.Configuration; @@ -33,7 +34,7 @@ database.Warmup(); logger.LogInfo(BunkumCategory.Startup, "Starting worker manager!"); -WorkerManager manager = RefreshWorkerManager.Create(logger, new FileSystemDataStore(), database); +WorkerManager manager = RefreshWorkerManager.Create(logger, new FileSystemDataStore(), database, new SystemDateTimeProvider()); manager.Start(); manager.WaitForExit(); \ No newline at end of file diff --git a/Refresh.Workers/WorkContext.cs b/Refresh.Workers/WorkContext.cs index 6e4bc2289..675b68ae1 100644 --- a/Refresh.Workers/WorkContext.cs +++ b/Refresh.Workers/WorkContext.cs @@ -1,5 +1,6 @@ using Bunkum.Core.Storage; using NotEnoughLogs; +using Refresh.Common.Time; using Refresh.Database; namespace Refresh.Workers; @@ -9,4 +10,5 @@ public class WorkContext : IDataContext public required GameDatabaseContext Database { get; init; } public required Logger Logger { get; init; } public required IDataStore DataStore { get; init; } + public required IDateTimeProvider TimeProvider { get; init; } } \ No newline at end of file diff --git a/Refresh.Workers/WorkerManager.cs b/Refresh.Workers/WorkerManager.cs index 3c04102ea..f750c8be8 100644 --- a/Refresh.Workers/WorkerManager.cs +++ b/Refresh.Workers/WorkerManager.cs @@ -1,6 +1,7 @@ using Bunkum.Core.Storage; using NotEnoughLogs; using Refresh.Common; +using Refresh.Common.Time; using Refresh.Database; using Refresh.Database.Models.Workers; using Refresh.Workers.State; @@ -12,6 +13,7 @@ public class WorkerManager private readonly Logger _logger; private readonly IDataStore _dataStore; private readonly GameDatabaseProvider _databaseProvider; + private readonly IDateTimeProvider _timeProvider; private readonly int _workerId; @@ -22,11 +24,12 @@ public class WorkerManager private readonly List _jobs = []; - public WorkerManager(Logger logger, IDataStore dataStore, GameDatabaseProvider databaseProvider) + public WorkerManager(Logger logger, IDataStore dataStore, GameDatabaseProvider databaseProvider, IDateTimeProvider timeProvider) { this._dataStore = dataStore; this._databaseProvider = databaseProvider; this._logger = logger; + this._timeProvider = timeProvider; using GameDatabaseContext context = this._databaseProvider.GetContext(); this._workerId = context.CreateWorker(); @@ -49,6 +52,7 @@ public void RunWorkCycle() Database = this._databaseProvider.GetContext(), Logger = this._logger, DataStore = this._dataStore, + TimeProvider = this._timeProvider, }; foreach (WorkerJob job in this._jobs) diff --git a/RefreshTests.GameServer/TestContext.cs b/RefreshTests.GameServer/TestContext.cs index a8a0bd96c..3ea93f75a 100644 --- a/RefreshTests.GameServer/TestContext.cs +++ b/RefreshTests.GameServer/TestContext.cs @@ -267,6 +267,7 @@ public WorkContext GetWorkContext() Database = this.Database, Logger = this.Server.Value.Logger, DataStore = this.GetDataStore(), + TimeProvider = this.Time, }; } diff --git a/RefreshTests.GameServer/Tests/Workers/JobStateTests.cs b/RefreshTests.GameServer/Tests/Workers/JobStateTests.cs index bab10536c..8891b481a 100644 --- a/RefreshTests.GameServer/Tests/Workers/JobStateTests.cs +++ b/RefreshTests.GameServer/Tests/Workers/JobStateTests.cs @@ -14,7 +14,7 @@ public void RemovesJobStateIfJobDoesntExist() { using TestContext context = this.GetServer(); IDataStore dataStore = context.GetDataStore(); - WorkerManager manager = new(Logger, dataStore, context.DatabaseProvider); + WorkerManager manager = new(Logger, dataStore, context.DatabaseProvider, context.Time); context.Database.UpdateOrCreateJobState(typeof(TestMigrationJob).Name, new MigrationJobState(), WorkerClass.Refresh); Assert.That(context.Database.GetJobState(typeof(TestMigrationJob).Name, typeof(MigrationJobState), WorkerClass.Refresh), Is.Not.Null); @@ -34,7 +34,7 @@ public void DoesNotRemoveJobStateIfJobExists(bool uploadLevel) { using TestContext context = this.GetServer(); IDataStore dataStore = context.GetDataStore(); - WorkerManager manager = new(Logger, dataStore, context.DatabaseProvider); + WorkerManager manager = new(Logger, dataStore, context.DatabaseProvider, context.Time); TestMigrationJob job = new(); manager.AddJob(job); @@ -68,7 +68,7 @@ public void DoesNotRemoveJobStateIfNotRefreshClass() { using TestContext context = this.GetServer(); IDataStore dataStore = context.GetDataStore(); - WorkerManager manager = new(Logger, dataStore, context.DatabaseProvider); + WorkerManager manager = new(Logger, dataStore, context.DatabaseProvider, context.Time); context.Database.UpdateOrCreateJobState(typeof(TestMigrationJob).Name, new MigrationJobState(), WorkerClass.Craftworld); Assert.That(context.Database.GetJobState(typeof(TestMigrationJob).Name, typeof(MigrationJobState), WorkerClass.Craftworld), Is.Not.Null); @@ -89,7 +89,7 @@ public void ReExecutesMigrationJobAfterRollbackAndReupdate() { using TestContext context = this.GetServer(); IDataStore dataStore = context.GetDataStore(); - WorkerManager manager = new(Logger, dataStore, context.DatabaseProvider); + WorkerManager manager = new(Logger, dataStore, context.DatabaseProvider, context.Time); TestMigrationJob job = new(); manager.AddJob(job); @@ -135,7 +135,7 @@ public void ReExecutesMigrationJobAfterRollbackAndReupdate() // Simulate a roll-back, meaning the job wouldn't be in the WorkerManager anymore, so no migrations will happen, and the job state would be // auto-removed by WorkerManager.Start() in real cases. context.Database.Refresh(); - manager = new(Logger, dataStore, context.DatabaseProvider); + manager = new(Logger, dataStore, context.DatabaseProvider, context.Time); GameLevel thirdLevel = context.CreateLevel(user); manager.RemoveUnusedJobStates(); @@ -155,7 +155,7 @@ public void ReExecutesMigrationJobAfterRollbackAndReupdate() Assert.That(thirdLevelMigrated!.Title, Does.Not.EndWith(" test")); // Now simulate a re-update, where the job is in the WorkerManager again - manager = new(Logger, dataStore, context.DatabaseProvider); + manager = new(Logger, dataStore, context.DatabaseProvider, context.Time); job = new(); manager.AddJob(job); manager.RemoveUnusedJobStates(); From fff864736653a05fe93eee747509600f8e4ff61c Mon Sep 17 00:00:00 2001 From: Toaster2 Date: Thu, 16 Jul 2026 13:10:37 +0200 Subject: [PATCH 09/17] Fix and test NewUserJob --- .../Repeating/NewUserJob.cs | 6 ++- Refresh.Workers/WorkContext.cs | 1 + .../Tests/Workers/NewUserJobTests.cs | 47 +++++++++++++++++++ 3 files changed, 52 insertions(+), 2 deletions(-) create mode 100644 RefreshTests.GameServer/Tests/Workers/NewUserJobTests.cs diff --git a/Refresh.Interfaces.Workers/Repeating/NewUserJob.cs b/Refresh.Interfaces.Workers/Repeating/NewUserJob.cs index af256df83..4d5f50c78 100644 --- a/Refresh.Interfaces.Workers/Repeating/NewUserJob.cs +++ b/Refresh.Interfaces.Workers/Repeating/NewUserJob.cs @@ -24,14 +24,16 @@ public override void ExecuteJob(WorkContext context) DateTimeOffset now = context.TimeProvider.Now; DatabaseList newUsers = context.Database.GetAllUsersWithRole(GameUserRole.NewUser); - foreach (GameUser user in newUsers.Items) + foreach (GameUser user in newUsers.Items.ToList()) { // If an account is, e.g., 2 hours and 40 minutes old, and max age for new users is 3 hours, we wouldn't // consider max to be reached yet, so floor the difference. long accountAge = (long)Math.Floor(now.Subtract(user.JoinDate).TotalHours); + + context.Logger.LogDebug(RefreshContext.Worker, $"{nameof(NewUserJob)} - new user: {user}, join date: {user.JoinDate}, current time: {now}, account age: {accountAge}h, configured required age: {this._requiredAccountAge}h."); if (accountAge < this._requiredAccountAge) continue; // Don't promote user if they haven't reached max age yet - context.Logger.LogInfo(RefreshContext.Worker, $"Promoting {user} to regular user since their account is {accountAge}/{this._requiredAccountAge} hours old now."); + context.Logger.LogInfo(RefreshContext.Worker, $"Promoting {user} to regular user since their account is {accountAge} hours old now (required configured age: {this._requiredAccountAge}h)."); context.Database.SetUserRole(user, GameUserRole.User); } } diff --git a/Refresh.Workers/WorkContext.cs b/Refresh.Workers/WorkContext.cs index 675b68ae1..47b91c400 100644 --- a/Refresh.Workers/WorkContext.cs +++ b/Refresh.Workers/WorkContext.cs @@ -10,5 +10,6 @@ public class WorkContext : IDataContext public required GameDatabaseContext Database { get; init; } public required Logger Logger { get; init; } public required IDataStore DataStore { get; init; } + // TODO also use this in jobs outside of NewUserJob which also rely on current time public required IDateTimeProvider TimeProvider { get; init; } } \ No newline at end of file diff --git a/RefreshTests.GameServer/Tests/Workers/NewUserJobTests.cs b/RefreshTests.GameServer/Tests/Workers/NewUserJobTests.cs new file mode 100644 index 000000000..826f7a93b --- /dev/null +++ b/RefreshTests.GameServer/Tests/Workers/NewUserJobTests.cs @@ -0,0 +1,47 @@ +using Refresh.Database.Models.Users; +using Refresh.Interfaces.Workers.Repeating; +using Refresh.Workers; + +namespace RefreshTests.GameServer.Tests.Workers; + +public class NewUserJobTests : GameServerTest +{ + [Test] + [TestCase(120, GameUserRole.User)] // waiting exactly 2 hours is just enough for promotion + [TestCase(130, GameUserRole.User)] // waiting 2 hours and 10 minutes is more than enough + [TestCase(119, GameUserRole.NewUser)] // waiting 1 hour and 59 minutes is not enough, so stay as NewUser + [TestCase(60, GameUserRole.NewUser)] // waiting just 1 hour is totally not enough + public void NewUsersGetPromotedIfOldEnough(long fastForwardMinutes, GameUserRole resultingRole) + { + using TestContext context = this.GetServer(); + GameUser user = context.CreateUser(); + Assert.That(user.Role, Is.EqualTo(GameUserRole.NewUser)); + + // Prepare + WorkContext workContext = new() + { + Database = context.Database, + DataStore = context.GetDataStore(), + Logger = context.Server.Value.Logger, + TimeProvider = context.Time, + }; + NewUserJob job = new(2); // Set required age to 2 hours + + // Ensure job doesn't promote the user immediately + job.ExecuteJob(workContext); + context.Database.Refresh(); + GameUser? updatedUser = context.Database.GetUserByObjectId(user.UserId); + Assert.That(updatedUser, Is.Not.Null); + Assert.That(updatedUser!.Role, Is.EqualTo(GameUserRole.NewUser)); + + // skip forward 2 hours and try again + context.Time.TimestampMilliseconds += 1000 * 60 * fastForwardMinutes; + job.ExecuteJob(workContext); + context.Database.Refresh(); + + // Ensure job has promoted the user this time + updatedUser = context.Database.GetUserByObjectId(user.UserId); + Assert.That(updatedUser, Is.Not.Null); + Assert.That(updatedUser!.Role, Is.EqualTo(resultingRole)); + } +} \ No newline at end of file From d1ab386f151598f2a6b9ec14bfa6b4547d721563 Mon Sep 17 00:00:00 2001 From: Toaster2 Date: Thu, 16 Jul 2026 13:27:10 +0200 Subject: [PATCH 10/17] Improve new user config comments, remove currently unnecessary new option --- Refresh.Core/Configuration/GameServerConfig.cs | 14 +++++--------- 1 file changed, 5 insertions(+), 9 deletions(-) diff --git a/Refresh.Core/Configuration/GameServerConfig.cs b/Refresh.Core/Configuration/GameServerConfig.cs index 6c8ecdbae..7d231ce50 100644 --- a/Refresh.Core/Configuration/GameServerConfig.cs +++ b/Refresh.Core/Configuration/GameServerConfig.cs @@ -132,7 +132,8 @@ protected override void Migrate(int oldVer, dynamic oldConfig) this.TrustedUserPermissions.LevelUploadRateLimit.UploadQuota = (int)oldConfig.TrustedUserPermissions.TimedLevelUploadLimits.LevelQuota; } - // In version 29, role perms for new users were added + // In version 29, the NewUser role and its related config options + // (new user role perms and SetNewUserToNormalUserAfterHoursPassed) were added else if (oldVer < 29) { this.NewUserPermissions = oldConfig.NormalUserPermissions; @@ -155,11 +156,12 @@ protected override void Migrate(int oldVer, dynamic oldConfig) public RolePermissions TrustedUserPermissions = new(); /// - /// How long we should wait (in hours) until we should mark a new account as no longer new. + /// How long we should wait (in hours) until we should use NewUserJob to set a new user's role from NewUser to User, + /// effectively marking them as no longer new. /// Once their account hits this age, we will start applying NormalUserPermissions instead of NewUserPermissions /// as their role-perms. /// - public int HoursUntilNewAccountNoLongerNew { get; set; } = 48; // TODO better naming probably + public int HoursUntilNewAccountNoLongerNew { get; set; } = 24 * 7; // TODO should we think of a better name? public bool AllowUsersToUseIpAuthentication { get; set; } = false; public bool PermitPsnLogin { get; set; } = true; @@ -218,11 +220,5 @@ protected override void Migrate(int oldVer, dynamic oldConfig) public bool PermitShowingOnlineUsers { get; set; } = true; - /// - /// Whether users that are considered "new" should be shown on user categories, and whether their - /// rooms should be exposed via API. - /// - public bool PermitShowingNewUsers { get; set; } = true; - public bool EnableDiveIn { get; set; } = true; } \ No newline at end of file From 3809712d016ffd444f1d81c1cfec3f68f773fe81 Mon Sep 17 00:00:00 2001 From: Toaster2 Date: Thu, 16 Jul 2026 13:48:47 +0200 Subject: [PATCH 11/17] Make RoleService fall back to NewUser, fix TestContext.CreateUser role updating --- Refresh.Core/Services/RoleService.cs | 3 ++- RefreshTests.GameServer/TestContext.cs | 2 +- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/Refresh.Core/Services/RoleService.cs b/Refresh.Core/Services/RoleService.cs index 279ea2309..8385aaf43 100644 --- a/Refresh.Core/Services/RoleService.cs +++ b/Refresh.Core/Services/RoleService.cs @@ -31,12 +31,13 @@ internal RoleService(GameAuthenticationService authService, GameServerConfig con if (!(authAttrib?.Required ?? true)) return null; MinimumRoleAttribute? roleAttrib = method.GetCustomAttribute(); - GameUserRole minimumRole = roleAttrib?.MinimumRole ?? GameUserRole.User; + GameUserRole minimumRole = roleAttrib?.MinimumRole ?? GameUserRole.NewUser; GameUser? user = (GameUser?)this._authService.AuthenticateToken(context, database)?.User; if (user == null) return null; // Let AuthenticationProvider handle 401 // if the user's role is lower than the minimum role for this endpoint, then return unauthorized + // by default, Restricted and Banned will be rejected by this since the default role is NewUser if (user.Role < minimumRole) { return Unauthorized; diff --git a/RefreshTests.GameServer/TestContext.cs b/RefreshTests.GameServer/TestContext.cs index 3ea93f75a..4a46ec3f2 100644 --- a/RefreshTests.GameServer/TestContext.cs +++ b/RefreshTests.GameServer/TestContext.cs @@ -126,7 +126,7 @@ public GameUser CreateUser(string? username = null, GameUserRole role = GameUser username ??= this.UserIncrement.ToString(); GameUser user = this.Database.CreateUser(username, $"{username}@{username}.local"); - if (role != GameUserRole.User) this.Database.SetUserRole(user, role); + if (user.Role != GameUserRole.User) this.Database.SetUserRole(user, role); if (verifyEmail) this.Database.VerifyUserEmail(user); this.Database.Entry(user).State = EntityState.Unchanged; From 1220a607c663b7b68ef50817977afdbc2afffdff Mon Sep 17 00:00:00 2001 From: Toaster2 Date: Thu, 16 Jul 2026 14:16:53 +0200 Subject: [PATCH 12/17] Update NormalUserPermissions summary --- Refresh.Core/Configuration/GameServerConfig.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Refresh.Core/Configuration/GameServerConfig.cs b/Refresh.Core/Configuration/GameServerConfig.cs index 7d231ce50..b7726c4ea 100644 --- a/Refresh.Core/Configuration/GameServerConfig.cs +++ b/Refresh.Core/Configuration/GameServerConfig.cs @@ -147,11 +147,11 @@ protected override void Migrate(int oldVer, dynamic oldConfig) /// public RolePermissions NewUserPermissions = new(); /// - /// Role-specific permissions for normal, not-new users and restricted users (if applicable) + /// Role-specific permissions for normal, not-new users. /// public RolePermissions NormalUserPermissions = new(); /// - /// Role-specific permissions for trusted users and above + /// Role-specific permissions for trusted users and above. /// public RolePermissions TrustedUserPermissions = new(); From 102f065bf58311f6b430a207eb53a2083063f1cb Mon Sep 17 00:00:00 2001 From: Toaster2 Date: Thu, 16 Jul 2026 14:25:24 +0200 Subject: [PATCH 13/17] Fix another outdated summary --- Refresh.Core/Configuration/GameServerConfig.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Refresh.Core/Configuration/GameServerConfig.cs b/Refresh.Core/Configuration/GameServerConfig.cs index b7726c4ea..b4074cfc1 100644 --- a/Refresh.Core/Configuration/GameServerConfig.cs +++ b/Refresh.Core/Configuration/GameServerConfig.cs @@ -133,7 +133,7 @@ protected override void Migrate(int oldVer, dynamic oldConfig) } // In version 29, the NewUser role and its related config options - // (new user role perms and SetNewUserToNormalUserAfterHoursPassed) were added + // (new user role perms and HoursUntilNewAccountNoLongerNew) were added else if (oldVer < 29) { this.NewUserPermissions = oldConfig.NormalUserPermissions; From 976619c29c32b71ee5f82dd596b934cc23c3ba23 Mon Sep 17 00:00:00 2001 From: Toaster2 Date: Thu, 16 Jul 2026 14:42:50 +0200 Subject: [PATCH 14/17] Revert stuff that should be done in separate PRs --- Refresh.Database/GameDatabaseContext.Users.cs | 2 -- Refresh.Interfaces.Game/Endpoints/Levels/PublishEndpoints.cs | 2 +- 2 files changed, 1 insertion(+), 3 deletions(-) diff --git a/Refresh.Database/GameDatabaseContext.Users.cs b/Refresh.Database/GameDatabaseContext.Users.cs index 4087bda76..40832aa5a 100644 --- a/Refresh.Database/GameDatabaseContext.Users.cs +++ b/Refresh.Database/GameDatabaseContext.Users.cs @@ -315,9 +315,7 @@ public int GetActiveUserCount() public void SetUserRole(GameUser user, GameUserRole role) { - // TODO allow restricting/banning/pardoning via CLI if(role == GameUserRole.Banned) throw new InvalidOperationException($"Cannot ban a user with this method. Please use {nameof(this.BanUser)}()."); - if(role == GameUserRole.Restricted) throw new InvalidOperationException($"Cannot restrict a user with this method. Please use {nameof(this.RestrictUser)}()."); if (user.Role is GameUserRole.Banned or GameUserRole.Restricted) { diff --git a/Refresh.Interfaces.Game/Endpoints/Levels/PublishEndpoints.cs b/Refresh.Interfaces.Game/Endpoints/Levels/PublishEndpoints.cs index a3526673d..1497549a6 100644 --- a/Refresh.Interfaces.Game/Endpoints/Levels/PublishEndpoints.cs +++ b/Refresh.Interfaces.Game/Endpoints/Levels/PublishEndpoints.cs @@ -110,7 +110,7 @@ public Response StartPublish(RequestContext context, { if (dataContext.User!.IsWriteBlocked(config)) { - dataContext.Database.AddPublishFailNotification($"Your user role ({user.Role}) is currently set to read-only by the server.", body.Title, dataContext.User!); + dataContext.Database.AddPublishFailNotification($"The server is in read-only mode.", body.Title, dataContext.User!); return Unauthorized; } From bf5ec0758fc33ff6b997a27b0a1f2a3c0cf35392 Mon Sep 17 00:00:00 2001 From: Toaster2 Date: Thu, 16 Jul 2026 14:54:59 +0200 Subject: [PATCH 15/17] Improve NewUser summary --- Refresh.Database/Models/Users/GameUserRole.cs | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/Refresh.Database/Models/Users/GameUserRole.cs b/Refresh.Database/Models/Users/GameUserRole.cs index c4f9c66d3..57b70cc63 100644 --- a/Refresh.Database/Models/Users/GameUserRole.cs +++ b/Refresh.Database/Models/Users/GameUserRole.cs @@ -30,8 +30,9 @@ public enum GameUserRole : sbyte /// User = 0, /// - /// A newly registered user. Can have different, usually more restrictive configurable perms than regular users, to make spam harder. - /// The duration in which an account's age makes it "new" is defined by config. + /// A newly registered user. Can have different, usually more restrictive configurable perms than regular users. + /// Useful to make spam more difficult, for example. The duration in which an account's age makes it "new" is defined by config. + /// Automatically promoted to User by NewUserJob. /// NewUser = -32, /// From a45861c1bfa02ce4a6a32152d4f7583c50f56887 Mon Sep 17 00:00:00 2001 From: Toaster2 Date: Thu, 16 Jul 2026 15:17:51 +0200 Subject: [PATCH 16/17] Fix stupids, add default role tests --- RefreshTests.GameServer/TestContext.cs | 3 ++- .../Tests/Users/RegistrationQueueTests.cs | 15 +++++++++++++++ .../Tests/Users/UserRoleTests.cs | 16 ++++++++++++---- .../Tests/Workers/NewUserJobTests.cs | 12 +++--------- 4 files changed, 32 insertions(+), 14 deletions(-) diff --git a/RefreshTests.GameServer/TestContext.cs b/RefreshTests.GameServer/TestContext.cs index 4a46ec3f2..ce9b0e294 100644 --- a/RefreshTests.GameServer/TestContext.cs +++ b/RefreshTests.GameServer/TestContext.cs @@ -121,12 +121,13 @@ public Token GenerateToken(GameUser? user, TokenType type, TokenGame game, Token return this.Database.GenerateTokenForUser(user ?? this.CreateUser(), type, game, platform, ipAddress ?? "0.0.0.0", tokenExpirySeconds); } + // Not changing default role to NewUser because I don't want to potentially break over 500 usages. public GameUser CreateUser(string? username = null, GameUserRole role = GameUserRole.User, bool verifyEmail = true) { username ??= this.UserIncrement.ToString(); GameUser user = this.Database.CreateUser(username, $"{username}@{username}.local"); - if (user.Role != GameUserRole.User) this.Database.SetUserRole(user, role); + if (user.Role != role) this.Database.SetUserRole(user, role); if (verifyEmail) this.Database.VerifyUserEmail(user); this.Database.Entry(user).State = EntityState.Unchanged; diff --git a/RefreshTests.GameServer/Tests/Users/RegistrationQueueTests.cs b/RefreshTests.GameServer/Tests/Users/RegistrationQueueTests.cs index f556cd67b..19333bb85 100644 --- a/RefreshTests.GameServer/Tests/Users/RegistrationQueueTests.cs +++ b/RefreshTests.GameServer/Tests/Users/RegistrationQueueTests.cs @@ -30,4 +30,19 @@ public void CreateAccountFromQueueDespiteWrongUsernameCasing() Assert.That(context.Database.GetUserByUsername(wrongUsername, false)?.UserId, Is.EqualTo(context.Database.GetUserByUsername(correctUsername, false)?.UserId)); Assert.That(context.Database.GetUserByUsername(wrongUsername, true)?.Username, Is.Null); } + + [Test] + public void UsersCreatedFromQueueAreNewUsers() + { + using TestContext context = this.GetServer(); + context.Database.AddRegistrationToQueue("new", "new@new.new", "some amazing credentials!"); + + QueuedRegistration? registration = context.Database.GetQueuedRegistrationByUsername("new"); + Assert.That(registration, Is.Not.Null); + + // Now use it to create an account + GameUser user = context.Database.CreateUserFromQueuedRegistration(registration, TokenPlatform.PS3); + Assert.That(user.Username, Is.EqualTo("new")); + Assert.That(user.Role, Is.EqualTo(GameUserRole.NewUser)); + } } \ No newline at end of file diff --git a/RefreshTests.GameServer/Tests/Users/UserRoleTests.cs b/RefreshTests.GameServer/Tests/Users/UserRoleTests.cs index 72eed7d98..0727b7e27 100644 --- a/RefreshTests.GameServer/Tests/Users/UserRoleTests.cs +++ b/RefreshTests.GameServer/Tests/Users/UserRoleTests.cs @@ -21,8 +21,7 @@ public void EnsureUsersUseCorrectRolePerms() config.TrustedUserPermissions.UserFilesizeQuota = 23456; config.TrustedUserPermissions.ReadOnlyMode = false; - // New user - GameUser user = context.CreateUser(); + GameUser user = context.CreateUser(role: GameUserRole.NewUser); Assert.That(user.Role, Is.EqualTo(GameUserRole.NewUser)); RolePermissions perms = user.GetRolePermissionsForUser(config); @@ -80,15 +79,24 @@ public void EnsureUsersUseCorrectRolePerms() Assert.That(perms.ReadOnlyMode, Is.True); Assert.That(user.IsWriteBlocked(config), Is.True); } + + [Test] + public void EnsureNewlyCreatedUsersAreNewUsers() + { + // Ensure using the database method causes users to have the NewUser role + using TestContext context = this.GetServer(); + GameUser user = context.Database.CreateUser("new", "new@new.com"); + Assert.That(user.Role, Is.EqualTo(GameUserRole.NewUser)); + } [Test] - public void EnsureSettingRoleToRestrictedOrBannedManuallyThrows() + public void EnsureSettingRoleToBannedManuallyThrows() { using TestContext context = this.GetServer(); GameUser user = context.CreateUser(); Assert.That(user.Role, Is.EqualTo(GameUserRole.NewUser)); - Assert.That(() => context.Database.SetUserRole(user, GameUserRole.Restricted), Throws.TypeOf()); + //Assert.That(() => context.Database.SetUserRole(user, GameUserRole.Restricted), Throws.TypeOf()); // TODO consistent behaviour Assert.That(() => context.Database.SetUserRole(user, GameUserRole.Banned), Throws.TypeOf()); } } \ No newline at end of file diff --git a/RefreshTests.GameServer/Tests/Workers/NewUserJobTests.cs b/RefreshTests.GameServer/Tests/Workers/NewUserJobTests.cs index 826f7a93b..047ecb7c8 100644 --- a/RefreshTests.GameServer/Tests/Workers/NewUserJobTests.cs +++ b/RefreshTests.GameServer/Tests/Workers/NewUserJobTests.cs @@ -14,17 +14,11 @@ public class NewUserJobTests : GameServerTest public void NewUsersGetPromotedIfOldEnough(long fastForwardMinutes, GameUserRole resultingRole) { using TestContext context = this.GetServer(); - GameUser user = context.CreateUser(); + GameUser user = context.CreateUser(role: GameUserRole.NewUser); Assert.That(user.Role, Is.EqualTo(GameUserRole.NewUser)); // Prepare - WorkContext workContext = new() - { - Database = context.Database, - DataStore = context.GetDataStore(), - Logger = context.Server.Value.Logger, - TimeProvider = context.Time, - }; + WorkContext workContext = context.GetWorkContext(); NewUserJob job = new(2); // Set required age to 2 hours // Ensure job doesn't promote the user immediately @@ -39,7 +33,7 @@ public void NewUsersGetPromotedIfOldEnough(long fastForwardMinutes, GameUserRole job.ExecuteJob(workContext); context.Database.Refresh(); - // Ensure job has promoted the user this time + // Ensure job has promoted the user this time (if enough time has passed) updatedUser = context.Database.GetUserByObjectId(user.UserId); Assert.That(updatedUser, Is.Not.Null); Assert.That(updatedUser!.Role, Is.EqualTo(resultingRole)); From 1a3785c85aecf86c7b4c518dfd82285ee2bebde6 Mon Sep 17 00:00:00 2001 From: Toaster2 Date: Thu, 16 Jul 2026 15:32:55 +0200 Subject: [PATCH 17/17] Remove unnecessary assert --- RefreshTests.GameServer/Tests/Users/UserRoleTests.cs | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/RefreshTests.GameServer/Tests/Users/UserRoleTests.cs b/RefreshTests.GameServer/Tests/Users/UserRoleTests.cs index 0727b7e27..8f7c5e6f0 100644 --- a/RefreshTests.GameServer/Tests/Users/UserRoleTests.cs +++ b/RefreshTests.GameServer/Tests/Users/UserRoleTests.cs @@ -90,11 +90,10 @@ public void EnsureNewlyCreatedUsersAreNewUsers() } [Test] - public void EnsureSettingRoleToBannedManuallyThrows() + public void EnsureManuallySettingRoleToBannedThrows() { using TestContext context = this.GetServer(); GameUser user = context.CreateUser(); - Assert.That(user.Role, Is.EqualTo(GameUserRole.NewUser)); //Assert.That(() => context.Database.SetUserRole(user, GameUserRole.Restricted), Throws.TypeOf()); // TODO consistent behaviour Assert.That(() => context.Database.SetUserRole(user, GameUserRole.Banned), Throws.TypeOf());