diff --git a/src/main/java/net/pingex/dcf/DiscordCommandableFramework.java b/src/main/java/net/pingex/dcf/DiscordCommandableFramework.java index e345038..5ce5a3b 100644 --- a/src/main/java/net/pingex/dcf/DiscordCommandableFramework.java +++ b/src/main/java/net/pingex/dcf/DiscordCommandableFramework.java @@ -5,6 +5,7 @@ import net.pingex.dcf.commands.InternalCommands; import net.pingex.dcf.core.Configuration; import net.pingex.dcf.core.GatewayConnectionsManager; import net.pingex.dcf.modularity.PluginLoader; +import net.pingex.dcf.permissions.PermissionsCommands; import org.apache.logging.log4j.LogManager; import org.apache.logging.log4j.Logger; import sx.blah.discord.api.ClientBuilder; @@ -44,6 +45,7 @@ public class DiscordCommandableFramework // Register internal commands new InternalCommands().getCommands().forEach(CommandRegistry::registerCommand); + new PermissionsCommands().getCommands().forEach(CommandRegistry::registerCommand); // Run plugins PluginLoader.getInstance().bulkRunPlugins(); diff --git a/src/main/java/net/pingex/dcf/commands/AnnotatedCommand.java b/src/main/java/net/pingex/dcf/commands/AnnotatedCommand.java index e05c2bd..1191cd5 100644 --- a/src/main/java/net/pingex/dcf/commands/AnnotatedCommand.java +++ b/src/main/java/net/pingex/dcf/commands/AnnotatedCommand.java @@ -1,5 +1,6 @@ package net.pingex.dcf.commands; +import net.pingex.dcf.permissions.DefaultPermission; import java.lang.annotation.ElementType; import java.lang.annotation.Retention; import java.lang.annotation.RetentionPolicy; @@ -36,4 +37,9 @@ public @interface AnnotatedCommand * Command usage help */ String usage() default Command.Defaults.USAGE; + + /** + * Default permission, ie. when the permissions provider doesn't return anything. + */ + DefaultPermission defaultPermission() default DefaultPermission.EVERYONE; } diff --git a/src/main/java/net/pingex/dcf/commands/Command.java b/src/main/java/net/pingex/dcf/commands/Command.java index 602420a..cf12efe 100644 --- a/src/main/java/net/pingex/dcf/commands/Command.java +++ b/src/main/java/net/pingex/dcf/commands/Command.java @@ -1,5 +1,6 @@ package net.pingex.dcf.commands; +import net.pingex.dcf.permissions.DefaultPermission; import sx.blah.discord.handle.impl.events.MessageReceivedEvent; import java.lang.reflect.Method; import java.util.Arrays; @@ -36,6 +37,11 @@ public abstract class Command implements ICommandExecutor */ private String usage; + /** + * Default permission, ie. when the permissions provider doesn't return anything. + */ + private DefaultPermission defaultPermission; + /** * Basic constructor. * @param name Name of the command @@ -43,14 +49,16 @@ public abstract class Command implements ICommandExecutor * @param description Description of the command * @param isEnabled Is the command enabled ? * @param usage Command usage help + * @param defaultPermission Default permission, ie. when the permissions provider doesn't return anything. */ - public Command(String name, List aliases, String description, boolean isEnabled, String usage) + public Command(String name, List aliases, String description, boolean isEnabled, String usage, DefaultPermission defaultPermission) { this.name = name; this.aliases = aliases; this.description = description; this.isEnabled = isEnabled; this.usage = usage; + this.defaultPermission = defaultPermission; } /** @@ -62,6 +70,7 @@ public abstract class Command implements ICommandExecutor public static final String DESCRIPTION = "No command description provided."; public static final boolean IS_ENABLED = true; public static final String USAGE = "No command usage provided."; + public static final DefaultPermission DEFAULT_PERMISSION = DefaultPermission.EVERYONE; } /** @@ -94,6 +103,11 @@ public abstract class Command implements ICommandExecutor */ private String usage = Defaults.USAGE; + /** + * Default permission, ie. when the permissions provider doesn't return anything. + */ + private DefaultPermission defaultPermission = Defaults.DEFAULT_PERMISSION; + public Builder(String name) { this.name = name; @@ -133,6 +147,12 @@ public abstract class Command implements ICommandExecutor return this; } + public Builder defaultPermission(DefaultPermission defaultPermission) + { + this.defaultPermission = defaultPermission; + return this; + } + /** * Build a new Command using a supplied executor. * @param toExecute The body of the command. @@ -140,7 +160,7 @@ public abstract class Command implements ICommandExecutor */ public Command build(ICommandExecutor toExecute) { - return new Command(name, aliases, description, isEnabled, usage) + return new Command(name, aliases, description, isEnabled, usage, defaultPermission) { @Override public void execute(MessageReceivedEvent event, List arguments) throws Throwable @@ -158,7 +178,7 @@ public abstract class Command implements ICommandExecutor */ public Command build(Method target, Object invokable) { - return new Command(name, aliases, description, isEnabled, usage) + return new Command(name, aliases, description, isEnabled, usage, defaultPermission) { @Override public void execute(MessageReceivedEvent event, List arguments) throws Throwable @@ -191,6 +211,7 @@ public abstract class Command implements ICommandExecutor .description(meta.description()) .enabled(meta.isEnabled()) .usage(meta.usage()) + .defaultPermission(meta.defaultPermission()) .build(target, invokable); } @@ -219,6 +240,11 @@ public abstract class Command implements ICommandExecutor return usage; } + public DefaultPermission getDefaultPermission() + { + return defaultPermission; + } + public void setEnabled(boolean enabled) { isEnabled = enabled; diff --git a/src/main/java/net/pingex/dcf/commands/CommandHandler.java b/src/main/java/net/pingex/dcf/commands/CommandHandler.java index d7c9576..0731a9d 100644 --- a/src/main/java/net/pingex/dcf/commands/CommandHandler.java +++ b/src/main/java/net/pingex/dcf/commands/CommandHandler.java @@ -5,6 +5,7 @@ import net.pingex.dcf.commands.parser.ICommandParser; import net.pingex.dcf.commands.parser.ParserException; import net.pingex.dcf.core.Configuration; import net.pingex.dcf.modularity.PluginWrapper; +import net.pingex.dcf.permissions.PermissionsHandler; import net.pingex.dcf.util.DiscordInteractionsUtil; import org.apache.commons.lang3.concurrent.BasicThreadFactory; import org.apache.logging.log4j.LogManager; @@ -66,6 +67,15 @@ public class CommandHandler return; } + // Authorize + boolean canRun = PermissionsHandler.canRunCommand(event.getMessage(), targetCommand.get()); + if(!canRun) + { + LOGGER.debug("User {} attempted to run a forbidden command !", event.getMessage().getAuthor().getID()); + DiscordInteractionsUtil.sendMessage(event.getMessage().getChannel(), "Access denied."); + return; + } + // Run it threadPool.submit(() -> { diff --git a/src/main/java/net/pingex/dcf/commands/InternalCommands.java b/src/main/java/net/pingex/dcf/commands/InternalCommands.java index d585ae1..424ce3e 100644 --- a/src/main/java/net/pingex/dcf/commands/InternalCommands.java +++ b/src/main/java/net/pingex/dcf/commands/InternalCommands.java @@ -1,6 +1,7 @@ package net.pingex.dcf.commands; import net.pingex.dcf.core.Configuration; +import net.pingex.dcf.permissions.DefaultPermission; import net.pingex.dcf.util.ArgumentParser; import net.pingex.dcf.util.DiscordInteractionsUtil; import org.apache.commons.lang3.StringUtils; @@ -28,6 +29,7 @@ public class InternalCommands implements IWithCommands private static final String DESCRIPTION = "List all available commands."; private static final boolean IS_ENABLED = true; private static final String USAGE = Configuration.COMMAND_PREFIX + NAME + " "; + private static final DefaultPermission DEFAULT_PERMISSION = DefaultPermission.EVERYONE; /** * How many commands should be displayed on each page @@ -38,7 +40,7 @@ public class InternalCommands implements IWithCommands private ListCommand() { - super(NAME, ALIASES, DESCRIPTION, IS_ENABLED, USAGE); + super(NAME, ALIASES, DESCRIPTION, IS_ENABLED, USAGE, DEFAULT_PERMISSION); } @Override @@ -106,12 +108,13 @@ public class InternalCommands implements IWithCommands private static final String DESCRIPTION = "Gives the usage of a command."; private static final boolean IS_ENABLED = true; private static final String USAGE = Configuration.COMMAND_PREFIX + NAME + " "; + private static final DefaultPermission DEFAULT_PERMISSION = DefaultPermission.EVERYONE; static final UsageCommand INSTANCE = new UsageCommand(); private UsageCommand() { - super(NAME, ALIASES, DESCRIPTION, IS_ENABLED, USAGE); + super(NAME, ALIASES, DESCRIPTION, IS_ENABLED, USAGE, DEFAULT_PERMISSION); } @Override diff --git a/src/main/java/net/pingex/dcf/permissions/DefaultPermission.java b/src/main/java/net/pingex/dcf/permissions/DefaultPermission.java new file mode 100644 index 0000000..2432bf8 --- /dev/null +++ b/src/main/java/net/pingex/dcf/permissions/DefaultPermission.java @@ -0,0 +1,49 @@ +package net.pingex.dcf.permissions; + +import net.pingex.dcf.core.Configuration; +import sx.blah.discord.handle.obj.IMessage; +import java.util.function.Predicate; + +/** + * Default behavior when a permissions provider doesn't return `true` or `false` + */ +public enum DefaultPermission implements Predicate +{ + /** + * Everyone is allowed to run the command. + */ + EVERYONE(iMessage -> true), + + /** + * Only the guild owner is allowed to run the command. + */ + GUILD_OWNER(iMessage -> iMessage.getAuthor().getID().equals(iMessage.getGuild().getOwnerID())), + + /** + * Only the bot owner is allowed to run the command. + */ + BOT_OWNER(iMessage -> iMessage.getAuthor().getID().equals(Configuration.BOT_OWNER)), + + /** + * Guild Owner x Bot Owner + */ + ANY_OWNER(iMessage -> GUILD_OWNER.test(iMessage) || BOT_OWNER.test(iMessage)), + + /** + * Nobody is allowed to run the command + */ + NONE(iMessage -> false); + + DefaultPermission(Predicate predicate) + { + this.predicate = predicate; + } + + private Predicate predicate; + + @Override + public boolean test(IMessage o) + { + return predicate.test(o); + } +} diff --git a/src/main/java/net/pingex/dcf/permissions/DefaultPermissionsProvider.java b/src/main/java/net/pingex/dcf/permissions/DefaultPermissionsProvider.java new file mode 100644 index 0000000..2d1958a --- /dev/null +++ b/src/main/java/net/pingex/dcf/permissions/DefaultPermissionsProvider.java @@ -0,0 +1,24 @@ +package net.pingex.dcf.permissions; + +import net.pingex.dcf.commands.Command; +import sx.blah.discord.handle.obj.IGuild; +import sx.blah.discord.handle.obj.IRole; +import sx.blah.discord.handle.obj.IUser; + +/** + * The one and only permissions provider for now. + */ +public class DefaultPermissionsProvider implements ICommandPermissionsProvider +{ + @Override + public Boolean validateUser(IGuild guild, IUser user, Command command) + { + return null; + } + + @Override + public Boolean validateGroup(IRole role, Command command) + { + return null; + } +} diff --git a/src/main/java/net/pingex/dcf/permissions/ICommandPermissionsProvider.java b/src/main/java/net/pingex/dcf/permissions/ICommandPermissionsProvider.java new file mode 100644 index 0000000..11f8d8c --- /dev/null +++ b/src/main/java/net/pingex/dcf/permissions/ICommandPermissionsProvider.java @@ -0,0 +1,34 @@ +package net.pingex.dcf.permissions; + +import net.pingex.dcf.commands.Command; +import sx.blah.discord.handle.obj.IGuild; +import sx.blah.discord.handle.obj.IRole; +import sx.blah.discord.handle.obj.IUser; + +/** + * Provider of permissions, ie. the proxy between {@code PermissionsHandler} and a data storage. + * + * The validator will successively test the following until something other than `null` is returned. + * - Individual user permission using {@code validateUser()} + * - The user's role using {@code validateGroup()} + * - Default behavior + */ +public interface ICommandPermissionsProvider +{ + /** + * Validate individual user permission. + * @param guild Guild where command is executed. `null` = globally + * @param user Target user + * @param command Command to test + * @return `true` if the user is able to run the command, `false` if he isn't able to, `null` if there is no answer. + */ + Boolean validateUser(IGuild guild, IUser user, Command command); + + /** + * Validate a role's ability to run a command. + * @param role Target role + * @param command Command to test + * @return `true` if the role is able to run the command, `false` if he isn't able to, `null` if there is no answer. + */ + Boolean validateGroup(IRole role, Command command); +} diff --git a/src/main/java/net/pingex/dcf/permissions/PermissionsCommands.java b/src/main/java/net/pingex/dcf/permissions/PermissionsCommands.java new file mode 100644 index 0000000..370636e --- /dev/null +++ b/src/main/java/net/pingex/dcf/permissions/PermissionsCommands.java @@ -0,0 +1,96 @@ +package net.pingex.dcf.permissions; + +import net.pingex.dcf.commands.Command; +import net.pingex.dcf.commands.CommandRegistry; +import net.pingex.dcf.commands.IWithCommands; +import net.pingex.dcf.util.DiscordInteractionsUtil; +import sx.blah.discord.handle.impl.events.MessageReceivedEvent; +import sx.blah.discord.handle.obj.IUser; + +import java.util.*; +import java.util.regex.Pattern; + +/** + * Commands for the permissions package. + */ +public class PermissionsCommands implements IWithCommands +{ + @Override + public Set getCommands() + { + return new HashSet<>(Arrays.asList( + IsAllowedCommand + )); + } + + private static final Command IsAllowedCommand = + new Command.Builder("canRun") + .description("Tells whether target user is allowed to run the command.") + .enabled(true) + .usage("Command [verbose]") + .defaultPermission(DefaultPermission.BOT_OWNER) + .build(PermissionsCommands::isAllowedImpl); + + private static Pattern usernamePattern = Pattern.compile("^.*#\\d{4}$"); + private static Pattern idPattern = Pattern.compile("^\\d{18}$"); + + private static void isAllowedImpl(MessageReceivedEvent event, List arguments) + { + // Parameters + Command target = null; + IUser userToLookup = null; + boolean verbose = false; + + // Argchk size + if(arguments.size() != 2 && arguments.size() != 3) + { + DiscordInteractionsUtil.sendMessage(event.getMessage().getChannel(), "Invalid arguments."); + return; + } + + // Argchk#1 - valid command + Optional uncheckedTarget = CommandRegistry.getCommandOrAliasByName(arguments.get(0)); + if(!uncheckedTarget.isPresent()) // Command existence + { + DiscordInteractionsUtil.sendMessage(event.getMessage().getChannel(), "Target command not found."); + return; + } + else target = uncheckedTarget.get(); + + // Argchk#2 - uid OR username#1234 OR "me" + if(idPattern.matcher(arguments.get(1)).matches()) // uid + { + userToLookup = event.getClient().getUserByID(arguments.get(1)); + } + else if(usernamePattern.matcher(arguments.get(1)).matches()) // username#1234 + { + for(IUser iUser : event.getClient().getUsers()) // Greedy + if(arguments.get(1).equalsIgnoreCase(iUser.getName() + "#" + iUser.getDiscriminator())) + { + userToLookup = iUser; + break; // Limit greediness + } + } + else if(arguments.get(1).equalsIgnoreCase("me")) // me + { + userToLookup = event.getMessage().getAuthor(); + } + else // ? + { + DiscordInteractionsUtil.sendMessage(event.getMessage().getChannel(), "Invalid arguments."); + return; + } + if(userToLookup == null) + { + DiscordInteractionsUtil.sendMessage(event.getMessage().getChannel(), "User not found."); + return; + } + + // Argchk#3 - verbosity + if(arguments.size() == 3) verbose = true; + + // ======================================== + + DiscordInteractionsUtil.sendMessage(event.getMessage().getChannel(), "STUB: Checking perms for command " + target.getName() + " and user " + userToLookup.getName()); + } +} diff --git a/src/main/java/net/pingex/dcf/permissions/PermissionsHandler.java b/src/main/java/net/pingex/dcf/permissions/PermissionsHandler.java new file mode 100644 index 0000000..df456e4 --- /dev/null +++ b/src/main/java/net/pingex/dcf/permissions/PermissionsHandler.java @@ -0,0 +1,69 @@ +package net.pingex.dcf.permissions; + +import net.pingex.dcf.commands.Command; +import sx.blah.discord.handle.obj.IGuild; +import sx.blah.discord.handle.obj.IMessage; +import sx.blah.discord.handle.obj.IRole; +import sx.blah.discord.handle.obj.IUser; + +import java.util.Collections; +import java.util.List; + +/** + * Landing class of this package. + */ +public class PermissionsHandler +{ + private static final ICommandPermissionsProvider CURRENT_PROVIDER = new DefaultPermissionsProvider(); + + /** + * Tells whether someone can run requested command. + * + * The validator will successively test the following until something other than `null` is returned. + * - Individual user permission on the called guild using {@code ICommandPermissionsProvider.validateUser()} + * - Individual user permission globally using {@code ICommandPermissionsProvider.validateUser()} + * - The user's role list using {@code ICommandPermissionsProvider.validateGroup()} + * - Default behavior included in a Command object + * + * @param request Request message, includes author, guild, and so on. + * @param command Requested command, as parsed by {@code CommandHandler} + * @return `true` if request is granted, `false` if denied. + */ + public static boolean canRunCommand(IMessage request, Command command) + { + IUser requestAuthor = request.getAuthor(); + IGuild originatedGuild; + List rolesForUser; + try + { + originatedGuild = request.getGuild(); + rolesForUser = requestAuthor.getRolesForGuild(originatedGuild); + } + catch(UnsupportedOperationException uoe) // DM + { + originatedGuild = null; + rolesForUser = Collections.emptyList(); + } + + // First check: user permission for guild, skipped if DM + if(originatedGuild != null) + { + Boolean canUserGuildRun = CURRENT_PROVIDER.validateUser(originatedGuild, requestAuthor, command); + if(canUserGuildRun != null) return canUserGuildRun; + } + + // Second check: user permission globally + Boolean canUserRun = CURRENT_PROVIDER.validateUser(null, requestAuthor, command); + if(canUserRun != null) return canUserRun; + + // Third check: user role permissions, auto-skipped if DM + for(IRole i : rolesForUser) + { + Boolean canRoleRun = CURRENT_PROVIDER.validateGroup(i, command); + if(canRoleRun != null) return canRoleRun; + } + + // Fourth check: default behavior + return command.getDefaultPermission().test(request); + } +} diff --git a/src/main/java/net/pingex/dcf/permissions/package-info.java b/src/main/java/net/pingex/dcf/permissions/package-info.java new file mode 100644 index 0000000..62042a9 --- /dev/null +++ b/src/main/java/net/pingex/dcf/permissions/package-info.java @@ -0,0 +1,4 @@ +/** + * This package contains everything related to permissions and the ability for an user to run something. + */ +package net.pingex.dcf.permissions; \ No newline at end of file