Skip to content

Commit dbba2fb

Browse files
authored
Implement basics of asset blocking (#1069)
Adds the ability to disallow assets by hash via CLI. While this PR doesn't actually implement any enforcement for this (aside from a proper `/showModerated` implementation), it does prepare for future PRs to do so. Doesn't really close #472 yet, because other kinds of enforcements (like icon resetting, preventing uploads of blocked assets, preventing new entities from referencing blocked assets etc.), aswell as a way to add/remove/view blocked assets over API are missing.
2 parents 22dd35f + 0a44c96 commit dbba2fb

File tree

12 files changed

+368
-8
lines changed

12 files changed

+368
-8
lines changed

Refresh.Database/GameDatabaseContext.Assets.cs

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -110,4 +110,51 @@ public void SetMainlinePhotoHash(GameAsset asset, string hash) =>
110110
{
111111
asset.AsMainlinePhotoHash = hash;
112112
});
113+
114+
public DisallowedAsset? GetDisallowedAssetInfo(string hash)
115+
=> this.DisallowedAssets.FirstOrDefault(d => d.AssetHash == hash);
116+
117+
/// <returns>
118+
/// The asset's disallowance info + whether the asset wasn't already disallowed before
119+
/// </returns
120+
// TODO: have the disallowance methods of other similar entities also return the entity itself aswell,
121+
// and make their entities also store more info (reason, timestamp etc.)
122+
public (DisallowedAsset, bool) DisallowAsset(string hash, GameAssetType type, string reason)
123+
{
124+
DisallowedAsset? existing = this.GetDisallowedAssetInfo(hash);
125+
if (existing != null) return (existing, false);
126+
127+
DisallowedAsset disallowed = new()
128+
{
129+
AssetHash = hash,
130+
AssetType = type,
131+
Reason = reason,
132+
DisallowedAt = this._time.Now,
133+
};
134+
135+
this.DisallowedAssets.Add(disallowed);
136+
this.SaveChanges();
137+
return (disallowed, true);
138+
}
139+
140+
public bool ReallowAsset(string hash)
141+
{
142+
DisallowedAsset? existing = this.GetDisallowedAssetInfo(hash);
143+
if (existing == null) return false;
144+
145+
this.DisallowedAssets.Remove(existing);
146+
this.SaveChanges();
147+
return true;
148+
}
149+
150+
public IQueryable<string> FilterOutAllowedAssets(List<string> hashes)
151+
=> this.DisallowedAssets
152+
.Where(d => hashes.Contains(d.AssetHash))
153+
.Select(d => d.AssetHash);
154+
155+
public DatabaseList<DisallowedAsset> GetDisallowedAssets(int skip, int count)
156+
=> new(this.DisallowedAssets, skip, count);
157+
158+
public DatabaseList<DisallowedAsset> GetDisallowedAssetsByType(GameAssetType type, int skip, int count)
159+
=> new(this.DisallowedAssets.Where(d => d.AssetType == type), skip, count);
113160
}

Refresh.Database/GameDatabaseContext.cs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,7 @@ public partial class GameDatabaseContext : DbContext, IDatabaseContext
6666
internal DbSet<DisallowedUser> DisallowedUsers { get; set; }
6767
internal DbSet<DisallowedEmailAddress> DisallowedEmailAddresses { get; set; }
6868
internal DbSet<DisallowedEmailDomain> DisallowedEmailDomains { get; set; }
69+
internal DbSet<DisallowedAsset> DisallowedAssets { get; set; }
6970
internal DbSet<RateReviewRelation> RateReviewRelations { get; set; }
7071
internal DbSet<TagLevelRelation> TagLevelRelations { get; set; }
7172
internal DbSet<GamePlaylist> GamePlaylists { get; set; }
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
using Microsoft.EntityFrameworkCore.Infrastructure;
2+
using Microsoft.EntityFrameworkCore.Migrations;
3+
4+
#nullable disable
5+
6+
namespace Refresh.Database.Migrations
7+
{
8+
/// <inheritdoc />
9+
[DbContext(typeof(GameDatabaseContext))]
10+
[Migration("20260411153651_AddAbilityToDisallowAssets")]
11+
public partial class AddAbilityToDisallowAssets : Migration
12+
{
13+
/// <inheritdoc />
14+
protected override void Up(MigrationBuilder migrationBuilder)
15+
{
16+
migrationBuilder.CreateTable(
17+
name: "DisallowedAssets",
18+
columns: table => new
19+
{
20+
AssetHash = table.Column<string>(type: "text", nullable: false),
21+
AssetType = table.Column<int>(type: "integer", nullable: false),
22+
Reason = table.Column<string>(type: "text", nullable: false),
23+
DisallowedAt = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false)
24+
},
25+
constraints: table =>
26+
{
27+
table.PrimaryKey("PK_DisallowedAssets", x => x.AssetHash);
28+
});
29+
}
30+
31+
/// <inheritdoc />
32+
protected override void Down(MigrationBuilder migrationBuilder)
33+
{
34+
migrationBuilder.DropTable(
35+
name: "DisallowedAssets");
36+
}
37+
}
38+
}

Refresh.Database/Migrations/GameDatabaseContextModelSnapshot.cs

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,26 @@ protected override void BuildModel(ModelBuilder modelBuilder)
2323

2424
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
2525

26+
modelBuilder.Entity("DisallowedAsset", b =>
27+
{
28+
b.Property<string>("AssetHash")
29+
.HasColumnType("text");
30+
31+
b.Property<int>("AssetType")
32+
.HasColumnType("integer");
33+
34+
b.Property<DateTimeOffset>("DisallowedAt")
35+
.HasColumnType("timestamp with time zone");
36+
37+
b.Property<string>("Reason")
38+
.IsRequired()
39+
.HasColumnType("text");
40+
41+
b.HasKey("AssetHash");
42+
43+
b.ToTable("DisallowedAssets");
44+
});
45+
2646
modelBuilder.Entity("Refresh.Database.Models.Activity.Event", b =>
2747
{
2848
b.Property<string>("EventId")
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
using Refresh.Database.Models.Assets;
2+
3+
public partial class DisallowedAsset
4+
{
5+
[Key] public string AssetHash { get; set; } = null!;
6+
7+
public GameAssetType AssetType { get; set; }
8+
9+
public string Reason { get; set; } = "";
10+
public DateTimeOffset DisallowedAt { get; set; }
11+
}

Refresh.GameServer/CommandLineManager.cs

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
using System.Diagnostics.CodeAnalysis;
22
using CommandLine;
33
using Refresh.Database;
4+
using Refresh.Database.Models.Assets;
45
using Refresh.Database.Models.Users;
56
using Refresh.Interfaces.APIv3.Documentation;
67

@@ -71,6 +72,22 @@ private class Options
7172

7273
[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.")]
7374
public bool ReallowEmailDomain { get; set; }
75+
76+
[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. "
77+
+ "Asset option is required if this is set, and both the Type and Reason options are optional.")]
78+
public bool DisallowAsset { get; set; }
79+
80+
[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.")]
81+
public bool ReallowAsset { get; set; }
82+
83+
[Option("asset", HelpText = "The hash of the asset to operate on.")]
84+
public string? AssetHash { get; set; }
85+
86+
[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.")]
87+
public string? AssetType { get; set; }
88+
89+
[Option("reason", HelpText = "The (usually optional) reason for a moderation action, such as asset disallowance.")]
90+
public string? Reason { get; set; }
7491

7592
[Option("rename-user", HelpText = "Changes a user's username. (old) username or Email option is required if this is set.")]
7693
public string? RenameUser { get; set; }
@@ -236,6 +253,39 @@ private void StartWithOptions(Options options)
236253
}
237254
else Fail("No email domain was provided");
238255
}
256+
else if (options.DisallowAsset)
257+
{
258+
if (options.AssetHash != null)
259+
{
260+
GameAssetType? type = null;
261+
if (options.AssetType != null)
262+
{
263+
bool parsed = Enum.TryParse(options.AssetType, true, out GameAssetType assetType);
264+
if (!parsed)
265+
{
266+
Fail($"The asset type '{options.AssetType}' couldn't be parsed. Possible values: "
267+
+ string.Join(", ", Enum.GetNames(typeof(GameAssetType))));
268+
269+
return;
270+
}
271+
272+
type = assetType;
273+
}
274+
275+
if (!this._server.DisallowAsset(options.AssetHash, type, options.Reason))
276+
Fail("Asset is already disallowed");
277+
}
278+
else Fail("No asset hash was provided");
279+
}
280+
else if (options.ReallowAsset)
281+
{
282+
if (options.AssetHash != null)
283+
{
284+
if (!this._server.ReallowAsset(options.AssetHash))
285+
Fail("Asset is already allowed");
286+
}
287+
else Fail("No asset hash was provided");
288+
}
239289
else if (options.RenameUser != null)
240290
{
241291
if(string.IsNullOrWhiteSpace(options.RenameUser))

Refresh.GameServer/RefreshGameServer.cs

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@
3232
using Refresh.Interfaces.Workers;
3333
using Refresh.Interfaces.Workers.Repeating;
3434
using Refresh.Workers;
35+
using Refresh.Database.Models.Assets;
3536

3637
namespace Refresh.GameServer;
3738

@@ -313,6 +314,22 @@ public bool ReallowEmailDomain(string domain)
313314
return context.ReallowEmailDomain(domain);
314315
}
315316

317+
public bool DisallowAsset(string hash, GameAssetType? type, string? reason)
318+
{
319+
using GameDatabaseContext context = this.GetContext();
320+
type ??= context.GetAssetFromHash(hash)?.AssetType;
321+
322+
(DisallowedAsset disallowed, bool success) = context.DisallowAsset(hash, type ?? GameAssetType.Unknown, reason ?? "");
323+
return success;
324+
}
325+
326+
public bool ReallowAsset(string hash)
327+
{
328+
using GameDatabaseContext context = this.GetContext();
329+
330+
return context.ReallowAsset(hash);
331+
}
332+
316333
public void RenameUser(GameUser user, string newUsername, bool force = false)
317334
{
318335
using GameDatabaseContext context = this.GetContext();

Refresh.Interfaces.APIv3/Endpoints/ApiTypes/Errors/ApiModerationError.cs

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,11 @@ namespace Refresh.Interfaces.APIv3.Endpoints.ApiTypes.Errors;
22

33
public class ApiModerationError : ApiError
44
{
5-
public static readonly ApiModerationError Instance = new();
6-
7-
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)
8-
{
9-
}
5+
public ApiModerationError(string message) : base(message, UnprocessableContent) {}
6+
7+
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.";
8+
public static readonly ApiModerationError AssetAutoFlaggedError = new(AssetAutoFlaggedErrorWhen);
9+
10+
public const string AssetDisallowedErrorWhen = "The asset you tried to upload is disallowed.";
11+
public static readonly ApiModerationError AssetDisallowedError = new(AssetDisallowedErrorWhen);
1012
}

Refresh.Interfaces.APIv3/Endpoints/ResourceApiEndpoints.cs

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -200,6 +200,12 @@ IntegrationConfig integration
200200
return new ApiValidationError($"You have exceeded your filesize quota.");
201201
}
202202

203+
if (database.GetDisallowedAssetInfo(hash) != null)
204+
{
205+
context.Logger.LogWarning(BunkumCategory.UserContent, "User {0} has tried to upload a disallowed asset, rejecting.", user);
206+
return ApiModerationError.AssetDisallowedError;
207+
}
208+
203209
GameAsset? gameAsset = importer.ReadAndVerifyAsset(hash, body, TokenPlatform.Website, database);
204210
if (gameAsset == null)
205211
return ApiValidationError.CannotReadAssetError;
@@ -214,7 +220,7 @@ IntegrationConfig integration
214220

215221
if (aipi != null && aipi.ScanAndHandleAsset(dataContext, gameAsset))
216222
{
217-
return ApiModerationError.Instance;
223+
return ApiModerationError.AssetAutoFlaggedError;
218224
}
219225

220226
database.AddAssetToDatabase(gameAsset);

Refresh.Interfaces.Game/Endpoints/ModerationEndpoints.cs

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
using Refresh.Core.Authentication.Permission;
77
using Refresh.Core.Services;
88
using Refresh.Core.Types.Commands;
9+
using Refresh.Core.Types.Data;
910
using Refresh.Database;
1011
using Refresh.Database.Models.Authentication;
1112
using Refresh.Database.Models.Users;
@@ -26,11 +27,11 @@ public SerializedModeratedSlotList ModerateSlots(RequestContext context, Seriali
2627
}
2728

2829
[GameEndpoint("showModerated", HttpMethods.Post, ContentType.Xml)]
29-
public SerializedModeratedResourceList ModerateResources(RequestContext context, SerializedModeratedResourceList body)
30+
public SerializedModeratedResourceList ModerateResources(RequestContext context, SerializedModeratedResourceList body, DataContext dataContext)
3031
{
3132
return new SerializedModeratedResourceList
3233
{
33-
Resources = new List<string>(),
34+
Resources = dataContext.Database.FilterOutAllowedAssets(body.Resources).ToList(),
3435
};
3536
}
3637

0 commit comments

Comments
 (0)