Add check for which commands are allowed to be run, config options for execute command

This commit is contained in:
Vankka 2023-07-09 01:11:41 +03:00
parent 417289cccb
commit 1207990461
No known key found for this signature in database
GPG Key ID: 6E50CB7A29B96AD0
10 changed files with 357 additions and 37 deletions

View File

@ -19,7 +19,7 @@
package com.discordsrv.bukkit;
import com.discordsrv.api.DiscordSRVApi;
import com.discordsrv.bukkit.command.game.BukkitAutoCompleteHelper;
import com.discordsrv.bukkit.command.game.BukkitGameCommandExecutionHelper;
import com.discordsrv.bukkit.command.game.handler.AbstractBukkitCommandHandler;
import com.discordsrv.bukkit.component.translation.BukkitTranslationLoader;
import com.discordsrv.bukkit.config.connection.BukkitConnectionConfig;
@ -40,7 +40,6 @@ import com.discordsrv.bukkit.scheduler.BukkitScheduler;
import com.discordsrv.bukkit.scheduler.FoliaScheduler;
import com.discordsrv.bukkit.scheduler.IBukkitScheduler;
import com.discordsrv.common.ServerDiscordSRV;
import com.discordsrv.common.command.discord.commands.subcommand.ExecuteCommand;
import com.discordsrv.common.command.game.handler.ICommandHandler;
import com.discordsrv.common.config.manager.ConnectionConfigManager;
import com.discordsrv.common.config.manager.MainConfigManager;
@ -68,7 +67,7 @@ public class BukkitDiscordSRV extends ServerDiscordSRV<DiscordSRVBukkitBootstrap
private final BukkitPluginManager pluginManager;
private AbstractBukkitCommandHandler commandHandler;
private final BukkitRequiredLinkingListener requiredLinkingListener;
private final BukkitAutoCompleteHelper autoCompleteHelper;
private final BukkitGameCommandExecutionHelper autoCompleteHelper;
private final BukkitConnectionConfigManager connectionConfigManager;
private final BukkitConfigManager configManager;
@ -97,7 +96,7 @@ public class BukkitDiscordSRV extends ServerDiscordSRV<DiscordSRVBukkitBootstrap
load();
this.requiredLinkingListener = new BukkitRequiredLinkingListener(this);
this.autoCompleteHelper = new BukkitAutoCompleteHelper(this);
this.autoCompleteHelper = new BukkitGameCommandExecutionHelper(this);
}
public JavaPlugin plugin() {
@ -230,7 +229,8 @@ public class BukkitDiscordSRV extends ServerDiscordSRV<DiscordSRVBukkitBootstrap
audiences.close();
}
public ExecuteCommand.AutoCompleteHelper autoCompleteHelper() {
@Override
public BukkitGameCommandExecutionHelper executeHelper() {
return autoCompleteHelper;
}
}

View File

