diff --git a/Refresh.Database/GameDatabaseContext.Assets.cs b/Refresh.Database/GameDatabaseContext.Assets.cs
index 61b8e5f3..17611405 100644
--- a/Refresh.Database/GameDatabaseContext.Assets.cs
+++ b/Refresh.Database/GameDatabaseContext.Assets.cs
@@ -110,4 +110,51 @@ public void SetMainlinePhotoHash(GameAsset asset, string hash) =>
{
asset.AsMainlinePhotoHash = hash;
});
+
+ public DisallowedAsset? GetDisallowedAssetInfo(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);
+
+ 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/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/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")
diff --git a/Refresh.Database/Models/Assets/DisallowedAsset.cs b/Refresh.Database/Models/Assets/DisallowedAsset.cs
new file mode 100644
index 00000000..8a0d7ee9
--- /dev/null
+++ b/Refresh.Database/Models/Assets/DisallowedAsset.cs
@@ -0,0 +1,11 @@
+using Refresh.Database.Models.Assets;
+
+public partial class DisallowedAsset
+{
+ [Key] public string AssetHash { get; set; } = null!;
+
+ public GameAssetType AssetType { get; set; }
+
+ public string Reason { get; set; } = "";
+ public DateTimeOffset DisallowedAt { get; set; }
+}
\ No newline at end of file
diff --git a/Refresh.GameServer/CommandLineManager.cs b/Refresh.GameServer/CommandLineManager.cs
index bff16e94..ed21a839 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, 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 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.")]
+ 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();
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/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(),
};
}
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
new file mode 100644
index 00000000..c264eb15
--- /dev/null
+++ b/RefreshTests.GameServer/Tests/Assets/AssetDisallowanceTests.cs
@@ -0,0 +1,161 @@
+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;
+
+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);
+ }
+
+ [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