ProtocolLib/src/main/java/com/comphenix/protocol/CommandFilter.java

482 lines
14 KiB
Java

package com.comphenix.protocol;
import java.util.ArrayList;
import java.util.Deque;
import java.util.HashSet;
import java.util.Iterator;
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.ConversationContext;
import org.bukkit.conversations.ConversationFactory;
import org.bukkit.plugin.Plugin;
import com.comphenix.protocol.MultipleLinesPrompt.MultipleConversationCanceller;
import com.comphenix.protocol.error.ErrorReporter;
import com.comphenix.protocol.error.Report;
import com.comphenix.protocol.error.ReportType;
import com.comphenix.protocol.events.PacketEvent;
/**
* A command to apply JavaScript filtering to the packet command.
*
* @author Kristian
*/
public class CommandFilter extends CommandBase {
public static final ReportType REPORT_FALLBACK_ENGINE = new ReportType("Falling back to the Rhino engine.");
public static final ReportType REPORT_CANNOT_LOAD_FALLBACK_ENGINE = new ReportType("Could not load Rhino either. Please upgrade your JVM or OS.");
public static final ReportType REPORT_PACKAGES_UNSUPPORTED_IN_ENGINE = new ReportType("Unable to initialize packages for JavaScript engine.");
public static final ReportType REPORT_FILTER_REMOVED_FOR_ERROR = new ReportType("Removing filter %s for causing %s.");
public static final ReportType REPORT_CANNOT_HANDLE_CONVERSATION = new ReportType("Cannot handle conversation.");
public interface FilterFailedHandler{
/**
* Invoked when a given filter has failed.
* @param event - the packet event.
* @param filter - the filter that failed.
* @param ex - the failure.
* @return TRUE to keep processing this filter, FALSE to remove it.
*/
public boolean handle(PacketEvent event, Filter filter, Exception ex);
}
/**
* 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 Set<PacketType> packets;
/**
* 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 packets - a list of packet types this filter applies to.
*/
public Filter(String name, String predicate, Set<PacketType> packets) {
this.name = name;
this.predicate = predicate;
this.packets = new HashSet<>(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<PacketType> getRanges() {
return new HashSet<>(packets);
}
/**
* 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 packets.contains(event.getPacketType());
}
/**
* Evaluate the current filter using the provided ScriptEngine as context.
* <p>
* 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 or the filter is not valid.
*/
public boolean evaluate(ScriptEngine context, PacketEvent event) throws ScriptException {
if (!isApplicable(event))
return true;
// Ensure that the predicate has been compiled
compile(context);
try {
Object result = ((Invocable) context).invokeFunction(name, event, event.getPacket().getHandle());
if (result instanceof Boolean)
return (Boolean) result;
else
throw new ScriptException("Filter result wasn't a boolean: " + result);
} 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 class CompilationSuccessCanceller implements MultipleConversationCanceller {
@Override
public boolean cancelBasedOnInput(ConversationContext context, String in) {
throw new UnsupportedOperationException("Cannot cancel on the last line alone.");
}
@Override
public void setConversation(Conversation conversation) {
// Ignore
}
@Override
public boolean cancelBasedOnInput(ConversationContext context, String currentLine, StringBuilder lines, int lineCount) {
try {
engine.eval("function(event, packet) {\n" + lines.toString());
// It compiles - accept the filter!
return true;
} catch (ScriptException e) {
// We also have the function() line
int realLineCount = lineCount + 1;
// Only possible to recover from an error on the last line.
return e.getLineNumber() < realLineCount;
}
}
@Override
public CompilationSuccessCanceller clone() {
return new CompilationSuccessCanceller();
}
}
/**
* Name of this command.
*/
public static final String NAME = "filter";
// Default error handler
private FilterFailedHandler defaultFailedHandler;
// Currently registered filters
private List<Filter> filters = new ArrayList<Filter>();
// Owner plugin
private final Plugin plugin;
// Whether or not the command is enabled
private ProtocolConfig config;
// Script engine
private ScriptEngine engine;
private boolean uninitialized;
public CommandFilter(ErrorReporter reporter, Plugin plugin, ProtocolConfig config) {
super(reporter, CommandBase.PERMISSION_ADMIN, NAME, 2);
this.plugin = plugin;
this.config = config;
// Tell the filter system to initialize the script first chance it gets
this.uninitialized = true;
}
private void initalizeScript() {
try {
// First attempt
initializeEngine();
// Oh for ..
if (!isInitialized()) {
throw new ScriptException("A JavaScript engine could not be found.");
} else {
plugin.getLogger().info("Loaded command filter engine.");
}
} catch (ScriptException e1) {
// It's not a huge deal
printPackageWarning(e1);
if (!config.getScriptEngineName().equals("rhino")) {
reporter.reportWarning(this, Report.newBuilder(REPORT_FALLBACK_ENGINE));
config.setScriptEngineName("rhino");
config.saveAll();
try {
initializeEngine();
if (!isInitialized()) {
reporter.reportWarning(this, Report.newBuilder(REPORT_CANNOT_LOAD_FALLBACK_ENGINE));
}
} catch (ScriptException e2) {
// And again ..
printPackageWarning(e2);
}
}
}
}
private void printPackageWarning(ScriptException e) {
reporter.reportWarning(this, Report.newBuilder(REPORT_PACKAGES_UNSUPPORTED_IN_ENGINE).error(e));
}
/**
* Initialize the current configured engine.
* @throws ScriptException If we are unable to import packages.
*/
private void initializeEngine() throws ScriptException {
ScriptEngineManager manager = new ScriptEngineManager();
engine = manager.getEngineByName(config.getScriptEngineName());
// Import useful packages
if (engine != null) {
engine.eval("importPackage(org.bukkit);");
engine.eval("importPackage(com.comphenix.protocol.reflect);");
}
}
/**
* Determine if the filter engine has been successfully initialized.
* @return TRUE if it has, FALSE otherwise.
*/
public boolean isInitialized() {
return engine != null;
}
private FilterFailedHandler getDefaultErrorHandler() {
// No need to create a new object every time
if (defaultFailedHandler == null) {
defaultFailedHandler = new FilterFailedHandler() {
@Override
public boolean handle(PacketEvent event, Filter filter, Exception ex) {
reporter.reportMinimal(plugin, "filterEvent(PacketEvent)", ex, event);
reporter.reportWarning(this,
Report.newBuilder(REPORT_FILTER_REMOVED_FOR_ERROR).messageParam(filter.getName(), ex.getClass().getSimpleName())
);
return false;
}
};
}
return defaultFailedHandler;
}
/**
* Determine whether or not to pass the given packet event to the packet listeners.
* <p>
* Uses a default filter failure handler that simply prints the error message and removes the filter.
* @param event - the event.
* @return TRUE if we should, FALSE otherwise.
*/
public boolean filterEvent(PacketEvent event) {
return filterEvent(event, getDefaultErrorHandler());
}
/**
* Determine whether or not to pass the given packet event to the packet listeners.
* @param event - the event.
* @param handler - failure handler.
* @return TRUE if we should, FALSE otherwise.
*/
public boolean filterEvent(PacketEvent event, FilterFailedHandler handler) {
for (Iterator<Filter> it = filters.iterator(); it.hasNext(); ) {
Filter filter = it.next();
try {
if (!filter.evaluate(engine, event)) {
return false;
}
} catch (Exception ex) {
if (!handler.handle(event, filter, ex)) {
it.remove();
}
}
}
// Pass!
return true;
}
/**
* Initialize the script engine if necessary.
*/
private void checkScriptStatus() {
// Start the engine
if (uninitialized) {
uninitialized = false;
initalizeScript();
}
}
/*
* Description: Adds or removes a simple packet filter.
Usage: /<command> add|remove name [packet IDs]
*/
@Override
protected boolean handleCommand(CommandSender sender, String[] args) {
checkScriptStatus();
if (!config.isDebug()) {
sender.sendMessage(ChatColor.RED + "Debug mode must be enabled in the configuration first!");
return true;
}
if (!isInitialized()) {
sender.sendMessage(ChatColor.RED + "JavaScript engine was not present. Filter system is disabled.");
return true;
}
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;
}
// Prepare the input to the packet type parser
Deque<String> rangeArguments = toQueue(args, 2);
final PacketTypeParser parser = new PacketTypeParser();
final Set<PacketType> packets = parser.parseTypes(rangeArguments, PacketTypeParser.DEFAULT_MAX_RANGE);
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 CompilationSuccessCanceller(), "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,
Report.newBuilder(REPORT_CANNOT_HANDLE_CONVERSATION).error(e).callerParam(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;
}
/**
* 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);
}
}
}