SboxSuperuser/Engine/Commands/AdministrationCommands.Content.cs

Registration of the content moderation admin commands (ban, unban, list, search) in a superuser command system. Bans are matched by kind and wildcard pattern, list output is paged, and search returns a size-capped set of catalog items as item1..itemN JSON fields.

using SboxSuperuser.Core.Content;
using SboxSuperuser.Engine.Content;

namespace SboxSuperuser.Engine.Commands;

internal sealed partial class AdministrationCommands
{
	internal const string ContentPermission = "admin.content.manage";
	// The dispatcher caps a result at 16 fields and 8 KB, so a search returns what fits.
	private const int CatalogResults = 16;
	private const int CatalogBytes = 7680;
	private const int MaximumSearchLength = 64;

	private void RegisterContent( HostContentService content, IContentCatalog catalog )
	{
		var kinds = string.Join( ", ", ContentBanList.Kinds );
		Register( "content.ban", $"Ban a map, addon or entity ({kinds}); * matches anything, as in author.*", ContentPermission,
			new[] { Kind(), Pattern(), new CommandArgumentDescriptor( "reason", "Why, shown when it is refused", CommandArgumentKind.String, isOptional: true, isRemainder: true ) },
			(context, arguments) => content.BanAsync( context, arguments.Get<string>( "kind" ), arguments.Get<string>( "pattern" ),
				arguments.Contains( "reason" ) ? arguments.Get<string>( "reason" ) : "" ), mutation: true );
		Register( "content.unban", "Allow a banned map, addon or entity again", ContentPermission, new[] { Kind(), Pattern() },
			(context, arguments) => content.UnbanAsync( context, arguments.Get<string>( "kind" ), arguments.Get<string>( "pattern" ) ), mutation: true );
		Register( "content.list", "List banned maps, addons and entities", ContentPermission, new[] { Page() },
			(_, arguments) => Task.FromResult( Rows( content.Bans.Bans.Select( ban => $"{ban.Kind} {ban.Pattern}{(ban.Reason.Length > 0 ? " — " + ban.Reason : "")}" ),
				PageNumber( arguments ), content.Bans.Bans.Count == 0 ? "Nothing is banned." : "Banned content." ) ) );
		if ( catalog is null ) return;
		Register( "content.search", "Find maps, addons or entities that aren't banned yet, with their icons", ContentPermission,
			new[] { Kind(), new CommandArgumentDescriptor( "query", "Words to search for; empty lists popular or all", CommandArgumentKind.String, isOptional: true,
				isRemainder: true, audit: CommandArgumentAudit.Redacted ) },
			(_, arguments) => SearchContentAsync( content, catalog, arguments.Get<string>( "kind" )?.ToLowerInvariant(),
				arguments.Contains( "query" ) ? arguments.Get<string>( "query" ) : "" ) );
	}

	// Results go back as item1, item2... JSON fields, the shape the menu's catalog lists read; banned items are left out.
	private static async Task<CommandResult> SearchContentAsync( HostContentService content, IContentCatalog catalog, string kind, string query )
	{
		if ( !ContentBanList.Kinds.Contains( kind ) ) return new CommandResult( CommandStatus.InvalidArguments, $"Content is one of {string.Join( ", ", ContentBanList.Kinds )}." );
		if ( query.Length > MaximumSearchLength ) return new CommandResult( CommandStatus.InvalidArguments, $"Searches are at most {MaximumSearchLength} characters." );
		var found = (await catalog.SearchAsync( kind, query, CatalogResults )).Where( item => content.IsAllowed( kind, item.Id ) ).ToArray();
		var fields = new Dictionary<string, string>( StringComparer.Ordinal );
		var size = 0;
		foreach ( var item in found )
		{
			if ( fields.Count == CatalogResults ) break;
			var json = System.Text.Json.JsonSerializer.Serialize( new AdminCatalogItem { Id = item.Id, Title = Printable( item.Title ), Icon = item.Icon } );
			if ( json.Length > 1024 || size + json.Length > CatalogBytes ) continue;
			size += json.Length;
			fields[ "item" + (fields.Count + 1) ] = json;
		}
		return new CommandResult( CommandStatus.Success, fields.Count == 0 ? "Nothing found." : $"{fields.Count} found.", fields );
	}

	private static string Printable( string text ) => new( (text ?? "").Where( character => !char.IsControl( character ) ).ToArray() );

	private static CommandArgumentDescriptor Kind() => new( "kind", "map, addon or entity", CommandArgumentKind.String, audit: CommandArgumentAudit.Redacted );

	private static CommandArgumentDescriptor Pattern() => new( "pattern", "Map or package ident, prefab or model path, or component type; * matches anything",
		CommandArgumentKind.String );
}