Skip to content
47 changes: 47 additions & 0 deletions Refresh.Database/GameDatabaseContext.Assets.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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);

/// <returns>
/// The asset's disallowance info + whether the asset wasn't already disallowed before
/// </returns
// TODO: have the disallowance methods of other similar entities also return the entity itself aswell,
// and make their entities also store more info (reason, timestamp etc.)
public (DisallowedAsset, bool) DisallowAsset(string hash, GameAssetType type, string reason)
{
DisallowedAsset? existing = this.GetDisallowedAssetInfo(hash);
if (existing != null) return (existing, false);

DisallowedAsset disallowed = new()
{
AssetHash = hash,
AssetType = type,
Reason = reason,
DisallowedAt = this._time.Now,
};

this.DisallowedAssets.Add(disallowed);
this.SaveChanges();
return (disallowed, true);
}

public bool ReallowAsset(string hash)
{
DisallowedAsset? existing = this.GetDisallowedAssetInfo(hash);
if (existing == null) return false;

this.DisallowedAssets.Remove(existing);
this.SaveChanges();
return true;
}

public IQueryable<string> FilterOutAllowedAssets(List<string> hashes)
=> this.DisallowedAssets
.Where(d => hashes.Contains(d.AssetHash))
.Select(d => d.AssetHash);

public DatabaseList<DisallowedAsset> GetDisallowedAssets(int skip, int count)
=> new(this.DisallowedAssets, skip, count);

public DatabaseList<DisallowedAsset> GetDisallowedAssetsByType(GameAssetType type, int skip, int count)
=> new(this.DisallowedAssets.Where(d => d.AssetType == type), skip, count);
}
1 change: 1 addition & 0 deletions Refresh.Database/GameDatabaseContext.cs
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,7 @@ public partial class GameDatabaseContext : DbContext, IDatabaseContext
internal DbSet<DisallowedUser> DisallowedUsers { get; set; }
internal DbSet<DisallowedEmailAddress> DisallowedEmailAddresses { get; set; }
internal DbSet<DisallowedEmailDomain> DisallowedEmailDomains { get; set; }
internal DbSet<DisallowedAsset> DisallowedAssets { get; set; }
internal DbSet<RateReviewRelation> RateReviewRelations { get; set; }
internal DbSet<TagLevelRelation> TagLevelRelations { get; set; }
internal DbSet<GamePlaylist> GamePlaylists { get; set; }
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;

#nullable disable

namespace Refresh.Database.Migrations
{
/// <inheritdoc />
[DbContext(typeof(GameDatabaseContext))]
[Migration("20260411153651_AddAbilityToDisallowAssets")]
public partial class AddAbilityToDisallowAssets : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "DisallowedAssets",
columns: table => new
{
AssetHash = table.Column<string>(type: "text", nullable: false),
AssetType = table.Column<int>(type: "integer", nullable: false),
Reason = table.Column<string>(type: "text", nullable: false),
DisallowedAt = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_DisallowedAssets", x => x.AssetHash);
});
}

/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "DisallowedAssets");
}
}
}
20 changes: 20 additions & 0 deletions Refresh.Database/Migrations/GameDatabaseContextModelSnapshot.cs
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,26 @@ protected override void BuildModel(ModelBuilder modelBuilder)

NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);

modelBuilder.Entity("DisallowedAsset", b =>
{
b.Property<string>("AssetHash")
.HasColumnType("text");

b.Property<int>("AssetType")
.HasColumnType("integer");

b.Property<DateTimeOffset>("DisallowedAt")
.HasColumnType("timestamp with time zone");

b.Property<string>("Reason")
.IsRequired()
.HasColumnType("text");

b.HasKey("AssetHash");

b.ToTable("DisallowedAssets");
});

modelBuilder.Entity("Refresh.Database.Models.Activity.Event", b =>
{
b.Property<string>("EventId")
Expand Down
11 changes: 11 additions & 0 deletions Refresh.Database/Models/Assets/DisallowedAsset.cs
Original file line number Diff line number Diff line change
@@ -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; }
}
50 changes: 50 additions & 0 deletions Refresh.GameServer/CommandLineManager.cs
Original file line number Diff line number Diff line change
@@ -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;

Expand Down Expand Up @@ -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; }
Expand Down Expand Up @@ -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))
Expand Down
17 changes: 17 additions & 0 deletions Refresh.GameServer/RefreshGameServer.cs
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@
using Refresh.Interfaces.Workers;
using Refresh.Interfaces.Workers.Repeating;
using Refresh.Workers;
using Refresh.Database.Models.Assets;

namespace Refresh.GameServer;

Expand Down Expand Up @@ -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();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
8 changes: 7 additions & 1 deletion Refresh.Interfaces.APIv3/Endpoints/ResourceApiEndpoints.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -214,7 +220,7 @@ IntegrationConfig integration

if (aipi != null && aipi.ScanAndHandleAsset(dataContext, gameAsset))
{
return ApiModerationError.Instance;
return ApiModerationError.AssetAutoFlaggedError;
}

database.AddAssetToDatabase(gameAsset);
Expand Down
5 changes: 3 additions & 2 deletions Refresh.Interfaces.Game/Endpoints/ModerationEndpoints.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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<string>(),
Resources = dataContext.Database.FilterOutAllowedAssets(body.Resources).ToList(),
};
}

Expand Down
6 changes: 6 additions & 0 deletions Refresh.Interfaces.Game/Endpoints/ResourceEndpoints.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Loading
Loading