diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index dd40f05..92503bf 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -1,10 +1,5 @@ name: Maven CI - -on: - push: - branches: [ master ] - pull_request: - branches: [ master ] +on: [push, pull_request] jobs: build: @@ -31,3 +26,4 @@ jobs: with: name: CommandSpy path: target/CommandSpy.jar + compression-level: 0 diff --git a/.gitignore b/.gitignore index f845591..d3ee570 100755 --- a/.gitignore +++ b/.gitignore @@ -6,3 +6,5 @@ target/ .classpath .project *.iml +.theia/ +run/ \ No newline at end of file diff --git a/README.md b/README.md index 57d3662..bb7b021 100644 --- a/README.md +++ b/README.md @@ -6,9 +6,9 @@ The plugin is created for the Kaboom server. ## Commands -| Command | Alias | Permission | Description | -| ------- | ----- | ---------- | ----------- | -|/commandspy | /c, /cs, /cspy | commandspy.command | Allows you to spy on players' commands| +| Command | Alias | Permission | Description | +|-------------|----------------|--------------------|----------------------------------------| +| /commandspy | /c, /cs, /cspy | commandspy.command | Allows you to spy on players' commands | ## Compiling diff --git a/pom.xml b/pom.xml index 8c686a8..61c531f 100644 --- a/pom.xml +++ b/pom.xml @@ -15,7 +15,7 @@ <dependency> <groupId>io.papermc.paper</groupId> <artifactId>paper-api</artifactId> - <version>1.18.2-R0.1-SNAPSHOT</version> + <version>1.20.4-R0.1-SNAPSHOT</version> <scope>provided</scope> </dependency> </dependencies> @@ -30,10 +30,22 @@ <build> <finalName>${project.artifactId}</finalName> <plugins> + <plugin> + <groupId>org.apache.maven.plugins</groupId> + <artifactId>maven-jar-plugin</artifactId> + <version>3.4.2</version> + <configuration> + <archive> + <manifestEntries> + <paperweight-mappings-namespace>mojang</paperweight-mappings-namespace> + </manifestEntries> + </archive> + </configuration> + </plugin> <plugin> <groupId>org.apache.maven.plugins</groupId> <artifactId>maven-checkstyle-plugin</artifactId> - <version>3.1.2</version> + <version>3.6.0</version> <executions> <execution> <id>checkstyle</id> diff --git a/src/main/java/pw/kaboom/commandspy/CommandSpyState.java b/src/main/java/pw/kaboom/commandspy/CommandSpyState.java new file mode 100644 index 0000000..676ba89 --- /dev/null +++ b/src/main/java/pw/kaboom/commandspy/CommandSpyState.java @@ -0,0 +1,127 @@ +package pw.kaboom.commandspy; + +import com.google.common.io.Files; +import it.unimi.dsi.fastutil.objects.ObjectOpenHashSet; +import net.kyori.adventure.text.Component; +import org.bukkit.Bukkit; +import org.bukkit.entity.Player; +import org.bukkit.plugin.java.JavaPlugin; +import org.jetbrains.annotations.NotNull; +import org.slf4j.Logger; + +import java.io.*; +import java.nio.ByteBuffer; +import java.util.Collection; +import java.util.UUID; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.locks.StampedLock; +import java.util.stream.Collectors; + +public final class CommandSpyState { + private static final Logger LOGGER = JavaPlugin.getPlugin(Main.class).getSLF4JLogger(); + + private final ObjectOpenHashSet<UUID> users = new ObjectOpenHashSet<>(); + private final StampedLock usersLock = new StampedLock(); + private final AtomicBoolean dirty = new AtomicBoolean(); + private final File file; + + public CommandSpyState(final @NotNull File file) { + this.file = file; + + try { + this.load(); + } catch (final FileNotFoundException exception) { + try { + this.save(); // Create file if it doesn't exist + } catch (IOException ignored) { + } + } catch (final IOException exception) { + LOGGER.error("Failed to load state file:", exception); + } + } + + private void load() throws IOException { + final InputStream reader = new BufferedInputStream(new FileInputStream(this.file)); + + int read; + final ByteBuffer buffer = ByteBuffer.wrap(new byte[16]); + + // Loop until we read less than 16 bytes + while ((read = reader.readNBytes(buffer.array(), 0, 16)) == 16) { + this.users.add(new UUID(buffer.getLong(0), buffer.getLong(8))); + } + + reader.close(); + if (read != 0) { + throw new IOException("Found " + read + " bytes extra whilst reading file"); + } + } + + private void save() throws IOException { + Files.createParentDirs(this.file); + final OutputStream writer = new BufferedOutputStream(new FileOutputStream(this.file)); + final ByteBuffer buffer = ByteBuffer.wrap(new byte[16]); + + final long stamp = this.usersLock.readLock(); + for (final UUID uuid : this.users) { + buffer.putLong(0, uuid.getMostSignificantBits()); + buffer.putLong(8, uuid.getLeastSignificantBits()); + writer.write(buffer.array()); + } + this.usersLock.unlockRead(stamp); + + writer.flush(); + writer.close(); + } + + public void trySave() { + // If the state is not dirty, then we don't need to do anything. + if (!this.dirty.compareAndExchange(true, false)) { + return; + } + + try { + this.save(); + } catch (final IOException exception) { + LOGGER.error("Failed to save state file:", exception); + } + } + + public boolean getCommandSpyState(final @NotNull UUID playerUUID) { + final long stamp = this.usersLock.readLock(); + final boolean result = this.users.contains(playerUUID); + this.usersLock.unlockRead(stamp); + + return result; + } + + public void setCommandSpyState(final @NotNull UUID playerUUID, final boolean state) { + final long stamp = this.usersLock.writeLock(); + + final boolean dirty; + if (state) { + dirty = this.users.add(playerUUID); + } else { + dirty = this.users.remove(playerUUID); + } + + this.usersLock.unlockWrite(stamp); + if (dirty) { + this.dirty.set(true); + } + } + + public void broadcastSpyMessage(final @NotNull Component message) { + // Raw access here, so we can get more performance by not locking/unlocking over and over + final long stamp = this.usersLock.readLock(); + final Collection<Player> players = Bukkit.getOnlinePlayers() + .stream() + .filter(p -> this.users.contains(p.getUniqueId())) + .collect(Collectors.toUnmodifiableSet()); + this.usersLock.unlockRead(stamp); + + for (final Player recipient : players) { + recipient.sendMessage(message); + } + } +} diff --git a/src/main/java/pw/kaboom/commandspy/Main.java b/src/main/java/pw/kaboom/commandspy/Main.java index 7aab4d7..a5d4f40 100644 --- a/src/main/java/pw/kaboom/commandspy/Main.java +++ b/src/main/java/pw/kaboom/commandspy/Main.java @@ -1,120 +1,188 @@ package pw.kaboom.commandspy; -import java.util.Set; -import java.util.UUID; - +import net.kyori.adventure.text.Component; +import net.kyori.adventure.text.format.NamedTextColor; import org.bukkit.Bukkit; import org.bukkit.command.Command; import org.bukkit.command.CommandExecutor; import org.bukkit.command.CommandSender; -import org.bukkit.command.ConsoleCommandSender; -import org.bukkit.configuration.file.FileConfiguration; import org.bukkit.entity.Player; import org.bukkit.event.EventHandler; +import org.bukkit.event.EventPriority; import org.bukkit.event.Listener; import org.bukkit.event.block.SignChangeEvent; import org.bukkit.event.player.PlayerCommandPreprocessEvent; -import org.bukkit.plugin.Plugin; import org.bukkit.plugin.java.JavaPlugin; -import net.kyori.adventure.text.Component; -import net.kyori.adventure.text.format.NamedTextColor; +import org.jetbrains.annotations.NotNull; + +import java.io.File; +import java.util.UUID; public final class Main extends JavaPlugin implements CommandExecutor, Listener { - private FileConfiguration config; + private CommandSpyState config; @Override public void onEnable() { - config = getConfig(); + this.config = new CommandSpyState(new File(this.getDataFolder(), "state.bin")); + + //noinspection DataFlowIssue this.getCommand("commandspy").setExecutor(this); this.getServer().getPluginManager().registerEvents(this, this); + + // Save the state every 30 seconds + Bukkit.getScheduler().runTaskTimerAsynchronously(this, this.config::trySave, 600L, 600L); } - private void enableCommandSpy(final Player player) { - config.set(player.getUniqueId().toString(), true); - saveConfig(); - player.sendMessage(Component.text("Successfully enabled CommandSpy")); + @Override + public void onDisable() { + this.config.trySave(); } - private void disableCommandSpy(final Player player) { - config.set(player.getUniqueId().toString(), null); - saveConfig(); - player.sendMessage(Component.text("Successfully disabled CommandSpy")); + private void updateCommandSpyState(final @NotNull Player target, + final @NotNull CommandSender source, final boolean state) { + this.config.setCommandSpyState(target.getUniqueId(), state); + + final Component stateString = Component.text(state ? "enabled" : "disabled"); + + target.sendMessage(Component.empty() + .append(Component.text("Successfully ")) + .append(stateString) + .append(Component.text(" CommandSpy"))); + + if (source != target) { + source.sendMessage(Component.empty() + .append(Component.text("Successfully ")) + .append(stateString) + .append(Component.text(" CommandSpy for ")) + .append(target.name()) + ); + } } private NamedTextColor getTextColor(final Player player) { - if (config.contains(player.getUniqueId().toString())) { + if (this.config.getCommandSpyState(player.getUniqueId())) { return NamedTextColor.YELLOW; } + return NamedTextColor.AQUA; } @Override - public boolean onCommand(final CommandSender sender, final Command cmd, final String label, - final String[] args) { - if (sender instanceof ConsoleCommandSender) { - sender.sendMessage(Component.text("Command has to be run by a player")); - return true; + public boolean onCommand(final @NotNull CommandSender sender, final @NotNull Command cmd, + final @NotNull String label, final String[] args) { + + Player target = null; + Boolean state = null; + + switch (args.length) { + case 0 -> { + } + case 1, 2 -> { + // Get the last argument as a state. Fail if we have 2 arguments. + state = getState(args[args.length - 1]); + if (state != null && args.length == 1) { + break; + } else if (state == null && args.length == 2) { + sender.sendMessage(Component.text("Usage: ", NamedTextColor.RED) + .append(Component.text(cmd.getUsage().replace("<command>", label))) + ); + return true; + } + + // Get the first argument as a player. Fail if it can't be found. + target = getPlayer(args[0]); + if (target != null) { + break; + } + + sender.sendMessage(Component.empty() + .append(Component.text("Player \"")) + .append(Component.text(args[0])) + .append(Component.text("\" not found")) + ); + return true; + } + default -> { + sender.sendMessage(Component.text("Usage: ", NamedTextColor.RED) + .append(Component.text(cmd.getUsage().replace("<command>", label))) + ); + return true; + } } - final Player player = (Player) sender; + if (target == null) { + if (!(sender instanceof final Player player)) { + sender.sendMessage(Component.text("Command has to be run by a player")); + return true; + } - if (args.length >= 1 && "on".equalsIgnoreCase(args[0])) { - enableCommandSpy(player); - return true; + target = player; } - if ((args.length >= 1 && "off".equalsIgnoreCase(args[0])) - || config.contains(player.getUniqueId().toString())) { - disableCommandSpy(player); - return true; + + if (state == null) { + state = !this.config.getCommandSpyState(target.getUniqueId()); } - enableCommandSpy(player); + + this.updateCommandSpyState(target, sender, state); + return true; } - @EventHandler + @EventHandler(ignoreCancelled = true, priority = EventPriority.MONITOR) void onPlayerCommandPreprocess(final PlayerCommandPreprocessEvent event) { - if (event.isCancelled()) { - return; - } - final Player player = event.getPlayer(); final NamedTextColor color = getTextColor(player); + final Component message = Component.text(player.getName(), color) - .append(Component.text(": ")) - .append(Component.text(event.getMessage())); + .append(Component.text(": ")) + .append(Component.text(event.getMessage())); - for (String uuidString : config.getKeys(false)) { - final UUID uuid = UUID.fromString(uuidString); - final Player recipient = Bukkit.getPlayer(uuid); - - if (recipient == null) { - continue; - } - recipient.sendMessage(message); - } + this.config.broadcastSpyMessage(message); } - @EventHandler + @EventHandler(ignoreCancelled = true, priority = EventPriority.MONITOR) void onSignChange(final SignChangeEvent event) { final Player player = event.getPlayer(); final NamedTextColor color = getTextColor(player); Component message = Component.text(player.getName(), color) - .append(Component.text(" created a sign with contents:")); + .append(Component.text(" created a sign with contents:")); - for (Component line : event.lines()) { + for (final Component line : event.lines()) { message = message - .append(Component.text("\n ")) - .append(line); + .append(Component.text("\n ")) + .append(line); } - for (String uuidString : config.getKeys(false)) { - final UUID uuid = UUID.fromString(uuidString); - final Player recipient = Bukkit.getPlayer(uuid); + this.config.broadcastSpyMessage(message); + } - if (recipient == null) { - continue; + private static Player getPlayer(final String arg) { + final Player player = Bukkit.getPlayer(arg); + if (player != null) { + return player; + } + + final UUID uuid; + try { + uuid = UUID.fromString(arg); + } catch (final IllegalArgumentException ignored) { + return null; + } + + return Bukkit.getPlayer(uuid); + } + + private static Boolean getState(final String arg) { + switch (arg) { + case "on", "enable" -> { + return true; + } + case "off", "disable" -> { + return false; + } + default -> { + return null; } - recipient.sendMessage(message); } } } diff --git a/src/main/resources/plugin.yml b/src/main/resources/plugin.yml index f95cb20..9ba5483 100644 --- a/src/main/resources/plugin.yml +++ b/src/main/resources/plugin.yml @@ -1,13 +1,13 @@ name: CommandSpy main: pw.kaboom.commandspy.Main description: Plugin that allows you to spy on players' commands. -api-version: 1.13 +api-version: '1.20' version: master folia-supported: true commands: commandspy: - aliases: [c,cs,cspy] + aliases: [ c, cs, cspy ] description: Allows you to spy on players' commands - usage: /commandspy permission: commandspy.command + usage: '/<command> [player] [on|enable|off|disable]' diff --git a/suppressions.xml b/suppressions.xml index 8990963..30ced2c 100644 --- a/suppressions.xml +++ b/suppressions.xml @@ -5,4 +5,6 @@ <suppressions> <suppress checks="Javadoc" files="."/> -</suppressions> + <suppress checks="MagicNumber" files="."/> + <suppress checks="MethodLength" files="."/> +</suppressions> \ No newline at end of file