From 15980d70fbfe1e213ec3e566ac5925035e224eed Mon Sep 17 00:00:00 2001 From: Kristian Date: Sun, 7 Apr 2013 15:33:19 +0200 Subject: [PATCH] Added a simple filter system that utilizes JavaScript (Rhino) This makes it possible to filter packet events with arbitrary code. --- .../com/comphenix/protocol/CommandBase.java | 2 + .../com/comphenix/protocol/CommandFilter.java | 394 ++++++++++++++++++ .../com/comphenix/protocol/CommandPacket.java | 11 +- .../protocol/MultipleLinesPrompt.java | 81 ++++ .../comphenix/protocol/ProtocolLibrary.java | 5 +- .../protocol/concurrency/IntegerSet.java | 11 + ProtocolLib/src/main/resources/plugin.yml | 6 + 7 files changed, 505 insertions(+), 5 deletions(-) create mode 100644 ProtocolLib/src/main/java/com/comphenix/protocol/CommandFilter.java create mode 100644 ProtocolLib/src/main/java/com/comphenix/protocol/MultipleLinesPrompt.java diff --git a/ProtocolLib/src/main/java/com/comphenix/protocol/CommandBase.java b/ProtocolLib/src/main/java/com/comphenix/protocol/CommandBase.java index 18ddb25e..1b8cd00c 100644 --- a/ProtocolLib/src/main/java/com/comphenix/protocol/CommandBase.java +++ b/ProtocolLib/src/main/java/com/comphenix/protocol/CommandBase.java @@ -55,6 +55,7 @@ abstract class CommandBase implements CommandExecutor { try { // Make sure we're dealing with the correct command if (!command.getName().equalsIgnoreCase(name)) { + reporter.reportWarning(this, "Incorrect command assigned to " + this); return false; } if (permission != null && !sender.hasPermission(permission)) { @@ -66,6 +67,7 @@ abstract class CommandBase implements CommandExecutor { if (args != null && args.length >= minimumArgumentCount) { return handleCommand(sender, args); } else { + sender.sendMessage(ChatColor.RED + "Insufficient commands. You need at least " + minimumArgumentCount); return false; } diff --git a/ProtocolLib/src/main/java/com/comphenix/protocol/CommandFilter.java b/ProtocolLib/src/main/java/com/comphenix/protocol/CommandFilter.java new file mode 100644 index 00000000..de13c50a --- /dev/null +++ b/ProtocolLib/src/main/java/com/comphenix/protocol/CommandFilter.java @@ -0,0 +1,394 @@ +package com.comphenix.protocol; + +import java.util.ArrayList; +import java.util.HashSet; +import java.util.List; +import java.util.Set; + +import javax.script.Invocable; +import javax.script.ScriptEngine; +import javax.script.ScriptEngineManager; +import javax.script.ScriptException; + +import org.bukkit.ChatColor; +import org.bukkit.command.CommandSender; +import org.bukkit.conversations.Conversable; +import org.bukkit.conversations.Conversation; +import org.bukkit.conversations.ConversationAbandonedEvent; +import org.bukkit.conversations.ConversationAbandonedListener; +import org.bukkit.conversations.ConversationCanceller; +import org.bukkit.conversations.ConversationContext; +import org.bukkit.conversations.ConversationFactory; +import org.bukkit.plugin.Plugin; + +import com.comphenix.protocol.concurrency.IntegerSet; +import com.comphenix.protocol.error.ErrorReporter; +import com.comphenix.protocol.events.PacketEvent; +import com.google.common.collect.DiscreteDomains; +import com.google.common.collect.Range; +import com.google.common.collect.Ranges; + +/** + * A command to apply JavaScript filtering to the packet command. + * + * @author Kristian + */ +public class CommandFilter extends CommandBase { + @SuppressWarnings("serial") + public static class FilterFailedException extends RuntimeException { + private Filter filter; + + public FilterFailedException() { + super(); + } + + public FilterFailedException(String message, Filter filter, Throwable cause) { + super(message, cause); + this.filter = filter; + } + + public Filter getFilter() { + return filter; + } + } + /** + * Possible sub commands. + * + * @author Kristian + */ + private enum SubCommand { + ADD, REMOVE; + } + + /** + * A filter that will be used to process a packet event. + * @author Kristian + */ + public static class Filter { + private final String name; + private final String predicate; + + private final IntegerSet ranges; + + /** + * Construct a new immutable filter. + * @param name - the unique name of the filter. + * @param predicate - the JavaScript predicate that will be used to filter packet events. + * @param ranges - a list of valid packet ID ranges that this filter applies to. + */ + public Filter(String name, String predicate, Set packets) { + this.name = name; + this.predicate = predicate; + this.ranges = new IntegerSet(Packets.MAXIMUM_PACKET_ID + 1); + this.ranges.addAll(packets); + } + + /** + * Retrieve the unique name of the filter. + * @return Unique name of the filter. + */ + public String getName() { + return name; + } + + /** + * Retrieve the JavaScript predicate that will be used to filter packet events. + * @return Predicate itself. + */ + public String getPredicate() { + return predicate; + } + + /** + * Retrieve a copy of the set of packets this filter applies to. + * @return Set of packets this filter applies to. + */ + public Set getRanges() { + return ranges.toSet(); + } + + /** + * Determine whether or not a packet event needs to be passed to this filter. + * @param event - the event to test. + * @return TRUE if it does, FALSE otherwise. + */ + private boolean isApplicable(PacketEvent event) { + return ranges.contains(event.getPacketID()); + } + + /** + * Evaluate the current filter using the provided ScriptEngine as context. + *

+ * This context may be modified with additional code. + * @param context - the current script context. + * @param event - the packet event to evaluate. + * @return TRUE to pass this packet event on to the debug listeners, FALSE otherwise. + * @throws ScriptException If the compilation failed. + */ + public boolean evaluate(ScriptEngine context, PacketEvent event) throws ScriptException { + if (!isApplicable(event)) + return true; + // Ensure that the predicate has been compiled + compile(context); + + try { + return (Boolean) ((Invocable) context).invokeFunction(name, event, event.getPacket().getHandle()); + } catch (NoSuchMethodException e) { + // Must be a fault with the script engine itself + throw new IllegalStateException("Unable to compile " + name + " into current script engine.", e); + } + } + + /** + * Force the compilation of a specific filter. + * @param context - the current script context. + * @throws ScriptException If the compilation failed. + */ + public void compile(ScriptEngine context) throws ScriptException { + if (context.get(name) == null) { + context.eval("var " + name + " = function(event, packet) {\n" + predicate); + } + } + + /** + * Clean up all associated code from this filter in the provided script engine. + * @param context - the current script context. + */ + public void close(ScriptEngine context) { + context.put(name, null); + } + } + + private static class BracketBalance implements ConversationCanceller { + private String KEY_BRACKET_COUNT = "bracket_balance.count"; + + // What to set the initial counter + private final int initialBalance; + + public BracketBalance(int initialBalance) { + this.initialBalance = initialBalance; + } + + @Override + public boolean cancelBasedOnInput(ConversationContext context, String in) { + Object stored = context.getSessionData(KEY_BRACKET_COUNT); + int value = 0; + + // Get the stored value + if (stored instanceof Integer) { + value = (Integer)stored; + } else { + value = initialBalance; + } + + value += count(in, '{') - count(in, '}'); + context.setSessionData(KEY_BRACKET_COUNT, value); + + // Cancel if the bracket balance is zero + return value <= 0; + } + + private int count(String text, char character) { + int counter = 0; + + for (int i=0; i < text.length(); i++) { + if (text.charAt(i) == character) { + counter++; + } + } + return counter; + } + + @Override + public void setConversation(Conversation conversation) { + // Whatever + } + + @Override + public ConversationCanceller clone() { + return new BracketBalance(initialBalance); + } + } + + /** + * Name of this command. + */ + public static final String NAME = "filter"; + + // Currently registered filters + private List filters = new ArrayList(); + + // Owner plugin + private final Plugin plugin; + + // Script engine + private ScriptEngine engine; + + public CommandFilter(ErrorReporter reporter, Plugin plugin) { + super(reporter, CommandBase.PERMISSION_ADMIN, NAME, 2); + this.plugin = plugin; + + // Start the engine + initalizeScript(); + } + + private void initalizeScript() { + ScriptEngineManager manager = new ScriptEngineManager(); + engine = manager.getEngineByName("JavaScript"); + + // Import useful packages + try { + engine.eval("importPackage(org.bukkit);"); + engine.eval("importPackage(com.comphenix.protocol.reflect);"); + } catch (ScriptException e) { + throw new IllegalStateException("Unable to initialize packages for JavaScript engine.", e); + } + } + + /** + * Determine whether or not to pass the given packet event to the packet listeners. + * @param event - the event. + * @return TRUE if we should, FALSE otherwise. + * @throws FilterFailedException If one of the filters failed. + */ + public boolean filterEvent(PacketEvent event) throws FilterFailedException { + for (Filter filter : filters) { + try { + if (!filter.evaluate(engine, event)) { + return false; + } + } catch (ScriptException e) { + throw new FilterFailedException("Filter failed.", filter, e); + } + } + // Pass! + return true; + } + + /* + * Description: Adds or removes a simple packet listener. + Usage: / add|remove name [packet IDs] + */ + @Override + protected boolean handleCommand(CommandSender sender, String[] args) { + final SubCommand command = parseCommand(args, 0); + final String name = args[1]; + + switch (command) { + case ADD: + // Never overwrite an existing filter + if (findFilter(name) != null) { + sender.sendMessage(ChatColor.RED + "Filter " + name + " already exists. Remove it first."); + return true; + } + + final Set packets = parseRanges(args, 2); + sender.sendMessage("Enter filter program ('}' to complete or CANCEL):"); + + // Make sure we can use the conversable interface + if (sender instanceof Conversable) { + final MultipleLinesPrompt prompt = + new MultipleLinesPrompt(new BracketBalance(1), "function(event, packet) {"); + + new ConversationFactory(plugin). + withFirstPrompt(prompt). + withEscapeSequence("CANCEL"). + withLocalEcho(false). + addConversationAbandonedListener(new ConversationAbandonedListener() { + @Override + public void conversationAbandoned(ConversationAbandonedEvent event) { + try { + final Conversable whom = event.getContext().getForWhom(); + + if (event.gracefulExit()) { + String predicate = prompt.removeAccumulatedInput(event.getContext()); + Filter filter = new Filter(name, predicate, packets); + + // Print the last line as well + whom.sendRawMessage(prompt.getPromptText(event.getContext())); + + try { + // Force early compilation + filter.compile(engine); + + filters.add(filter); + whom.sendRawMessage(ChatColor.GOLD + "Added filter " + name); + } catch (ScriptException e) { + e.printStackTrace(); + whom.sendRawMessage(ChatColor.GOLD + "Compilation error: " + e.getMessage()); + } + } else { + // Too bad + whom.sendRawMessage(ChatColor.RED + "Cancelled filter."); + } + } catch (Exception e) { + reporter.reportDetailed(this, "Cannot handle conversation.", e, event); + } + } + }). + buildConversation((Conversable) sender). + begin(); + } else { + sender.sendMessage(ChatColor.RED + "Only console and players are supported!"); + } + + break; + + case REMOVE: + Filter filter = findFilter(name); + + // See if it exists before we remove it + if (filter != null) { + filter.close(engine); + filters.remove(filter); + sender.sendMessage(ChatColor.GOLD + "Removed filter " + name); + } else { + sender.sendMessage(ChatColor.RED + "Unable to find a filter by the name " + name); + } + break; + } + + return true; + } + + private Set parseRanges(String[] args, int start) { + List> ranges = RangeParser.getRanges(args, 2, args.length - 1, Ranges.closed(0, 255)); + Set flatten = new HashSet(); + + if (ranges.isEmpty()) { + // Use every packet ID + ranges.add(Ranges.closed(0, 255)); + } + + // Finally, flatten it all + for (Range range : ranges) { + flatten.addAll(range.asSet(DiscreteDomains.integers())); + } + return flatten; + } + + /** + * Lookup a filter by its name. + * @param name - the filter name. + * @return The filter, or NULL if not found. + */ + private Filter findFilter(String name) { + // We'll just use a linear scan for now - we don't expect that many filters + for (Filter filter : filters) { + if (filter.getName().equalsIgnoreCase(name)) { + return filter; + } + } + return null; + } + + private SubCommand parseCommand(String[] args, int index) { + String text = args[index].toUpperCase(); + + try { + return SubCommand.valueOf(text); + } catch (IllegalArgumentException e) { + throw new IllegalArgumentException(text + " is not a valid sub command. Must be add or remove.", e); + } + } +} diff --git a/ProtocolLib/src/main/java/com/comphenix/protocol/CommandPacket.java b/ProtocolLib/src/main/java/com/comphenix/protocol/CommandPacket.java index 6f098599..3d67453a 100644 --- a/ProtocolLib/src/main/java/com/comphenix/protocol/CommandPacket.java +++ b/ProtocolLib/src/main/java/com/comphenix/protocol/CommandPacket.java @@ -93,11 +93,15 @@ class CommandPacket extends CommandBase { private AbstractIntervalTree clientListeners = createTree(ConnectionSide.CLIENT_SIDE); private AbstractIntervalTree serverListeners = createTree(ConnectionSide.SERVER_SIDE); - public CommandPacket(ErrorReporter reporter, Plugin plugin, Logger logger, ProtocolManager manager) { + // Filter packet events + private CommandFilter filter; + + public CommandPacket(ErrorReporter reporter, Plugin plugin, Logger logger, CommandFilter filter, ProtocolManager manager) { super(reporter, CommandBase.PERMISSION_ADMIN, NAME, 1); this.plugin = plugin; this.logger = logger; this.manager = manager; + this.filter = filter; this.chatter = new ChatExtensions(manager); } @@ -362,7 +366,6 @@ class CommandPacket extends CommandBase { } public DetailedPacketListener createPacketListener(final ConnectionSide side, int idStart, int idStop, final boolean detailed) { - Set range = Ranges.closed(idStart, idStop).asSet(DiscreteDomains.integers()); Set packets; @@ -386,14 +389,14 @@ class CommandPacket extends CommandBase { return new DetailedPacketListener() { @Override public void onPacketSending(PacketEvent event) { - if (side.isForServer()) { + if (side.isForServer() && filter.filterEvent(event)) { printInformation(event); } } @Override public void onPacketReceiving(PacketEvent event) { - if (side.isForClient()) { + if (side.isForClient() && filter.filterEvent(event)) { printInformation(event); } } diff --git a/ProtocolLib/src/main/java/com/comphenix/protocol/MultipleLinesPrompt.java b/ProtocolLib/src/main/java/com/comphenix/protocol/MultipleLinesPrompt.java new file mode 100644 index 00000000..48695a60 --- /dev/null +++ b/ProtocolLib/src/main/java/com/comphenix/protocol/MultipleLinesPrompt.java @@ -0,0 +1,81 @@ +package com.comphenix.protocol; + +import org.bukkit.conversations.ConversationCanceller; +import org.bukkit.conversations.ConversationContext; +import org.bukkit.conversations.ExactMatchConversationCanceller; +import org.bukkit.conversations.Prompt; +import org.bukkit.conversations.StringPrompt; + +/** + * Represents a conversation prompt that accepts a list of lines. + * + * @author Kristian + */ +class MultipleLinesPrompt extends StringPrompt { + // Feels a bit like Android + private static final String KEY = "multiple_lines_prompt"; + private static final String KEY_LAST = KEY + ".last_line"; + + private final ConversationCanceller endMarker; + private final String initialPrompt; + + /** + * Retrieve and remove the current accumulated input. + * @param context - conversation context. + * @return The accumulated input, or NULL if not found. + */ + public String removeAccumulatedInput(ConversationContext context) { + Object result = context.getSessionData(KEY); + + if (result instanceof StringBuilder) { + context.setSessionData(KEY, null); + return ((StringBuilder) result).toString(); + } else { + return null; + } + } + + /** + * Construct a multiple lines input prompt with a specific end marker. + *

+ * This is usually an empty string. + * @param endMarker - the end marker. + */ + public MultipleLinesPrompt(String endMarker, String initialPrompt) { + this(new ExactMatchConversationCanceller(endMarker), initialPrompt); + } + + public MultipleLinesPrompt(ConversationCanceller endMarker, String initialPrompt) { + this.endMarker = endMarker; + this.initialPrompt = initialPrompt; + } + + @Override + public Prompt acceptInput(ConversationContext context, String in) { + StringBuilder result = (StringBuilder) context.getSessionData(KEY); + + if (result == null) { + context.setSessionData(KEY, result = new StringBuilder()); + } + + // Save the last line as well + context.setSessionData(KEY_LAST, in); + result.append(in); + + // And we're done + if (endMarker.cancelBasedOnInput(context, in)) + return Prompt.END_OF_CONVERSATION; + else + return this; + } + + @Override + public String getPromptText(ConversationContext context) { + Object last = context.getSessionData(KEY_LAST); + + if (last instanceof String) + return (String) last; + else + return initialPrompt; + } +} diff --git a/ProtocolLib/src/main/java/com/comphenix/protocol/ProtocolLibrary.java b/ProtocolLib/src/main/java/com/comphenix/protocol/ProtocolLibrary.java index fed4ccc0..dcb120c5 100644 --- a/ProtocolLib/src/main/java/com/comphenix/protocol/ProtocolLibrary.java +++ b/ProtocolLib/src/main/java/com/comphenix/protocol/ProtocolLibrary.java @@ -103,6 +103,7 @@ public class ProtocolLibrary extends JavaPlugin { // Commands private CommandProtocol commandProtocol; private CommandPacket commandPacket; + private CommandFilter commandFilter; // Whether or not disable is not needed private boolean skipDisable; @@ -161,7 +162,8 @@ public class ProtocolLibrary extends JavaPlugin { // Initialize command handlers commandProtocol = new CommandProtocol(detailedReporter, this, updater, config); - commandPacket = new CommandPacket(detailedReporter, this, logger, protocolManager); + commandFilter = new CommandFilter(detailedReporter, this); + commandPacket = new CommandPacket(detailedReporter, this, logger, commandFilter, protocolManager); // Send logging information to player listeners too setupBroadcastUsers(PERMISSION_INFO); @@ -256,6 +258,7 @@ public class ProtocolLibrary extends JavaPlugin { // Set up command handlers registerCommand(CommandProtocol.NAME, commandProtocol); registerCommand(CommandPacket.NAME, commandPacket); + registerCommand(CommandFilter.NAME, commandFilter); // Player login and logout events protocolManager.registerEvents(manager, this); diff --git a/ProtocolLib/src/main/java/com/comphenix/protocol/concurrency/IntegerSet.java b/ProtocolLib/src/main/java/com/comphenix/protocol/concurrency/IntegerSet.java index e2aaa3f6..c99e18e1 100644 --- a/ProtocolLib/src/main/java/com/comphenix/protocol/concurrency/IntegerSet.java +++ b/ProtocolLib/src/main/java/com/comphenix/protocol/concurrency/IntegerSet.java @@ -18,6 +18,7 @@ package com.comphenix.protocol.concurrency; import java.util.Arrays; +import java.util.Collection; import java.util.HashSet; import java.util.Set; @@ -60,6 +61,16 @@ public class IntegerSet { array[element] = true; } + /** + * Add the given collection of elements to the set. + * @param packets - elements to add. + */ + public void addAll(Collection packets) { + for (Integer id : packets) { + add(id); + } + } + /** * Remove the given element from the set, or do nothing if it's already removed. * @param element - element to remove. diff --git a/ProtocolLib/src/main/resources/plugin.yml b/ProtocolLib/src/main/resources/plugin.yml index c95bbdcf..1b2a0377 100644 --- a/ProtocolLib/src/main/resources/plugin.yml +++ b/ProtocolLib/src/main/resources/plugin.yml @@ -17,6 +17,12 @@ commands: usage: / add|remove|names client|server [ID start]-[ID stop] [detailed] permission: protocol.admin permission-message: You don't have + filter: + description: Add or remove programmable filters to the packet listeners. + usage: / add|remove name [ID start]-[ID stop] + aliases: [packet_filter] + permission: protocol.admin + permission-message: You don't have permissions: protocol.*: