From 1535d45aa9bab897635e1e3586907e73750b87dd Mon Sep 17 00:00:00 2001 From: Toaster2 Date: Sat, 11 Apr 2026 12:26:14 +0200 Subject: [PATCH 01/10] Add DisallowedAssets to DB + DB methods --- .../GameDatabaseContext.Assets.cs | 37 +++++++++++++++++++ Refresh.Database/GameDatabaseContext.cs | 1 + .../Models/Assets/DisallowedAsset.cs | 13 +++++++ 3 files changed, 51 insertions(+) create mode 100644 Refresh.Database/Models/Assets/DisallowedAsset.cs diff --git a/Refresh.Database/GameDatabaseContext.Assets.cs b/Refresh.Database/GameDatabaseContext.Assets.cs index 61b8e5f3..db4fcf72 100644 --- a/Refresh.Database/GameDatabaseContext.Assets.cs +++ b/Refresh.Database/GameDatabaseContext.Assets.cs @@ -110,4 +110,41 @@ public void SetMainlinePhotoHash(GameAsset asset, string hash) => { asset.AsMainlinePhotoHash = hash; }); + + public DisallowedAsset? GetAssetDisallowanceInfo(string hash) + => this.DisallowedAssets.FirstOrDefault(d => d.AssetHash == hash); + + /// + /// The asset's disallowance info + whether the asset wasn't already disallowed before + /// FilterOutAllowedAssets(List hashes) + => this.DisallowedAssets + .Where(d => hashes.Contains(d.AssetHash)) + .Select(d => d.AssetHash); } \ No newline at end of file diff --git a/Refresh.Database/GameDatabaseContext.cs b/Refresh.Database/GameDatabaseContext.cs index ea851668..23c696de 100644 --- a/Refresh.Database/GameDatabaseContext.cs +++ b/Refresh.Database/GameDatabaseContext.cs @@ -66,6 +66,7 @@ public partial class GameDatabaseContext : DbContext, IDatabaseContext internal DbSet DisallowedUsers { get; set; } internal DbSet DisallowedEmailAddresses { get; set; } internal DbSet DisallowedEmailDomains { get; set; } + internal DbSet DisallowedAssets { get; set; } internal DbSet RateReviewRelations { get; set; } internal DbSet TagLevelRelations { get; set; } internal DbSet GamePlaylists { get; set; } diff --git a/Refresh.Database/Models/Assets/DisallowedAsset.cs b/Refresh.Database/Models/Assets/DisallowedAsset.cs new file mode 100644 index 00000000..f67e446c --- /dev/null +++ b/Refresh.Database/Models/Assets/DisallowedAsset.cs @@ -0,0 +1,13 @@ +using Refresh.Database.Models.Assets; + +#nullable disable + +public partial class DisallowedAsset +{ + [Key] + public string AssetHash { get; set; } + public GameAssetType AssetType { get; set; } + + // Could be a short description of what this asset is (to understand why it's blocked) + public string Reason { get; set; } // TODO: maybe also add reasons to the other 3 "DisallowedX" entities, separately from ModerationAction? +} \ No newline at end of file From 727994adbe956193bf323a49d08884fd5bdd5b18 Mon Sep 17 00:00:00 2001 From: Toaster2 Date: Sat, 11 Apr 2026 17:06:39 +0200 Subject: [PATCH 02/10] Add timestamp to DisallowedAsset --- Refresh.Database/GameDatabaseContext.Assets.cs | 12 +++++++++--- Refresh.Database/Models/Assets/DisallowedAsset.cs | 10 ++++------ 2 files changed, 13 insertions(+), 9 deletions(-) diff --git a/Refresh.Database/GameDatabaseContext.Assets.cs b/Refresh.Database/GameDatabaseContext.Assets.cs index db4fcf72..d4d80db4 100644 --- a/Refresh.Database/GameDatabaseContext.Assets.cs +++ b/Refresh.Database/GameDatabaseContext.Assets.cs @@ -111,7 +111,7 @@ public void SetMainlinePhotoHash(GameAsset asset, string hash) => asset.AsMainlinePhotoHash = hash; }); - public DisallowedAsset? GetAssetDisallowanceInfo(string hash) + public DisallowedAsset? GetDisallowedAssetInfo(string hash) => this.DisallowedAssets.FirstOrDefault(d => d.AssetHash == hash); /// @@ -121,7 +121,7 @@ public void SetMainlinePhotoHash(GameAsset asset, string hash) => // and make their entities also store more info (reason, timestamp etc.) public (DisallowedAsset, bool) DisallowAsset(string hash, GameAssetType type, string reason) { - DisallowedAsset? existing = this.GetAssetDisallowanceInfo(hash); + DisallowedAsset? existing = this.GetDisallowedAssetInfo(hash); if (existing != null) return (existing, false); DisallowedAsset disallowed = new() @@ -136,7 +136,7 @@ public void SetMainlinePhotoHash(GameAsset asset, string hash) => public bool ReallowAsset(string hash) { - DisallowedAsset? existing = this.GetAssetDisallowanceInfo(hash); + DisallowedAsset? existing = this.GetDisallowedAssetInfo(hash); if (existing == null) return false; this.DisallowedAssets.Remove(existing); @@ -147,4 +147,10 @@ public IQueryable FilterOutAllowedAssets(List hashes) => this.DisallowedAssets .Where(d => hashes.Contains(d.AssetHash)) .Select(d => d.AssetHash); + + public DatabaseList GetDisallowedAssets(int skip, int count) + => new(this.DisallowedAssets, skip, count); + + public DatabaseList GetDisallowedAssetsByType(GameAssetType type, int skip, int count) + => new(this.DisallowedAssets.Where(d => d.AssetType == type), skip, count); } \ No newline at end of file diff --git a/Refresh.Database/Models/Assets/DisallowedAsset.cs b/Refresh.Database/Models/Assets/DisallowedAsset.cs index f67e446c..8a0d7ee9 100644 --- a/Refresh.Database/Models/Assets/DisallowedAsset.cs +++ b/Refresh.Database/Models/Assets/DisallowedAsset.cs @@ -1,13 +1,11 @@ using Refresh.Database.Models.Assets; -#nullable disable - public partial class DisallowedAsset { - [Key] - public string AssetHash { get; set; } + [Key] public string AssetHash { get; set; } = null!; + public GameAssetType AssetType { get; set; } - // Could be a short description of what this asset is (to understand why it's blocked) - public string Reason { get; set; } // TODO: maybe also add reasons to the other 3 "DisallowedX" entities, separately from ModerationAction? + public string Reason { get; set; } = ""; + public DateTimeOffset DisallowedAt { get; set; } } \ No newline at end of file From b1c6b14537c00579df8aa68c479ab6a9b8978cc4 Mon Sep 17 00:00:00 2001 From: Toaster2 Date: Sat, 11 Apr 2026 17:07:43 +0200 Subject: [PATCH 03/10] Ability to disallow asset via CLI --- Refresh.GameServer/CommandLineManager.cs | 50 ++++++++++++++++++++++++ Refresh.GameServer/RefreshGameServer.cs | 17 ++++++++ 2 files changed, 67 insertions(+) diff --git a/Refresh.GameServer/CommandLineManager.cs b/Refresh.GameServer/CommandLineManager.cs index bff16e94..86f00ffd 100644 --- a/Refresh.GameServer/CommandLineManager.cs +++ b/Refresh.GameServer/CommandLineManager.cs @@ -1,6 +1,7 @@ using System.Diagnostics.CodeAnalysis; using CommandLine; using Refresh.Database; +using Refresh.Database.Models.Assets; using Refresh.Database.Models.Users; using Refresh.Interfaces.APIv3.Documentation; @@ -71,6 +72,22 @@ private class Options [Option("reallow-email-domain", HelpText = "Re-allow the email domain to be used by anyone. Email option is required if this is set. If a whole Email address is given, only the substring after the last @ will be used.")] public bool ReallowEmailDomain { get; set; } + + [Option("disallow-asset", HelpText = "Disallow an asset by hash. While this won't delete the asset if it already exists on the server, it will prevent it from being uploaded if it doesn't exist yet, and will do various other things (reset icons, instruct the game to censor this asset, prevent publishing levels/photos which use this asset, etc)." + + "Asset option is required if this is set, and both the Type and Reason options are optional.")] + public bool DisallowAsset { get; set; } + + [Option("reallow-asset", HelpText = "Re-allow an asset by hash. It may be uploaded and used in various UGC again. Asset option is required if this is set.")] + public bool ReallowAsset { get; set; } + + [Option("asset", HelpText = "The hash of the asset to operate with.")] + public string? AssetHash { get; set; } + + [Option("type", HelpText = "The type of the asset to use. If this isn't set, we will use the corrensponding GameAsset's type from DB instead, if it exists.")] + public string? AssetType { get; set; } + + [Option("reason", HelpText = "The (usually optional) reason for a moderation action, such as asset disallowance.")] + public string? Reason { get; set; } [Option("rename-user", HelpText = "Changes a user's username. (old) username or Email option is required if this is set.")] public string? RenameUser { get; set; } @@ -236,6 +253,39 @@ private void StartWithOptions(Options options) } else Fail("No email domain was provided"); } + else if (options.DisallowAsset) + { + if (options.AssetHash != null) + { + GameAssetType? type = null; + if (options.AssetType != null) + { + bool parsed = Enum.TryParse(options.AssetType, true, out GameAssetType assetType); + if (!parsed) + { + Fail($"The asset type '{options.AssetType}' couldn't be parsed. Possible values: " + + string.Join(", ", Enum.GetNames(typeof(GameAssetType)))); + + return; + } + + type = assetType; + } + + if (!this._server.DisallowAsset(options.AssetHash, type, options.Reason)) + Fail("Asset is already disallowed"); + } + else Fail("No asset hash was provided"); + } + else if (options.ReallowAsset) + { + if (options.AssetHash != null) + { + if (!this._server.ReallowAsset(options.AssetHash)) + Fail("Asset is already allowed"); + } + else Fail("No asset hash was provided"); + } else if (options.RenameUser != null) { if(string.IsNullOrWhiteSpace(options.RenameUser)) diff --git a/Refresh.GameServer/RefreshGameServer.cs b/Refresh.GameServer/RefreshGameServer.cs index f012fafa..b8668714 100644 --- a/Refresh.GameServer/RefreshGameServer.cs +++ b/Refresh.GameServer/RefreshGameServer.cs @@ -32,6 +32,7 @@ using Refresh.Interfaces.Workers; using Refresh.Interfaces.Workers.Repeating; using Refresh.Workers; +using Refresh.Database.Models.Assets; namespace Refresh.GameServer; @@ -313,6 +314,22 @@ public bool ReallowEmailDomain(string domain) return context.ReallowEmailDomain(domain); } + public bool DisallowAsset(string hash, GameAssetType? type, string? reason) + { + using GameDatabaseContext context = this.GetContext(); + type ??= context.GetAssetFromHash(hash)?.AssetType; + + (DisallowedAsset disallowed, bool success) = context.DisallowAsset(hash, type ?? GameAssetType.Unknown, reason ?? ""); + return success; + } + + public bool ReallowAsset(string hash) + { + using GameDatabaseContext context = this.GetContext(); + + return context.ReallowAsset(hash); + } + public void RenameUser(GameUser user, string newUsername, bool force = false) { using GameDatabaseContext context = this.GetContext(); From 03e2d06dbec26342bf3e9d9d166045a9a3ab6d79 Mon Sep 17 00:00:00 2001 From: Toaster2 Date: Sat, 11 Apr 2026 17:08:57 +0200 Subject: [PATCH 04/10] Proper /showModerated implementation --- Refresh.Interfaces.Game/Endpoints/ModerationEndpoints.cs | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/Refresh.Interfaces.Game/Endpoints/ModerationEndpoints.cs b/Refresh.Interfaces.Game/Endpoints/ModerationEndpoints.cs index 8fdcfc41..d67312b7 100644 --- a/Refresh.Interfaces.Game/Endpoints/ModerationEndpoints.cs +++ b/Refresh.Interfaces.Game/Endpoints/ModerationEndpoints.cs @@ -6,6 +6,7 @@ using Refresh.Core.Authentication.Permission; using Refresh.Core.Services; using Refresh.Core.Types.Commands; +using Refresh.Core.Types.Data; using Refresh.Database; using Refresh.Database.Models.Authentication; using Refresh.Database.Models.Users; @@ -26,11 +27,11 @@ public SerializedModeratedSlotList ModerateSlots(RequestContext context, Seriali } [GameEndpoint("showModerated", HttpMethods.Post, ContentType.Xml)] - public SerializedModeratedResourceList ModerateResources(RequestContext context, SerializedModeratedResourceList body) + public SerializedModeratedResourceList ModerateResources(RequestContext context, SerializedModeratedResourceList body, DataContext dataContext) { return new SerializedModeratedResourceList { - Resources = new List(), + Resources = dataContext.Database.FilterOutAllowedAssets(body.Resources).ToList(), }; } From df55e980e0d9c923aa94aaa29c825f6ca3f03a12 Mon Sep 17 00:00:00 2001 From: Toaster2 Date: Sat, 11 Apr 2026 17:33:54 +0200 Subject: [PATCH 05/10] Test asset disallowance --- .../Tests/Assets/AssetDisallowanceTests.cs | 118 ++++++++++++++++++ 1 file changed, 118 insertions(+) create mode 100644 RefreshTests.GameServer/Tests/Assets/AssetDisallowanceTests.cs diff --git a/RefreshTests.GameServer/Tests/Assets/AssetDisallowanceTests.cs b/RefreshTests.GameServer/Tests/Assets/AssetDisallowanceTests.cs new file mode 100644 index 00000000..867ab98b --- /dev/null +++ b/RefreshTests.GameServer/Tests/Assets/AssetDisallowanceTests.cs @@ -0,0 +1,118 @@ +using Refresh.Database.Models.Assets; +using Refresh.Database.Models.Authentication; +using Refresh.Database.Models.Users; +using Refresh.Interfaces.Game.Types.UserData; +using RefreshTests.GameServer.Extensions; + +namespace RefreshTests.GameServer.Tests.Assets; + +public class AssetDisallowanceTests : GameServerTest +{ + [Test] + public void CanDisallowAndReallowAsset() + { + using TestContext context = this.GetServer(); + + string hash = "trash"; + GameAssetType type = GameAssetType.Mesh; + + // Ensure that the asset isn't already disallowed + Assert.That(context.Database.GetDisallowedAssetInfo(hash), Is.Null); + + // Disallow + (DisallowedAsset disallowed, bool success) = context.Database.DisallowAsset(hash, type, "too ugly"); + Assert.That(success, Is.True); + Assert.That(disallowed.AssetHash, Is.EqualTo(hash)); + Assert.That(disallowed.AssetType, Is.EqualTo(type)); + Assert.That(disallowed.Reason, Is.EqualTo("too ugly")); + + // Ensure that the same entity is gotten again, and the DB method doesn't try to insert a new one + (disallowed, success) = context.Database.DisallowAsset(hash, type, "too ugly"); + Assert.That(success, Is.False); + Assert.That(disallowed.AssetHash, Is.EqualTo(hash)); + Assert.That(disallowed.AssetType, Is.EqualTo(type)); + Assert.That(disallowed.Reason, Is.EqualTo("too ugly")); + + // ensure that the separately gotten entity is also the same + DisallowedAsset? gottenAgain = context.Database.GetDisallowedAssetInfo(hash); + Assert.That(gottenAgain, Is.Not.Null); + Assert.That(success, Is.False); + Assert.That(disallowed.AssetHash, Is.EqualTo(hash)); + Assert.That(disallowed.AssetType, Is.EqualTo(type)); + Assert.That(disallowed.Reason, Is.EqualTo("too ugly")); + + // Ensure it doesn't also return this if the hash is different + Assert.That(context.Database.GetDisallowedAssetInfo("bash"), Is.Null); + + // Reallow + success = context.Database.ReallowAsset(hash); + Assert.That(success, Is.True); + Assert.That(context.Database.GetDisallowedAssetInfo(hash), Is.Null); + + // Reallow again + success = context.Database.ReallowAsset(hash); + Assert.That(success, Is.False); + Assert.That(context.Database.GetDisallowedAssetInfo(hash), Is.Null); + } + + [Test] + public void ShowModeratedReturnsDisallowedAssetHashes() + { + using TestContext context = this.GetServer(); + GameAsset one = new() + { + AssetHash = "1", + AssetType = GameAssetType.Texture, + AssetFormat = GameAssetFormat.Binary, + }; + GameAsset two = new() + { + AssetHash = "2", + AssetType = GameAssetType.Texture, + AssetFormat = GameAssetFormat.Binary, + }; + GameAsset three = new() + { + AssetHash = "3", + AssetType = GameAssetType.Texture, + AssetFormat = GameAssetFormat.Binary, + }; + GameAsset four = new() + { + AssetHash = "4", + AssetType = GameAssetType.Texture, + AssetFormat = GameAssetFormat.Binary, + }; + + context.Database.AddAssetsToDatabase([one, two, three, four]); + GameUser user = context.CreateUser(); + HttpClient client = context.GetAuthenticatedClient(TokenType.Game, user); + + SerializedModeratedResourceList request = new() + { + Resources = ["1", "2", "4", "6"] // also test against assets not in DB (in this case "6") + }; + HttpResponseMessage message = client.PostAsync("/lbp/showModerated", new StringContent(request.AsXML())).Result; + Assert.That(message.StatusCode, Is.EqualTo(OK)); + + SerializedModeratedResourceList response = message.Content.ReadAsXML(); + Assert.That(response.Resources, Is.Empty); + + // Now disallow a few assets and try again + context.Database.DisallowAsset("3", GameAssetType.Texture, "Cringe drawing"); + context.Database.DisallowAsset("7", GameAssetType.Plan, "Cringe drawing"); + context.Database.DisallowAsset("9", GameAssetType.Plan, "Cringe drawing"); + + request = new() + { + Resources = ["1", "3", "4", "6", "7"] + }; + message = client.PostAsync("/lbp/showModerated", new StringContent(request.AsXML())).Result; + Assert.That(message.StatusCode, Is.EqualTo(OK)); + + response = message.Content.ReadAsXML(); + Assert.That(response.Resources.Count, Is.EqualTo(2)); + Assert.That(response.Resources.Contains("3"), Is.True); + Assert.That(response.Resources.Contains("7"), Is.True); + } +} \ No newline at end of file From 8342cb2a1f9b97348143c8c0e5deeb78b0111d7f Mon Sep 17 00:00:00 2001 From: Toaster2 Date: Sat, 11 Apr 2026 17:34:49 +0200 Subject: [PATCH 06/10] Forgot to actually call SaveChanges() when dis/reallowing asset --- Refresh.Database/GameDatabaseContext.Assets.cs | 3 +++ 1 file changed, 3 insertions(+) diff --git a/Refresh.Database/GameDatabaseContext.Assets.cs b/Refresh.Database/GameDatabaseContext.Assets.cs index d4d80db4..e6b3ed7d 100644 --- a/Refresh.Database/GameDatabaseContext.Assets.cs +++ b/Refresh.Database/GameDatabaseContext.Assets.cs @@ -130,7 +130,9 @@ public void SetMainlinePhotoHash(GameAsset asset, string hash) => AssetType = type, Reason = reason, }; + this.DisallowedAssets.Add(disallowed); + this.SaveChanges(); return (disallowed, true); } @@ -140,6 +142,7 @@ public bool ReallowAsset(string hash) if (existing == null) return false; this.DisallowedAssets.Remove(existing); + this.SaveChanges(); return true; } From f656aa350cc04704d24d7153ecec510568ae03e6 Mon Sep 17 00:00:00 2001 From: Toaster2 Date: Sat, 11 Apr 2026 17:54:00 +0200 Subject: [PATCH 07/10] Reword CLI options --- Refresh.GameServer/CommandLineManager.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Refresh.GameServer/CommandLineManager.cs b/Refresh.GameServer/CommandLineManager.cs index 86f00ffd..ed21a839 100644 --- a/Refresh.GameServer/CommandLineManager.cs +++ b/Refresh.GameServer/CommandLineManager.cs @@ -73,14 +73,14 @@ private class Options [Option("reallow-email-domain", HelpText = "Re-allow the email domain to be used by anyone. Email option is required if this is set. If a whole Email address is given, only the substring after the last @ will be used.")] public bool ReallowEmailDomain { get; set; } - [Option("disallow-asset", HelpText = "Disallow an asset by hash. While this won't delete the asset if it already exists on the server, it will prevent it from being uploaded if it doesn't exist yet, and will do various other things (reset icons, instruct the game to censor this asset, prevent publishing levels/photos which use this asset, etc)." + [Option("disallow-asset", HelpText = "Disallow an asset by hash. While this won't delete the asset, it will prevent it from being uploaded in the future, and do other actions, such as instructing the game to censor this asset. " + "Asset option is required if this is set, and both the Type and Reason options are optional.")] public bool DisallowAsset { get; set; } [Option("reallow-asset", HelpText = "Re-allow an asset by hash. It may be uploaded and used in various UGC again. Asset option is required if this is set.")] public bool ReallowAsset { get; set; } - [Option("asset", HelpText = "The hash of the asset to operate with.")] + [Option("asset", HelpText = "The hash of the asset to operate on.")] public string? AssetHash { get; set; } [Option("type", HelpText = "The type of the asset to use. If this isn't set, we will use the corrensponding GameAsset's type from DB instead, if it exists.")] From 4a07171c0fd0992ace7da6373df1794608e2aedb Mon Sep 17 00:00:00 2001 From: Toaster2 Date: Sat, 11 Apr 2026 17:54:19 +0200 Subject: [PATCH 08/10] Actually set timestamp of DisallowedAsset --- Refresh.Database/GameDatabaseContext.Assets.cs | 1 + 1 file changed, 1 insertion(+) diff --git a/Refresh.Database/GameDatabaseContext.Assets.cs b/Refresh.Database/GameDatabaseContext.Assets.cs index e6b3ed7d..17611405 100644 --- a/Refresh.Database/GameDatabaseContext.Assets.cs +++ b/Refresh.Database/GameDatabaseContext.Assets.cs @@ -129,6 +129,7 @@ public void SetMainlinePhotoHash(GameAsset asset, string hash) => AssetHash = hash, AssetType = type, Reason = reason, + DisallowedAt = this._time.Now, }; this.DisallowedAssets.Add(disallowed); From b6b437f33d85e39be3b4dcb98dba57840aecf8af Mon Sep 17 00:00:00 2001 From: Toaster2 Date: Sat, 11 Apr 2026 17:55:02 +0200 Subject: [PATCH 09/10] DisallowedAssets DB migration --- ...260411153651_AddAbilityToDisallowAssets.cs | 38 +++++++++++++++++++ .../GameDatabaseContextModelSnapshot.cs | 20 ++++++++++ 2 files changed, 58 insertions(+) create mode 100644 Refresh.Database/Migrations/20260411153651_AddAbilityToDisallowAssets.cs diff --git a/Refresh.Database/Migrations/20260411153651_AddAbilityToDisallowAssets.cs b/Refresh.Database/Migrations/20260411153651_AddAbilityToDisallowAssets.cs new file mode 100644 index 00000000..455c6907 --- /dev/null +++ b/Refresh.Database/Migrations/20260411153651_AddAbilityToDisallowAssets.cs @@ -0,0 +1,38 @@ +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Refresh.Database.Migrations +{ + /// + [DbContext(typeof(GameDatabaseContext))] + [Migration("20260411153651_AddAbilityToDisallowAssets")] + public partial class AddAbilityToDisallowAssets : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "DisallowedAssets", + columns: table => new + { + AssetHash = table.Column(type: "text", nullable: false), + AssetType = table.Column(type: "integer", nullable: false), + Reason = table.Column(type: "text", nullable: false), + DisallowedAt = table.Column(type: "timestamp with time zone", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_DisallowedAssets", x => x.AssetHash); + }); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "DisallowedAssets"); + } + } +} diff --git a/Refresh.Database/Migrations/GameDatabaseContextModelSnapshot.cs b/Refresh.Database/Migrations/GameDatabaseContextModelSnapshot.cs index 2ff6b5f4..0eeb2f28 100644 --- a/Refresh.Database/Migrations/GameDatabaseContextModelSnapshot.cs +++ b/Refresh.Database/Migrations/GameDatabaseContextModelSnapshot.cs @@ -23,6 +23,26 @@ protected override void BuildModel(ModelBuilder modelBuilder) NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + modelBuilder.Entity("DisallowedAsset", b => + { + b.Property("AssetHash") + .HasColumnType("text"); + + b.Property("AssetType") + .HasColumnType("integer"); + + b.Property("DisallowedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Reason") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("AssetHash"); + + b.ToTable("DisallowedAssets"); + }); + modelBuilder.Entity("Refresh.Database.Models.Activity.Event", b => { b.Property("EventId") From 9cd25d99ed4a7bee7c42c78fd863031a2de3e760 Mon Sep 17 00:00:00 2001 From: Toaster2 Date: Sun, 12 Apr 2026 19:22:14 +0200 Subject: [PATCH 10/10] Block disallowed asset uploads --- .../ApiTypes/Errors/ApiModerationError.cs | 12 +++--- .../Endpoints/ResourceApiEndpoints.cs | 8 +++- .../Endpoints/ResourceEndpoints.cs | 6 +++ .../Tests/Assets/AssetDisallowanceTests.cs | 43 +++++++++++++++++++ 4 files changed, 63 insertions(+), 6 deletions(-) diff --git a/Refresh.Interfaces.APIv3/Endpoints/ApiTypes/Errors/ApiModerationError.cs b/Refresh.Interfaces.APIv3/Endpoints/ApiTypes/Errors/ApiModerationError.cs index 9589edf2..465a98d2 100644 --- a/Refresh.Interfaces.APIv3/Endpoints/ApiTypes/Errors/ApiModerationError.cs +++ b/Refresh.Interfaces.APIv3/Endpoints/ApiTypes/Errors/ApiModerationError.cs @@ -2,9 +2,11 @@ namespace Refresh.Interfaces.APIv3.Endpoints.ApiTypes.Errors; public class ApiModerationError : ApiError { - public static readonly ApiModerationError Instance = new(); - - public ApiModerationError() : base("This content was flagged as potentially unsafe, and administrators have been alerted. If you believe this is an error, please contact an administrator.", UnprocessableContent) - { - } + public ApiModerationError(string message) : base(message, UnprocessableContent) {} + + public const string AssetAutoFlaggedErrorWhen = "This content was flagged as potentially unsafe, and administrators have been alerted. If you believe this is an error, please contact an administrator."; + public static readonly ApiModerationError AssetAutoFlaggedError = new(AssetAutoFlaggedErrorWhen); + + public const string AssetDisallowedErrorWhen = "The asset you tried to upload is disallowed."; + public static readonly ApiModerationError AssetDisallowedError = new(AssetDisallowedErrorWhen); } \ No newline at end of file diff --git a/Refresh.Interfaces.APIv3/Endpoints/ResourceApiEndpoints.cs b/Refresh.Interfaces.APIv3/Endpoints/ResourceApiEndpoints.cs index 9ab5fd32..7f5893db 100644 --- a/Refresh.Interfaces.APIv3/Endpoints/ResourceApiEndpoints.cs +++ b/Refresh.Interfaces.APIv3/Endpoints/ResourceApiEndpoints.cs @@ -200,6 +200,12 @@ IntegrationConfig integration return new ApiValidationError($"You have exceeded your filesize quota."); } + if (database.GetDisallowedAssetInfo(hash) != null) + { + context.Logger.LogWarning(BunkumCategory.UserContent, "User {0} has tried to upload a disallowed asset, rejecting.", user); + return ApiModerationError.AssetDisallowedError; + } + GameAsset? gameAsset = importer.ReadAndVerifyAsset(hash, body, TokenPlatform.Website, database); if (gameAsset == null) return ApiValidationError.CannotReadAssetError; @@ -214,7 +220,7 @@ IntegrationConfig integration if (aipi != null && aipi.ScanAndHandleAsset(dataContext, gameAsset)) { - return ApiModerationError.Instance; + return ApiModerationError.AssetAutoFlaggedError; } database.AddAssetToDatabase(gameAsset); diff --git a/Refresh.Interfaces.Game/Endpoints/ResourceEndpoints.cs b/Refresh.Interfaces.Game/Endpoints/ResourceEndpoints.cs index 1f64f050..8b6dc48c 100644 --- a/Refresh.Interfaces.Game/Endpoints/ResourceEndpoints.cs +++ b/Refresh.Interfaces.Game/Endpoints/ResourceEndpoints.cs @@ -61,6 +61,12 @@ public Response UploadAsset(RequestContext context, string hash, string type, by context.Logger.LogWarning(BunkumCategory.UserContent, "{0} is above 2MB ({1} bytes), rejecting.", hash, body.Length); return RequestEntityTooLarge; } + + if (database.GetDisallowedAssetInfo(hash) != null) + { + context.Logger.LogWarning(BunkumCategory.UserContent, "User {0} has tried to upload a disallowed asset, rejecting.", user); + return Unauthorized; + } GameAsset? gameAsset = importer.ReadAndVerifyAsset(hash, body, token.TokenPlatform, database); if (gameAsset == null) diff --git a/RefreshTests.GameServer/Tests/Assets/AssetDisallowanceTests.cs b/RefreshTests.GameServer/Tests/Assets/AssetDisallowanceTests.cs index 867ab98b..c264eb15 100644 --- a/RefreshTests.GameServer/Tests/Assets/AssetDisallowanceTests.cs +++ b/RefreshTests.GameServer/Tests/Assets/AssetDisallowanceTests.cs @@ -1,6 +1,10 @@ +using System.Security.Cryptography; using Refresh.Database.Models.Assets; using Refresh.Database.Models.Authentication; using Refresh.Database.Models.Users; +using Refresh.Interfaces.APIv3.Endpoints.ApiTypes; +using Refresh.Interfaces.APIv3.Endpoints.ApiTypes.Errors; +using Refresh.Interfaces.APIv3.Endpoints.DataTypes.Response.Data; using Refresh.Interfaces.Game.Types.UserData; using RefreshTests.GameServer.Extensions; @@ -115,4 +119,43 @@ public void ShowModeratedReturnsDisallowedAssetHashes() Assert.That(response.Resources.Contains("3"), Is.True); Assert.That(response.Resources.Contains("7"), Is.True); } + + [Test] + public void CannotUploadDisallowedAssetFromGame() + { + using TestContext context = this.GetServer(); + GameUser user = context.CreateUser(); + HttpClient client = context.GetAuthenticatedClient(TokenType.Game, user); + + ReadOnlySpan data = "TEX a"u8; + + string hash = BitConverter.ToString(SHA1.HashData(data)) + .Replace("-", "") + .ToLower(); + + context.Database.DisallowAsset(hash, GameAssetType.Texture, "Weegee"); + + HttpResponseMessage message = client.PostAsync($"/lbp/upload/{hash}", new ByteArrayContent(data.ToArray())).Result; + Assert.That(message.StatusCode, Is.EqualTo(Unauthorized)); + } + + [Test] + public void CannotUploadDisallowedAssetFromApi() + { + using TestContext context = this.GetServer(); + GameUser user = context.CreateUser(); + HttpClient client = context.GetAuthenticatedClient(TokenType.Api, user); + + ReadOnlySpan data = [0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A]; + + string hash = BitConverter.ToString(SHA1.HashData(data)) + .Replace("-", "") + .ToLower(); + + context.Database.DisallowAsset(hash, GameAssetType.Png, "Weegee"); + + ApiResponse? response = client.PostData($"/api/v3/assets/{hash}", new ByteArrayContent(data.ToArray()), false, true); + Assert.That(response?.Error, Is.Not.Null); + Assert.That(response!.Error!.Name, Is.EqualTo(nameof(ApiModerationError))); + } } \ No newline at end of file