@ -2,20 +2,22 @@ package com.discordsrv.bukkit.command.game;
import com.discordsrv.bukkit.BukkitDiscordSRV;
import com.discordsrv.bukkit.PaperCommandMap;
import com.discordsrv.common.command.discord.commands.subcommand.ExecuteCommand;
import com.discordsrv.common.command.game.GameCommandExecutionHelper;
import org.bukkit.command.Command;
import org.bukkit.command.CommandSender;
import org.bukkit.command.PluginCommand;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import java.util.Locale;
import java.util.concurrent.CompletableFuture;
public class BukkitAutoCompleteHelper implements ExecuteCommand.AutoCompleteHelper {
public class BukkitGameCommandExecutionHelper implements GameCommandExecutionHelper {
private final BukkitDiscordSRV discordSRV;
public BukkitAutoCompleteHelper(BukkitDiscordSRV discordSRV) {
public BukkitGameCommandExecutionHelper(BukkitDiscordSRV discordSRV) {
this.discordSRV = discordSRV;
}
@ -78,4 +80,31 @@ public class BukkitAutoCompleteHelper implements ExecuteCommand.AutoCompleteHelp
return future;
}
@Override
public List<String> getAliases(String command) {
PluginCommand pluginCommand = discordSRV.server().getPluginCommand(command);
if (pluginCommand == null) {
return Collections.emptyList();
}
List<String> aliases = new ArrayList<>(pluginCommand.getAliases());
aliases.add(pluginCommand.getName());
String pluginName = pluginCommand.getName().toLowerCase(Locale.ROOT);
int originalMax = aliases.size();
for (int i = 0; i < originalMax; i++) {
// plugin:command
aliases.add(pluginName + ":" + aliases.get(i));
}
return aliases;
}
@Override
public boolean isSameCommand(String command1, String command2) {
PluginCommand pluginCommand1 = discordSRV.server().getPluginCommand(command1);
PluginCommand pluginCommand2 = discordSRV.server().getPluginCommand(command2);
return pluginCommand1 == pluginCommand2;
}
}

View File

@ -23,7 +23,7 @@ import com.discordsrv.api.module.type.Module;
import com.discordsrv.api.placeholder.DiscordPlaceholders;
import com.discordsrv.common.bootstrap.IBootstrap;
import com.discordsrv.common.channel.ChannelConfigHelper;
import com.discordsrv.common.command.discord.commands.subcommand.ExecuteCommand;
import com.discordsrv.common.command.game.GameCommandExecutionHelper;
import com.discordsrv.common.command.game.handler.ICommandHandler;
import com.discordsrv.common.component.ComponentFactory;
import com.discordsrv.common.config.connection.ConnectionConfig;
@ -154,7 +154,7 @@ public interface DiscordSRV extends DiscordSRVApi {
CompletableFuture<Void> invokeDisable();
@Nullable
default ExecuteCommand.AutoCompleteHelper autoCompleteHelper() {
default GameCommandExecutionHelper executeHelper() {
return null;
}

View File

@ -7,6 +7,7 @@ import com.discordsrv.common.command.combined.commands.DebugCommand;
import com.discordsrv.common.command.combined.commands.ResyncCommand;
import com.discordsrv.common.command.combined.commands.VersionCommand;
import com.discordsrv.common.command.discord.commands.subcommand.ExecuteCommand;
import com.discordsrv.common.config.main.DiscordCommandConfig;
public class DiscordSRVDiscordCommand {
@ -16,11 +17,18 @@ public class DiscordSRVDiscordCommand {
public static DiscordCommand get(DiscordSRV discordSRV) {
if (INSTANCE == null) {
INSTANCE = DiscordCommand.chatInput(IDENTIFIER, "discordsrv", "DiscordSRV related commands")
DiscordCommandConfig config = discordSRV.config().discordCommand;
DiscordCommand.ChatInputBuilder builder = DiscordCommand.chatInput(IDENTIFIER, "discordsrv", "DiscordSRV related commands")
.addSubCommand(DebugCommand.getDiscord(discordSRV))
.addSubCommand(VersionCommand.getDiscord(discordSRV))
.addSubCommand(ResyncCommand.getDiscord(discordSRV))
.addSubCommand(ExecuteCommand.get(discordSRV))
.addSubCommand(ResyncCommand.getDiscord(discordSRV));
if (config.execute.enabled) {
builder = builder.addSubCommand(ExecuteCommand.get(discordSRV));
}
INSTANCE = builder
.setGuildOnly(false)
.setDefaultPermission(DiscordCommand.DefaultPermission.ADMINISTRATOR)
.build();

View File

@ -1,11 +1,16 @@
package com.discordsrv.common.command.discord.commands.subcommand;
import com.discordsrv.api.discord.entity.DiscordUser;
import com.discordsrv.api.discord.entity.guild.DiscordGuildMember;
import com.discordsrv.api.discord.entity.interaction.command.CommandOption;
import com.discordsrv.api.discord.entity.interaction.command.DiscordCommand;
import com.discordsrv.api.discord.entity.interaction.component.ComponentIdentifier;
import com.discordsrv.api.discord.events.interaction.command.DiscordChatInputInteractionEvent;
import com.discordsrv.api.discord.events.interaction.command.DiscordCommandAutoCompleteInteractionEvent;
import com.discordsrv.common.DiscordSRV;
import com.discordsrv.common.command.game.GameCommandExecutionHelper;
import com.discordsrv.common.config.main.DiscordCommandConfig;
import com.discordsrv.common.config.main.generic.GameCommandFilterConfig;
import com.discordsrv.common.logging.Logger;
import com.discordsrv.common.logging.NamedLogger;
import net.dv8tion.jda.api.interactions.commands.OptionMapping;
@ -14,7 +19,6 @@ import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
import java.util.Locale;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.TimeoutException;
@ -26,11 +30,13 @@ public class ExecuteCommand implements Consumer<DiscordChatInputInteractionEvent
public static DiscordCommand get(DiscordSRV discordSRV) {
if (INSTANCE == null) {
DiscordCommandConfig.ExecuteConfig config = discordSRV.config().discordCommand.execute;
ExecuteCommand command = new ExecuteCommand(discordSRV);
INSTANCE = DiscordCommand.chatInput(ComponentIdentifier.of("DiscordSRV", "execute"), "execute", "Run a Minecraft console command")
.addOption(
CommandOption.builder(CommandOption.Type.STRING, "command", "The command to execute")
.setAutoComplete(true)
.setAutoComplete(config.suggest)
.setRequired(true)
.build()
)
@ -43,26 +49,60 @@ public class ExecuteCommand implements Consumer<DiscordChatInputInteractionEvent
}
private final DiscordSRV discordSRV;
private final GameCommandExecutionHelper helper;
private final Logger logger;
public ExecuteCommand(DiscordSRV discordSRV) {
this.discordSRV = discordSRV;
this.helper = discordSRV.executeHelper();
this.logger = new NamedLogger(discordSRV, "EXECUTE_COMMAND");
}
public boolean isNotAcceptableCommand(DiscordGuildMember member, DiscordUser user, String command, boolean suggestions) {
DiscordCommandConfig.ExecuteConfig config = discordSRV.config().discordCommand.execute;
for (GameCommandFilterConfig filter : config.filters) {
if (!filter.isAcceptableCommand(member, user, command, suggestions, helper)) {
return true;
}
}
return false;
}
@Override
public void accept(DiscordChatInputInteractionEvent event) {
DiscordCommandConfig.ExecuteConfig config = discordSRV.config().discordCommand.execute;
if (!config.enabled) {
event.asJDA().reply("The execute command is disabled").setEphemeral(true).queue();
return;
}
OptionMapping mapping = event.asJDA().getOption("command");
if (mapping == null) {
return;
}
String command = mapping.getAsString();
if (isNotAcceptableCommand(event.getMember(), event.getUser(), command, false)) {
event.asJDA().reply("You do not have permission to run that command").setEphemeral(true).queue();
return;
}
discordSRV.logger().error("> " + command);
}
@Override
public void autoComplete(DiscordCommandAutoCompleteInteractionEvent event) {
if (helper == null) {
// No suggestions available.
return;
}
DiscordCommandConfig.ExecuteConfig config = discordSRV.config().discordCommand.execute;
if (!config.suggest) {
return;
}
OptionMapping mapping = event.asJDA().getOption("command");
if (mapping == null) {
return;
@ -71,13 +111,7 @@ public class ExecuteCommand implements Consumer<DiscordChatInputInteractionEvent
String command = mapping.getAsString();
List<String> parts = new ArrayList<>(Arrays.asList(command.split(" ")));
AutoCompleteHelper helper = discordSRV.autoCompleteHelper();
if (helper == null) {
// No suggestions available.
return;
}
List<String> suggestions = getSuggestions(helper, parts);
List<String> suggestions = getSuggestions(parts);
if (suggestions == null) {
return;
}
@ -85,7 +119,7 @@ public class ExecuteCommand implements Consumer<DiscordChatInputInteractionEvent
if (suggestions.isEmpty() || suggestions.contains(command)) {
parts.add("");
List<String> newSuggestions = getSuggestions(helper, parts);
List<String> newSuggestions = getSuggestions(parts);
if (newSuggestions == null) {
return;
}
@ -110,13 +144,18 @@ public class ExecuteCommand implements Consumer<DiscordChatInputInteractionEvent
return s1.toLowerCase(Locale.ROOT).compareTo(s2.toLowerCase(Locale.ROOT));
});
for (String suggestion : suggestions) {
if (event.getChoices().size() >= 25) break;
if (event.getChoices().size() >= 25) {
break;
}
if (config.filterSuggestions && isNotAcceptableCommand(event.getMember(), event.getUser(), suggestion, true)) {
continue;
}
event.addChoice(suggestion, suggestion);
}
}
private List<String> getSuggestions(AutoCompleteHelper helper, List<String> parts) {
private List<String> getSuggestions(List<String> parts) {
try {
return helper.suggestCommands(new ArrayList<>(parts)).get(2, TimeUnit.SECONDS);
} catch (InterruptedException e) {
@ -132,9 +171,4 @@ public class ExecuteCommand implements Consumer<DiscordChatInputInteractionEvent
return null;
}
}
public interface AutoCompleteHelper {
CompletableFuture<List<String>> suggestCommands(List<String> parts);
}
}

View File

@ -0,0 +1,12 @@
package com.discordsrv.common.command.game;
import java.util.List;
import java.util.concurrent.CompletableFuture;
public interface GameCommandExecutionHelper {
CompletableFuture<List<String>> suggestCommands(List<String> parts);
List<String> getAliases(String command);
boolean isSameCommand(String command1, String command2);
}

View File

@ -1,6 +1,12 @@
package com.discordsrv.common.config.main;
import com.discordsrv.common.config.main.generic.GameCommandFilterConfig;
import org.spongepowered.configurate.objectmapping.ConfigSerializable;
import org.spongepowered.configurate.objectmapping.meta.Comment;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
@ConfigSerializable
public class DiscordCommandConfig {
@ -10,6 +16,26 @@ public class DiscordCommandConfig {
@ConfigSerializable
public static class ExecuteConfig {
public ExecuteConfig() {
filters.add(
new GameCommandFilterConfig(
new ArrayList<>(),
false,
new ArrayList<>(Arrays.asList("say", "/gamemode(?: (?:survival|spectator)(?: .+)?)?/"))
)
);
}
public boolean enabled = true;
@Comment("At least one condition has to match to allow execution")
public List<GameCommandFilterConfig> filters = new ArrayList<>();
@Comment("If commands should be suggested while typing\n" +
"Suggestions go through the server's main thread (on servers with a main thread) to ensure compatability.")
public boolean suggest = true;
@Comment("If suggestions should be filtered based on the \"filters\" option")
public boolean filterSuggestions = true;
}
}

View File

@ -29,7 +29,9 @@ import com.discordsrv.common.config.main.linking.LinkedAccountConfig;
import org.spongepowered.configurate.objectmapping.ConfigSerializable;
import org.spongepowered.configurate.objectmapping.meta.Comment;
import java.util.*;
import java.util.Arrays;
import java.util.LinkedHashMap;
import java.util.Map;
@ConfigSerializable
public abstract class MainConfig implements Config {
@ -79,6 +81,9 @@ public abstract class MainConfig implements Config {
@Comment("In-game command configuration")
public GameCommandConfig gameCommand = new GameCommandConfig();
@Comment("Discord command configuration")
public DiscordCommandConfig discordCommand = new DiscordCommandConfig();
@Comment("Configuration for the %discord_invite% placeholder. The below options will be attempted in the order they are in")
public DiscordInviteConfig invite = new DiscordInviteConfig();

View File

@ -1,20 +1,116 @@
package com.discordsrv.common.config.main.generic;
import com.discordsrv.api.discord.entity.DiscordUser;
import com.discordsrv.api.discord.entity.guild.DiscordGuildMember;
import com.discordsrv.api.discord.entity.guild.DiscordRole;
import com.discordsrv.common.command.game.GameCommandExecutionHelper;
import org.spongepowered.configurate.objectmapping.ConfigSerializable;
import org.spongepowered.configurate.objectmapping.meta.Comment;
import java.util.ArrayList;
import java.util.List;
import java.util.*;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
@ConfigSerializable
public class GameCommandFilterConfig {
public GameCommandFilterConfig() {}
public GameCommandFilterConfig(List<Long> roleAndUserIds, boolean blacklist, List<String> commands) {
this.roleAndUserIds = roleAndUserIds;
this.blacklist = blacklist;
this.commands = commands;
}
@Comment("The role and user ids which this filter applies to")
public List<Long> roleAndUserIds = new ArrayList<>();
@Comment("true for blacklist (blocking commands), false for whitelist (allowing commands)")
public boolean blacklist = true;
@Comment("The role and user ids which this set of allowed/blocked commands is for")
public List<Long> roleAndUserIds = new ArrayList<>();
@Comment("The commands that are allowed/blocked. Use / at the beginning and end of a value for a regular expression (regex)")
@Comment("The commands and/or patterns that are allowed/blocked.\n" +
"The command needs to start with input, this will attempt to normalize command aliases where possible (for the main command)\n" +
"If the command start and ends with /, the input will be treated as a regular expression (regex) and it will pass if it matches the entire command")
public List<String> commands = new ArrayList<>();
public static boolean isCommandMatch(String configCommand, String command, boolean suggestions, GameCommandExecutionHelper helper) {
if (configCommand.startsWith("/") && configCommand.endsWith("/")) {
// Regex handling
Pattern pattern = Pattern.compile(configCommand.substring(1, configCommand.length() - 1));
Matcher matcher = pattern.matcher(command);
return matcher.matches() && matcher.start() == 0 && matcher.end() == command.length();
}
// Normal handling
configCommand = configCommand.toLowerCase(Locale.ROOT);
command = command.toLowerCase(Locale.ROOT);
List<String> parts = new ArrayList<>(Arrays.asList(configCommand.split(" ")));
String rootCommand = parts.remove(0);
Set<String> rootCommands = new LinkedHashSet<>();
rootCommands.add(rootCommand);
if (helper != null) {
rootCommands.addAll(helper.getAliases(rootCommand));
}
if (suggestions) {
// Allow suggesting the commands up to the allowed command
for (String rootCmd : rootCommands) {
if (command.matches("^" + Pattern.quote(rootCmd) + " ?$")) {
return true;
}
StringBuilder built = new StringBuilder(rootCmd);
for (String part : parts) {
built.append(" ").append(part);
if (command.matches("^" + Pattern.quote(built.toString()) + " ?$")) {
return true;
}
}
}
}
String arguments = String.join(" ", parts);
for (String rootCmd : rootCommands) {
String joined = rootCmd + (arguments.isEmpty() ? "" : " " + arguments);
// This part at the end prevents "command list" matching "command listsecrets"
if (command.matches("^" + Pattern.quote(joined) + "(?:$| .+)")) {
// Make sure it's the same command, the alias may be used by another command
return helper == null || helper.isSameCommand(rootCommand, rootCmd);
}
}
return false;
}
public boolean isAcceptableCommand(DiscordGuildMember member, DiscordUser user, String command, boolean suggestions, GameCommandExecutionHelper helper) {
long userId = user.getId();
List<Long> roleIds = new ArrayList<>();
if (member != null) {
for (DiscordRole role : member.getRoles()) {
roleIds.add(role.getId());
}
}
boolean match = false;
for (Long id : roleAndUserIds) {
if (id == userId || roleIds.contains(id)) {
match = true;
break;
}
}
if (!match) {
return true;
}
for (String configCommand : commands) {
if (isCommandMatch(configCommand, command, suggestions, helper) != blacklist) {
return true;
}
}
return false;
}
}

View File

@ -0,0 +1,110 @@
package com.discordsrv.common.command.game;
import com.discordsrv.common.config.main.generic.GameCommandFilterConfig;
import org.junit.jupiter.api.Assertions;
import org.junit.jupiter.api.Test;
import java.util.Arrays;
import java.util.Collections;
import java.util.List;
import java.util.concurrent.CompletableFuture;
public class GameCommandFilterTest {
private final ExecutionHelper helper = new ExecutionHelper();
@Test
public void test1() {
Assertions.assertTrue(GameCommandFilterConfig.isCommandMatch("test", "test", false, helper));
}
@Test
public void test2() {
Assertions.assertFalse(GameCommandFilterConfig.isCommandMatch("test", "tester", false, helper));
}
@Test
public void argumentTest() {
Assertions.assertTrue(GameCommandFilterConfig.isCommandMatch("test arg", "test arg", false, helper));
}
@Test
public void suggestTest() {
Assertions.assertTrue(GameCommandFilterConfig.isCommandMatch("test arg", "test", true, helper));
}
@Test
public void extraTest() {
Assertions.assertTrue(GameCommandFilterConfig.isCommandMatch("test arg", "test arg extra arguments after that", false, helper));
}
@Test
public void argumentOverflowTest1() {
Assertions.assertFalse(GameCommandFilterConfig.isCommandMatch("test arg", "test argument", false, helper));
}
@Test
public void sameCommandTest1() {
Assertions.assertFalse(GameCommandFilterConfig.isCommandMatch("plugin1:test", "test", false, helper));
}
@Test
public void sameCommandTest2() {
Assertions.assertTrue(GameCommandFilterConfig.isCommandMatch("plugin2:test", "test", false, helper));
}
@Test
public void regexTest1() {
Assertions.assertTrue(GameCommandFilterConfig.isCommandMatch("/test/", "test", false, helper));
}
@Test
public void regexTest2() {
Assertions.assertFalse(GameCommandFilterConfig.isCommandMatch("/test/", "test extra", false, helper));
}
@Test
public void regexTest3() {
Assertions.assertTrue(GameCommandFilterConfig.isCommandMatch("/test( argument)?/", "test argument", false, helper));
}
@Test
public void regexTest4() {
Assertions.assertFalse(GameCommandFilterConfig.isCommandMatch("/test( argument)?/", "test fail", false, helper));
}
@Test
public void regexTest5() {
Assertions.assertTrue(GameCommandFilterConfig.isCommandMatch("/test( argument)?/", "test", true, helper));
}
public static class ExecutionHelper implements GameCommandExecutionHelper {
private final List<String> TEST1_USED = Collections.singletonList("plugin1:test");
private final List<String> TEST1 = Arrays.asList("test", "plugin1:test");
private final List<String> TEST2 = Arrays.asList("test", "plugin2:test");
private final List<String> TESTER = Arrays.asList("tester", "plugin2:tester");
@Override
public CompletableFuture<List<String>> suggestCommands(List<String> parts) {
return null;
}
@Override
public List<String> getAliases(String command) {
if (TEST1_USED.contains(command)) {
return TEST1;
} else if (TEST2.contains(command)) {
return TEST2;
} else if (TESTER.contains(command)) {
return TESTER;
}
return Collections.emptyList();
}
@Override
public boolean isSameCommand(String command1, String command2) {
return getAliases(command1) == getAliases(command2);
}
}
}