diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..8027a9a --- /dev/null +++ b/.gitignore @@ -0,0 +1,18 @@ +# Eclipse stuff +/.classpath +/.project +/.settings +/.checkstyle + +/bin/ + +/target/ + +.idea/ +.papermc/ + +dependency-reduced-pom.xml + +CachedPlayerHeads.iml + +CoordinatesObfuscator.iml diff --git a/README.md b/README.md new file mode 100644 index 0000000..1d7ed12 --- /dev/null +++ b/README.md @@ -0,0 +1,17 @@ +CachedPlayerHeads +============ + +**Spigot plugin that drops player heads when they die, without causing client-side stuttering** + +Features +-------- +- SkinsRestorer support +- Bungeecord support (just copy it in both spigot and bungeecord plugins folders) +- The only plugin of its kind that doesn't cause client-side stuttering when the heads are loaded +- Older heads remain the same also if a player changes skin. + +How it works +------------ +When a player is killed by another player in survival mode, the plugin downloads the skin and drops a head with that skin. + +The standard player heads plugins cause stuttering because the Minecraft client downloads the skins from the internet synchronously when it loads the chunks: this plugin instead downloads the skin on the server-side asynchronously and it applies the texture data itself on the head, instead of using the player name/uuid. diff --git a/pom.xml b/pom.xml new file mode 100644 index 0000000..1d322d3 --- /dev/null +++ b/pom.xml @@ -0,0 +1,143 @@ + + + 4.0.0 + org.warp + CachedPlayerHeads + 3.2.0 + CachedPlayerHeads + + + + src/main/resources + true + + plugin.yml + bungee.yml + + + + src/main/resources + false + + plugin.yml + bungee.yml + + + + + + + org.apache.maven.plugins + maven-compiler-plugin + 3.8.1 + + 8 + + + + org.apache.maven.plugins + maven-shade-plugin + 2.4.3 + + + package + + shade + + + + + *:skullcreator:* + + + + + + + + + + + bungeecord-repo + https://oss.sonatype.org/content/repositories/snapshots + + + papermc + https://papermc.io/repo/repository/maven-public/ + + + dmulloy2-repo + https://repo.dmulloy2.net/content/groups/public/ + + + spigotmc-repo + https://hub.spigotmc.org/nexus/content/repositories/snapshots/ + + + skullcreator-repo + https://dl.bintray.com/deanveloper/SkullCreator + + + codemc-snapshots + https://repo.codemc.org/repository/maven-snapshots/ + + + mc + https://libraries.minecraft.net/ + + + + + dev.dbassett + skullcreator + 3.0.0 + compile + + + com.comphenix.protocol + ProtocolLib + 4.6.0-SNAPSHOT + + + com.destroystokyo.paper + paper-api + 1.16.5-R0.1-SNAPSHOT + provided + + + + net.skinsrestorer + skinsrestorer + 14.0.0-SNAPSHOT + + + com.mojang + authlib + 1.5.21 + provided + + + net.md-5 + bungeecord-api + 1.16-R0.5-SNAPSHOT + jar + provided + + + net.md-5 + bungeecord-api + 1.16-R0.5-SNAPSHOT + javadoc + provided + + + http://blackspectrum.eu/ + \ No newline at end of file diff --git a/src/main/java/org/warp/cachedplayerheads/BukkitSkinStorage.java b/src/main/java/org/warp/cachedplayerheads/BukkitSkinStorage.java new file mode 100644 index 0000000..2eee66a --- /dev/null +++ b/src/main/java/org/warp/cachedplayerheads/BukkitSkinStorage.java @@ -0,0 +1,10 @@ +package org.warp.cachedplayerheads; + +import net.skinsrestorer.shared.storage.SkinStorage; + +public class BukkitSkinStorage extends SkinStorage { + + public BukkitSkinStorage() { + super(Platform.BUKKIT); + } +} diff --git a/src/main/java/org/warp/cachedplayerheads/BungeeAPI.java b/src/main/java/org/warp/cachedplayerheads/BungeeAPI.java new file mode 100644 index 0000000..c61c435 --- /dev/null +++ b/src/main/java/org/warp/cachedplayerheads/BungeeAPI.java @@ -0,0 +1,107 @@ +package org.warp.cachedplayerheads; + +import com.google.common.io.ByteArrayDataInput; +import com.google.common.io.ByteArrayDataOutput; +import com.google.common.io.ByteStreams; +import java.util.UUID; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.Executors; +import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.TimeoutException; +import org.bukkit.Bukkit; +import org.bukkit.configuration.ConfigurationSection; +import org.bukkit.entity.Player; +import org.bukkit.plugin.Plugin; +import org.bukkit.plugin.messaging.PluginMessageListener; +import org.jetbrains.annotations.Nullable; + +public class BungeeAPI implements PluginMessageListener { + + private final Plugin playerheads; + private final ConcurrentHashMap> requests = new ConcurrentHashMap<>(); + + private static final ScheduledExecutorService executor; + + static { + executor = Executors.newSingleThreadScheduledExecutor(); + } + + public BungeeAPI(Plugin playerheads) { + this.playerheads = playerheads; + + playerheads.getServer().getMessenger() + .registerIncomingPluginChannel(playerheads, "cachedplayerheads:channel", this); + playerheads.getServer().getMessenger() + .registerOutgoingPluginChannel(playerheads, "cachedplayerheads:channel"); + } + + private boolean checkIfBungee() { + @Nullable ConfigurationSection settings = playerheads + .getServer() + .spigot() + .getConfig() + .getConfigurationSection("settings"); + if (settings == null || !settings.getBoolean("settings.bungeecord")) { + playerheads.getLogger().severe("This server is not BungeeCord."); + return false; + } else { + playerheads.getLogger().severe("This server is BungeeCord."); + return true; + } + } + + public CompletableFuture getPlayerTexture(String playerName) { + ByteArrayDataOutput out = ByteStreams.newDataOutput(); + out.writeUTF("PlayerTextureRequest"); + UUID reqUUID = UUID.randomUUID(); + out.writeLong(reqUUID.getMostSignificantBits()); + out.writeLong(reqUUID.getLeastSignificantBits()); + out.writeUTF(playerName); + + + CompletableFuture futureResult = new CompletableFuture<>(); + requests.put(reqUUID, futureResult); + + try { + System.out.println("Asking player texture. Request: " + reqUUID); + Bukkit.getServer().sendPluginMessage(playerheads, "cachedplayerheads:channel", out.toByteArray()); + } catch (Exception ex) { + requests.remove(reqUUID); + throw ex; + } + + CompletableFuture timeoutFuture = new CompletableFuture<>(); + executor.schedule(() -> { + requests.remove(reqUUID); + timeoutFuture.completeExceptionally(new TimeoutException()); + }, 30, TimeUnit.SECONDS); + return CompletableFuture.anyOf(futureResult, timeoutFuture).thenApply(x -> (String) x); + } + + @Override + public void onPluginMessageReceived(String channel, Player player, byte[] message) { + if (!channel.equalsIgnoreCase("cachedplayerheads:channel")) { + return; + } + ByteArrayDataInput in = ByteStreams.newDataInput(message); + String subchannel = in.readUTF(); + if (subchannel.equals("PlayerTextureResponse")) { + long msb = in.readLong(); + long lsb = in.readLong(); + boolean hasString = in.readBoolean(); + String skinData = hasString ? in.readUTF() : null; + UUID reqUUID = new UUID(msb, lsb); + System.out.println("Received player texture. Request: " + reqUUID); + CompletableFuture req = requests.get(reqUUID); + if (req != null) { + req.complete(skinData); + } else { + System.out.println("Received response for unknown request: " + reqUUID); + } + } else { + System.err.println("Invalid subchannel: " + subchannel); + } + } +} diff --git a/src/main/java/org/warp/cachedplayerheads/CommandExecutor.java b/src/main/java/org/warp/cachedplayerheads/CommandExecutor.java new file mode 100644 index 0000000..9a0fba7 --- /dev/null +++ b/src/main/java/org/warp/cachedplayerheads/CommandExecutor.java @@ -0,0 +1,78 @@ +package org.warp.cachedplayerheads; + +import java.util.HashMap; +import java.util.concurrent.CompletableFuture; +import org.bukkit.Bukkit; +import org.bukkit.Location; +import org.bukkit.command.Command; +import org.bukkit.command.CommandSender; +import org.bukkit.entity.Player; +import org.bukkit.inventory.ItemStack; +import org.bukkit.inventory.meta.ItemMeta; + +public class CommandExecutor implements org.bukkit.command.CommandExecutor { + private Playerheads plugin; + + CommandExecutor(Playerheads plugin) { + this.plugin = plugin; + } + + public boolean onCommand(CommandSender sender, Command cmd, String commandLabel, String[] args) { + if (this.plugin.lockdown) + return true; + if (sender instanceof Player && this.plugin.givePermissionOption && !sender.hasPermission("vendettacraft.playerheads.give")) { + sender.sendMessage("[PlayerHeads] You do not have permission to perform this command."); + return true; + } + if (args.length < 3) + return false; + if (args[0].equalsIgnoreCase("give")) { + Player p = Bukkit.getPlayer(args[1]); + if (p == null) { + sender.sendMessage("[PlayerHeads] The field is not recognised."); + return true; + } + Player pp = Bukkit.getPlayer(args[2]); + if (pp == null) { + ReflectionHandler + .getSkullItem(args[2]) + .thenAccept(i -> displayNameFunction(i, p, sender)); + return true; + } + ReflectionHandler + .getTexture(pp) + .thenCompose(texture -> { + if (texture != null) { + return ReflectionHandler.getSkullItemFromTexture(pp.getName(), texture); + } else { + sender.sendMessage("[PlayerHeads] The field is not recognised."); + return CompletableFuture.completedFuture(null); + } + }) + .thenAccept(item -> { + if (item != null) { + displayNameFunction(item, p, sender); + } + }); + return true; + } + return true; + } + + private void givePlayerItem(Player player, ItemStack itemStack) { + ItemMeta meta = itemStack.getItemMeta(); + itemStack.setItemMeta(meta); + HashMap h = player.getInventory().addItem(itemStack); + Location loc = player.getLocation(); + for (ItemStack i : h.values()) + loc.getWorld().dropItem(loc, i); + } + + private void displayNameFunction(ItemStack i, Player p, CommandSender sender) { + if (i != null) { + givePlayerItem(p, i); + } else { + sender.sendMessage("[PlayerHeads] The SkinName/Base64 is not recognised."); + } + } +} diff --git a/src/main/java/org/warp/cachedplayerheads/EventListener.java b/src/main/java/org/warp/cachedplayerheads/EventListener.java new file mode 100644 index 0000000..2184223 --- /dev/null +++ b/src/main/java/org/warp/cachedplayerheads/EventListener.java @@ -0,0 +1,124 @@ +package org.warp.cachedplayerheads; + +import org.bukkit.entity.LivingEntity; +import org.bukkit.entity.Player; +import org.bukkit.event.EventHandler; +import org.bukkit.event.Listener; +import org.bukkit.event.entity.EntityDeathEvent; +import org.bukkit.event.player.PlayerLoginEvent; +import org.bukkit.plugin.Plugin; + +public class EventListener implements Listener { + private Playerheads plugin; + + EventListener(Playerheads plugin) { + plugin.getServer().getPluginManager().registerEvents(this, (Plugin)plugin); + this.plugin = plugin; + } + + @EventHandler + public void onDeath(EntityDeathEvent event) { + LivingEntity livingEntity = event.getEntity(); + boolean shouldDrop = false; + if (livingEntity instanceof Player) { + if (livingEntity.getKiller() != null) { + if (this.plugin.dropPlayerOption) + shouldDrop = true; + } else if (this.plugin.dropOtherDeathOption) { + shouldDrop = true; + } + if (shouldDrop) { + Player p = ((Player) livingEntity).getPlayer(); + if (p != null) { + ReflectionHandler + .getSkullItem(p) + .thenAccept(skullItem -> plugin.getServer().getScheduler().scheduleSyncDelayedTask(plugin, () -> { + p.getWorld().dropItem(p.getLocation(), skullItem); + })); + } + } + } + } + + @EventHandler + public void onLogin(PlayerLoginEvent event) { + /* + Player p = event.getPlayer(); + String id = p.getUniqueId().toString(); + String base64 = ReflectionHandler.getTexture(p); + if (base64 == null) { + Playerheads.playerDataConfig = (FileConfiguration)YamlConfiguration.loadConfiguration(Playerheads.playerDataFile); + Object obj = Playerheads.playerDataConfig.get(id); + if (obj != null) { + base64 = obj.toString(); + } + } + if (base64 != null) { + Playerheads.PlayerBase64.put(id, base64); + Playerheads.refreshPlayerDatafile(); + } + } + + @EventHandler + public void onExit(PlayerQuitEvent event) { + Playerheads.PlayerBase64.remove(event.getPlayer().getUniqueId().toString()); + Playerheads.refreshPlayerDatafile(); + } + + @EventHandler + public void onHeadPlace(BlockPlaceEvent event) { + ItemStack i = event.getItemInHand(); + Block b = event.getBlockPlaced(); + if (b.getType() == Material.LEGACY_SKULL && i.getType() == Material.LEGACY_SKULL_ITEM) { + String s = i.getItemMeta().getDisplayName(); + if (s == null) + s = "Head"; + Playerheads.HeadsPlaced.put(StringUtils.loc2str(b.getLocation()), s); + Playerheads.refreshHeadBlockDataFile(); + } + } + + @EventHandler + public void onHeadBreak(BlockBreakEvent event) { + Block b = event.getBlock(); + if (b.getType() == Material.PLAYER_HEAD || b.getType() == Material.PLAYER_WALL_HEAD) + for (String sloc : Playerheads.HeadsPlaced.keySet()) { + Location loc = StringUtils.str2loc(sloc); + Object blockState = b.getState(); + boolean isSkull = blockState instanceof Skull; + if (loc.equals(b.getLocation()) && isSkull) { + ItemStack i = ReflectionHandler.getSkullItem((Skull) blockState); + ItemMeta im = i.getItemMeta(); + im.setDisplayName(Playerheads.HeadsPlaced.get(sloc)); + i.setItemMeta(im); + if (event.getPlayer().getGameMode() != GameMode.CREATIVE) + loc.getWorld().dropItem(loc, i); + Playerheads.HeadsPlaced.remove(sloc); + Playerheads.refreshHeadBlockDataFile(); + event.setCancelled(true); + b.setType(Material.AIR); + } + } + } + + @EventHandler + public void onPistonMoveHead(BlockPistonExtendEvent event) { + for (Block b : event.getBlocks()) { + if (b.getType() == Material.LEGACY_SKULL) + for (String sloc : Playerheads.HeadsPlaced.keySet()) { + Location loc = StringUtils.str2loc(sloc); + GameProfile gp = ReflectionHandler.getGameProfile(b); + if (loc.equals(b.getLocation()) && gp != null) { + ItemStack i = ReflectionHandler.getSkull(gp); + ItemMeta im = i.getItemMeta(); + im.setDisplayName(Playerheads.HeadsPlaced.get(sloc)); + i.setItemMeta(im); + loc.getWorld().dropItem(loc, i); + Playerheads.HeadsPlaced.remove(sloc); + Playerheads.refreshHeadBlockDataFile(); + b.setType(Material.AIR, true); + } + } + }*/ + } +} diff --git a/src/main/java/org/warp/cachedplayerheads/MojangAPI.java b/src/main/java/org/warp/cachedplayerheads/MojangAPI.java new file mode 100644 index 0000000..0d64285 --- /dev/null +++ b/src/main/java/org/warp/cachedplayerheads/MojangAPI.java @@ -0,0 +1,209 @@ +// +// Source code recreated from a .class file by IntelliJ IDEA +// (powered by FernFlower decompiler) +// + +package org.warp.cachedplayerheads; + +import com.google.gson.Gson; +import com.google.gson.JsonObject; +import java.io.BufferedReader; +import java.io.IOException; +import java.io.InputStreamReader; +import java.net.HttpURLConnection; +import java.net.URL; +import net.skinsrestorer.shared.exception.SkinRequestException; +import net.skinsrestorer.shared.storage.Locale; +import net.skinsrestorer.shared.storage.SkinStorage; +import net.skinsrestorer.shared.utils.MetricsCounter; +import net.skinsrestorer.shared.utils.Property; + +public class MojangAPI { + private static final String UUID_URL = "https://api.minetools.eu/uuid/%name%"; + private static final String UUID_URL_MOJANG = "https://api.mojang.com/users/profiles/minecraft/%name%"; + private static final String UUID_URL_BACKUP = "https://api.ashcon.app/mojang/v2/user/%name%"; + private static final String SKIN_URL = "https://api.minetools.eu/profile/%uuid%"; + private static final String SKIN_URL_MOJANG = "https://sessionserver.mojang.com/session/minecraft/profile/%uuid%?unsigned=false"; + private static final String SKIN_URL_BACKUP = "https://api.ashcon.app/mojang/v2/user/%uuid%"; + private SkinStorage skinStorage; + + public MojangAPI() { + } + + public Object getSkinProperty(String uuid) { + return this.getSkinProperty(uuid, true); + } + + public Object getSkinProperty(String uuid, boolean tryNext) { + try { + String output = this.readURL("https://api.minetools.eu/profile/%uuid%".replace("%uuid%", uuid)); + JsonObject obj = (JsonObject)(new Gson()).fromJson(output, JsonObject.class); + Property property = new Property(); + if (obj.has("raw")) { + JsonObject raw = obj.getAsJsonObject("raw"); + if (raw.has("status") && raw.get("status").getAsString().equalsIgnoreCase("ERR")) { + return this.getSkinPropertyMojang(uuid); + } + + if (property.valuesFromJson(raw)) { + return this.getSkinStorage().createProperty("textures", property.getValue(), property.getSignature()); + } + } + } catch (Exception var7) { + if (tryNext) { + return this.getSkinPropertyMojang(uuid); + } + } + + return null; + } + + public Object getSkinPropertyMojang(String uuid) { + return this.getSkinPropertyMojang(uuid, true); + } + + public Object getSkinPropertyMojang(String uuid, boolean tryNext) { + if (tryNext) { + System.out.println("Trying Mojang API to get skin property for " + uuid + "."); + } + + try { + String output = this.readURL("https://sessionserver.mojang.com/session/minecraft/profile/%uuid%?unsigned=false".replace("%uuid%", uuid)); + JsonObject obj = (JsonObject)(new Gson()).fromJson(output, JsonObject.class); + Property property = new Property(); + if (obj.has("properties") && property.valuesFromJson(obj)) { + return this.getSkinStorage().createProperty("textures", property.getValue(), property.getSignature()); + } + } catch (Exception var6) { + if (tryNext) { + return this.getSkinPropertyBackup(uuid); + } + } + + return null; + } + + public Object getSkinPropertyBackup(String uuid) { + return this.getSkinPropertyBackup(uuid, true); + } + + public Object getSkinPropertyBackup(String uuid, boolean tryNext) { + if (tryNext) { + System.out.println("Trying backup API to get skin property for " + uuid + "."); + } + + try { + String output = this.readURL("https://api.ashcon.app/mojang/v2/user/%uuid%".replace("%uuid%", uuid), 10000); + JsonObject obj = (JsonObject)(new Gson()).fromJson(output, JsonObject.class); + JsonObject textures = obj.get("textures").getAsJsonObject(); + JsonObject rawTextures = textures.get("raw").getAsJsonObject(); + Property property = new Property(); + property.setValue(rawTextures.get("value").getAsString()); + property.setSignature(rawTextures.get("signature").getAsString()); + return this.getSkinStorage().createProperty("textures", property.getValue(), property.getSignature()); + } catch (Exception var8) { + System.err.println("Failed to get skin property from backup API. (" + uuid + ")"); + return null; + } + } + + public String getUUID(String name, boolean tryNext) throws SkinRequestException { + try { + String output = this.readURL("https://api.minetools.eu/uuid/%name%".replace("%name%", name)); + JsonObject obj = (JsonObject)(new Gson()).fromJson(output, JsonObject.class); + if (obj.has("status") && obj.get("status").getAsString().equalsIgnoreCase("ERR")) { + return this.getUUIDMojang(name); + } else if (obj.get("id") == null) { + throw new SkinRequestException(Locale.NOT_PREMIUM); + } else { + return obj.get("id").getAsString(); + } + } catch (IOException var5) { + return tryNext ? this.getUUIDMojang(name) : null; + } + } + + public String getUUIDMojang(String name) throws SkinRequestException { + return this.getUUIDMojang(name, true); + } + + public String getUUIDMojang(String name, boolean tryNext) throws SkinRequestException { + if (tryNext) { + System.out.println("Trying Mojang API to get UUID for player " + name + "."); + } + + try { + String output = this.readURL("https://api.mojang.com/users/profiles/minecraft/%name%".replace("%name%", name)); + if (output.isEmpty()) { + throw new SkinRequestException(Locale.NOT_PREMIUM); + } else { + JsonObject obj = (JsonObject)(new Gson()).fromJson(output, JsonObject.class); + if (obj.has("error")) { + return tryNext ? this.getUUIDBackup(name) : null; + } else { + return obj.get("id").getAsString(); + } + } + } catch (IOException var5) { + return tryNext ? this.getUUIDBackup(name) : null; + } + } + + public String getUUIDBackup(String name) throws SkinRequestException { + return this.getUUIDBackup(name, true); + } + + public String getUUIDBackup(String name, boolean tryNext) throws SkinRequestException { + if (tryNext) { + System.out.println("Trying backup API to get UUID for player " + name + "."); + } + + try { + String output = this.readURL("https://api.ashcon.app/mojang/v2/user/%name%".replace("%name%", name), 10000); + JsonObject obj = (JsonObject)(new Gson()).fromJson(output, JsonObject.class); + if (obj.has("code")) { + if (obj.get("error").getAsString().equalsIgnoreCase("Not Found")) { + throw new SkinRequestException(Locale.NOT_PREMIUM); + } else { + throw new SkinRequestException(Locale.ALT_API_FAILED); + } + } else { + return obj.get("uuid").getAsString().replace("-", ""); + } + } catch (IOException var5) { + throw new SkinRequestException(Locale.NOT_PREMIUM); + } + } + + private String readURL(String url) throws IOException { + return this.readURL(url, 5000); + } + + private String readURL(String url, int timeout) throws IOException { + HttpURLConnection con = (HttpURLConnection)(new URL(url)).openConnection(); + MetricsCounter.incrAPI(url); + con.setRequestMethod("GET"); + con.setRequestProperty("User-Agent", "SkinsRestorer"); + con.setConnectTimeout(timeout); + con.setReadTimeout(timeout); + con.setDoOutput(true); + StringBuilder output = new StringBuilder(); + BufferedReader in = new BufferedReader(new InputStreamReader(con.getInputStream())); + + String line; + while((line = in.readLine()) != null) { + output.append(line); + } + + in.close(); + return output.toString(); + } + + public SkinStorage getSkinStorage() { + return this.skinStorage; + } + + public void setSkinStorage(final SkinStorage skinStorage) { + this.skinStorage = skinStorage; + } +} diff --git a/src/main/java/org/warp/cachedplayerheads/Playerheads.java b/src/main/java/org/warp/cachedplayerheads/Playerheads.java new file mode 100644 index 0000000..5355684 --- /dev/null +++ b/src/main/java/org/warp/cachedplayerheads/Playerheads.java @@ -0,0 +1,150 @@ +package org.warp.cachedplayerheads; + +import java.io.File; +import java.io.IOException; +import java.util.concurrent.ConcurrentHashMap; +import net.skinsrestorer.api.SkinsRestorerAPI; +import net.skinsrestorer.bukkit.SkinsRestorer; +import org.bukkit.command.PluginCommand; +import org.bukkit.configuration.file.FileConfiguration; +import org.bukkit.configuration.file.YamlConfiguration; +import org.bukkit.plugin.java.JavaPlugin; +import org.jetbrains.annotations.Nullable; + +public final class Playerheads extends JavaPlugin { + + private FileConfiguration config = getConfig(); + + static ConcurrentHashMap PlayerBase64 = new ConcurrentHashMap<>(); + + static ConcurrentHashMap HeadsPlaced = new ConcurrentHashMap<>(); + + // Setting definition + static SkinsRestorer skinsRestorer; + + @Nullable + static SkinsRestorerAPI skinsRestorerAPI; + + static MojangAPI mojangAPI; + + static BungeeAPI bungeeAPI; + + static FileConfiguration playerDataConfig; + + static File playerDataFile; + + private static File headBlockDataFile; + + boolean dropPlayerOption; + + boolean dropOtherDeathOption; + + boolean givePermissionOption; + + boolean lockdown; + + public void onEnable() { + + //Connecting to the main SkinsRestorer API + skinsRestorer = JavaPlugin.getPlugin(SkinsRestorer.class); + + // Connecting to Bukkit API for applying the skin + skinsRestorerAPI = SkinsRestorerAPI.getApi(); + + mojangAPI = new MojangAPI(); + mojangAPI.setSkinStorage(new BukkitSkinStorage()); + + bungeeAPI = new BungeeAPI(this); + + PluginCommand cmd = getCommand("playerheads"); + cmd.setExecutor(new CommandExecutor(this)); + cmd.setTabCompleter(new TabCompleter()); + new EventListener(this); + createPlayerData(); + createHeadBlockData(); + reloadHeadBlockDataToHashMap(); + this.config.addDefault("Should player heads drop when a player is killed by another player?", Boolean.valueOf(true)); + this.config.addDefault("Should player heads drop whenever a player gets killed? (excluding being killed by players)", Boolean.valueOf(false)); + this.config.addDefault("Do players need the give permission to use /playerheads give command?)", Boolean.valueOf(true)); + this.config.options().copyDefaults(true); + saveConfig(); + this.dropPlayerOption = this.config.getBoolean("Should player heads drop when a player is killed by another player?"); + this.dropOtherDeathOption = this.config.getBoolean("Should player heads drop whenever a player gets killed? (excluding being killed by players)"); + this.givePermissionOption = this.config.getBoolean("Do players need the give permission to use /playerheads give command?)"); + + getLogger().info("Player Heads Plugin has been enabled"); + } + + public void onDisable() { + getLogger().info("Player Heads Plugin has been disabled"); + } + + private static synchronized void createPlayerData() { + try { + File folder = new File("plugins/CachedPlayerHeads"); + if (!folder.exists()) + folder.mkdirs(); + playerDataFile = new File(folder, "PlayerData.yml"); + if (!playerDataFile.exists()) + playerDataFile.createNewFile(); + } catch (Exception e) { + e.printStackTrace(); + } + } + + private static synchronized void createHeadBlockData() { + try { + File folder = new File("plugins/CachedPlayerHeads"); + if (!folder.exists()) + folder.mkdirs(); + headBlockDataFile = new File(folder, "HeadBlockData.yml"); + if (!headBlockDataFile.exists()) + headBlockDataFile.createNewFile(); + } catch (Exception e) { + e.printStackTrace(); + } + } + + static synchronized void refreshPlayerDatafile() { + playerDataFile.delete(); + createPlayerData(); + playerDataConfig = (FileConfiguration)YamlConfiguration.loadConfiguration(playerDataFile); + for (String s : PlayerBase64.keySet()) { + String gt = PlayerBase64.get(s); + playerDataConfig.set(s, gt); + } + try { + playerDataConfig.save(playerDataFile); + } catch (IOException e) { + e.printStackTrace(); + } + } + + static synchronized void refreshHeadBlockDataFile() { + headBlockDataFile.delete(); + createPlayerData(); + YamlConfiguration yamlConfiguration = YamlConfiguration.loadConfiguration(headBlockDataFile); + for (String s : HeadsPlaced.keySet()) { + String gt = HeadsPlaced.get(s); + yamlConfiguration.set(s, gt); + } + try { + yamlConfiguration.save(headBlockDataFile); + } catch (IOException e) { + e.printStackTrace(); + } + } + + private static synchronized void reloadHeadBlockDataToHashMap() { + YamlConfiguration yamlConfiguration = YamlConfiguration.loadConfiguration(headBlockDataFile); + for (String s : yamlConfiguration.getKeys(false)) { + String gt = yamlConfiguration.getString(s); + HeadsPlaced.put(s, gt); + } + try { + yamlConfiguration.save(headBlockDataFile); + } catch (IOException e) { + e.printStackTrace(); + } + } +} diff --git a/src/main/java/org/warp/cachedplayerheads/ReflectionHandler.java b/src/main/java/org/warp/cachedplayerheads/ReflectionHandler.java new file mode 100644 index 0000000..b0ecd13 --- /dev/null +++ b/src/main/java/org/warp/cachedplayerheads/ReflectionHandler.java @@ -0,0 +1,107 @@ +package org.warp.cachedplayerheads; + +import com.destroystokyo.paper.profile.PlayerProfile; +import com.mojang.authlib.properties.Property; +import dev.dbassett.skullcreator.SkullCreator; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.function.Function; +import net.skinsrestorer.shared.exception.SkinRequestException; +import org.bukkit.entity.Player; +import org.bukkit.inventory.ItemStack; +import org.bukkit.inventory.meta.ItemMeta; +import org.bukkit.inventory.meta.SkullMeta; + +class ReflectionHandler { + + private static final ExecutorService blockingTextures = Executors.newFixedThreadPool(4); + + static CompletableFuture getSkullItem(Player player) { + return ReflectionHandler + .getTexture(player) + .thenComposeAsync(texture -> getSkullItemFromTexture(player.getName(), texture), blockingTextures); + } + static CompletableFuture getSkullItem(Player skinPlayer, Player namePlayer) { + return ReflectionHandler + .getTexture(skinPlayer) + .thenComposeAsync(texture -> getSkullItemFromTexture(namePlayer.getName(), texture), blockingTextures); + } + + public static CompletableFuture getSkullItem(String playerName) { + return ReflectionHandler + .getTexture(playerName) + .thenComposeAsync(texture -> getSkullItemFromTexture(playerName, texture), blockingTextures); + } + + public static CompletableFuture getSkullItem(String skinPlayerName, String namePlayerName) { + return ReflectionHandler + .getTexture(skinPlayerName) + .thenComposeAsync(texture -> getSkullItemFromTexture(namePlayerName, texture), blockingTextures); + } + + public static CompletableFuture getSkullItemFromTexture(String playerName, String texture) { + return CompletableFuture + .supplyAsync(() -> { + ItemStack item; + if (texture == null) { + // hardcoded steve skin + item = SkullCreator.itemFromBase64("eyJ0aW1lc3RhbXAiOjE1MDA4MzU1ODY5ODcsInByb2ZpbGVJZCI6ImMxZWQ5N2Q0ZDE2NzQyYzI5OGI1ODFiZmRiODhhMjFmIiwicHJvZmlsZU5hbWUiOiJ5b2xvX21hdGlzIiwic2lnbmF0dXJlUmVxdWlyZWQiOnRydWUsInRleHR1cmVzIjp7IlNLSU4iOnsidXJsIjoiaHR0cDovL3RleHR1cmVzLm1pbmVjcmFmdC5uZXQvdGV4dHVyZS9jYjNjY2NkOTUzNjVjNWQ4NTY0NGE1MzZlNjliNGJlNThlYmZiZjE2ZjQzY2Y1NjE3ODZiNzRkYTJiOGVlYiJ9fX0"); + } else { + item = SkullCreator.itemFromBase64(texture); + } + SkullMeta skullMeta = (SkullMeta) item.getItemMeta(); + PlayerProfile playerProfile = skullMeta.getPlayerProfile(); + if (playerProfile != null) { + playerProfile.setName(playerName); + skullMeta.setPlayerProfile(playerProfile); + } + item.setItemMeta(skullMeta); + ItemMeta itemMeta = item.getItemMeta(); + item.setItemMeta(itemMeta); + return item; + }, blockingTextures); + } + + static CompletableFuture getTexture(Player player) { + return getTexture(player.getName()); + } + + static CompletableFuture getTexture(String playerName) { + try { + Function> downloader = texture -> CompletableFuture + .supplyAsync(() -> { + if (texture != null) { + return texture; + } + // Try to download online skin as fallback + String uuid = null; + try { + uuid = Playerheads.mojangAPI.getUUID(playerName, true); + } catch (SkinRequestException e) { + return null; + } + Property prop = ((Property) Playerheads.mojangAPI.getSkinProperty(uuid)); + if (prop != null) { + return prop.getValue(); + } + return null; + }, blockingTextures); + + if (Playerheads.skinsRestorerAPI != null) { + return CompletableFuture + .supplyAsync(() -> + SkinsRestorerAPIUtils.getSkinData(Playerheads.skinsRestorerAPI, playerName), blockingTextures) + .thenComposeAsync(downloader, blockingTextures); + } else if (Playerheads.skinsRestorer.isBungeeEnabled()) { + return Playerheads.bungeeAPI.getPlayerTexture(playerName) + .thenComposeAsync(downloader, blockingTextures); + } else { + return downloader.apply(null); + } + } catch (Exception ex) { + ex.printStackTrace(); + return CompletableFuture.completedFuture(null); + } + } +} diff --git a/src/main/java/org/warp/cachedplayerheads/SkinsRestorerAPIUtils.java b/src/main/java/org/warp/cachedplayerheads/SkinsRestorerAPIUtils.java new file mode 100644 index 0000000..e924b1d --- /dev/null +++ b/src/main/java/org/warp/cachedplayerheads/SkinsRestorerAPIUtils.java @@ -0,0 +1,27 @@ +package org.warp.cachedplayerheads; + +import java.lang.reflect.InvocationTargetException; +import java.lang.reflect.Method; +import net.skinsrestorer.api.SkinsRestorerAPI; + +public class SkinsRestorerAPIUtils { + + public static String getSkinData(SkinsRestorerAPI skinsRestorerAPI, String playerName) { + if (skinsRestorerAPI == null) return null; + // Use SkinRestorer's skin username if the player has set it + String transformedPlayerName = skinsRestorerAPI.getSkinName(playerName); + if (transformedPlayerName == null) { + transformedPlayerName = playerName; + } + Object skinData = skinsRestorerAPI.getSkinData(transformedPlayerName); + if (skinData != null) { + try { + Method getValueMethod = skinData.getClass().getMethod("getValue"); + return (String) getValueMethod.invoke(skinData); + } catch (NoSuchMethodException | InvocationTargetException | IllegalAccessException e) { + throw new RuntimeException(e); + } + } + return null; + } +} diff --git a/src/main/java/org/warp/cachedplayerheads/StringUtils.java b/src/main/java/org/warp/cachedplayerheads/StringUtils.java new file mode 100644 index 0000000..6bbaef2 --- /dev/null +++ b/src/main/java/org/warp/cachedplayerheads/StringUtils.java @@ -0,0 +1,15 @@ +package org.warp.cachedplayerheads; + +import org.bukkit.Bukkit; +import org.bukkit.Location; + +class StringUtils { + static Location str2loc(String str) { + String[] str2loc = str.split(":"); + return new Location(Bukkit.getServer().getWorld(str2loc[0]), Double.parseDouble(str2loc[1]), Double.parseDouble(str2loc[2]), Double.parseDouble(str2loc[3])); + } + + static String loc2str(Location loc) { + return loc.getWorld().getName() + ":" + loc.getBlockX() + ":" + loc.getBlockY() + ":" + loc.getBlockZ(); + } +} diff --git a/src/main/java/org/warp/cachedplayerheads/TabCompleter.java b/src/main/java/org/warp/cachedplayerheads/TabCompleter.java new file mode 100644 index 0000000..5b74ff4 --- /dev/null +++ b/src/main/java/org/warp/cachedplayerheads/TabCompleter.java @@ -0,0 +1,24 @@ +package org.warp.cachedplayerheads; + +import java.util.ArrayList; +import java.util.List; +import org.bukkit.command.Command; +import org.bukkit.command.CommandSender; + +public class TabCompleter implements org.bukkit.command.TabCompleter { + public List onTabComplete(CommandSender commandSender, Command cmd, String commandLabel, String[] args) { + if (cmd.getName().equalsIgnoreCase("playerheads")) { + int argsLength = args.length; + if (argsLength == 1) { + ArrayList listFinal = new ArrayList<>(); + String al = args[0].toLowerCase(); + if ("give".startsWith(al)) + listFinal.add("give"); + return listFinal; + } + if (argsLength > 3) + return new ArrayList<>(); + } + return null; + } +} diff --git a/src/main/java/org/warp/cachedplayerheads/bungee/BungeeAPI.java b/src/main/java/org/warp/cachedplayerheads/bungee/BungeeAPI.java new file mode 100644 index 0000000..c76dd61 --- /dev/null +++ b/src/main/java/org/warp/cachedplayerheads/bungee/BungeeAPI.java @@ -0,0 +1,117 @@ +package org.warp.cachedplayerheads.bungee; + +import com.google.common.io.ByteArrayDataInput; +import com.google.common.io.ByteArrayDataOutput; +import com.google.common.io.ByteStreams; +import java.util.UUID; +import net.md_5.bungee.api.connection.ProxiedPlayer; +import net.md_5.bungee.api.connection.Server; +import net.md_5.bungee.api.event.PluginMessageEvent; +import net.md_5.bungee.api.plugin.Listener; +import net.md_5.bungee.api.plugin.Plugin; +import net.md_5.bungee.event.EventHandler; +import net.skinsrestorer.api.SkinsRestorerAPI; +import org.warp.cachedplayerheads.SkinsRestorerAPIUtils; + +public class BungeeAPI implements Listener { + + private final Plugin playerheads; + + private final SkinsRestorerAPI skinsRestorerAPI; + + public BungeeAPI(SkinsRestorerAPI skinsRestorerAPI, Plugin playerheads) { + this.skinsRestorerAPI = skinsRestorerAPI; + this.playerheads = playerheads; + playerheads.getProxy().getPluginManager().registerListener(playerheads, this); + } + + public void sendSkinData(Sender sender, UUID reqUUID, String skinData) { + ByteArrayDataOutput out = ByteStreams.newDataOutput(); + out.writeUTF("PlayerTextureResponse"); + out.writeLong(reqUUID.getMostSignificantBits()); + out.writeLong(reqUUID.getLeastSignificantBits()); + if (skinData == null) { + out.writeBoolean(false); + } else { + out.writeBoolean(true); + out.writeUTF(skinData); + } + + // we send the data to the server + // using ServerInfo the packet is being queued if there are no players in the server + // using only the server to send data the packet will be lost if no players are in it + System.out.println("Replying skin request: " + reqUUID); + sender.sendData("cachedplayerheads:channel", out.toByteArray()); + } + + @EventHandler + public void on(PluginMessageEvent event) { + if (!event.getTag().equals("cachedplayerheads:channel")) { + return; + } + ByteArrayDataInput in = ByteStreams.newDataInput(event.getData()); + String subChannel = in.readUTF(); + long msb = in.readLong(); + long lsb = in.readLong(); + UUID reqUUID = new UUID(msb, lsb); + String playerName = in.readUTF(); + System.out.println("Received skin request: " + reqUUID); + if (subChannel.equalsIgnoreCase("PlayerTextureRequest")) { + Sender sender; + // the receiver is a server when the proxy talks to a server + if (event.getReceiver() instanceof Server) { + Server receiver = (Server) event.getReceiver(); + sender = new ServerSender(receiver); + } + // the receiver is a ProxiedPlayer when a server talks to the proxy + else if (event.getReceiver() instanceof ProxiedPlayer) { + ProxiedPlayer receiver = (ProxiedPlayer) event.getReceiver(); + sender = new ProxiedPlayerSender(receiver); + } else { + System.err.println("Invalid receiver type: " + event.getReceiver()); + sender = null; + } + if (sender != null) { + playerheads.getProxy().getScheduler().runAsync(playerheads, () -> { + String skinData = SkinsRestorerAPIUtils.getSkinData(skinsRestorerAPI, playerName); + sendSkinData(sender, reqUUID, skinData); + }); + } + } else { + System.err.println("Invalid subchannel: " + subChannel); + } + } + + private interface Sender { + + void sendData(String channel, byte[] data); + } + + private static class ServerSender implements Sender { + + private final Server server; + + public ServerSender(Server server) { + this.server = server; + } + + @Override + public void sendData(String channel, byte[] data) { + server.sendData(channel, data); + } + } + + private static class ProxiedPlayerSender implements Sender { + + private final ProxiedPlayer proxiedPlayer; + + public ProxiedPlayerSender(ProxiedPlayer proxiedPlayer) { + this.proxiedPlayer = proxiedPlayer; + } + + @Override + public void sendData(String channel, byte[] data) { + proxiedPlayer.getServer().sendData(channel, data); + } + } +} diff --git a/src/main/java/org/warp/cachedplayerheads/bungee/Playerheads.java b/src/main/java/org/warp/cachedplayerheads/bungee/Playerheads.java new file mode 100644 index 0000000..cec6c13 --- /dev/null +++ b/src/main/java/org/warp/cachedplayerheads/bungee/Playerheads.java @@ -0,0 +1,21 @@ +package org.warp.cachedplayerheads.bungee; + +import net.md_5.bungee.api.plugin.Plugin; +import net.skinsrestorer.api.SkinsRestorerAPI; + +public class Playerheads extends Plugin { + + + // Setting definition + static SkinsRestorerAPI skinsRestorerAPI; + + private BungeeAPI api; + + @Override + public void onEnable() { + skinsRestorerAPI = SkinsRestorerAPI.getApi(); + + getProxy().registerChannel("cachedplayerheads:channel"); + api = new BungeeAPI(skinsRestorerAPI, this); + } +} diff --git a/src/main/resources/bungee.yml b/src/main/resources/bungee.yml new file mode 100644 index 0000000..e1197a1 --- /dev/null +++ b/src/main/resources/bungee.yml @@ -0,0 +1,4 @@ +name: ${project.name} +main: ${project.groupId}.cachedplayerheads.bungee.Playerheads +version: 2.1 +author: Cavallium diff --git a/src/main/resources/plugin.yml b/src/main/resources/plugin.yml new file mode 100644 index 0000000..f1fb5c7 --- /dev/null +++ b/src/main/resources/plugin.yml @@ -0,0 +1,16 @@ +name: ${project.name} +main: ${project.groupId}.cachedplayerheads.Playerheads +api-version: 1.16 +version: 2.1 +author: Cavallium +load: POSTWORLD +depend: + - SkinsRestorer +commands: + playerheads: + description: Allows you to give players, player heads. + usage: "Usage:\n/playerheads give " +permissions: + vendettacraft.movableblocks.give: + description: Allows you to give players, player heads via the give command. + default: op \ No newline at end of file diff --git a/start.sh b/start.sh new file mode 100644 index 0000000..637dee2 --- /dev/null +++ b/start.sh @@ -0,0 +1,29 @@ +WORKSPACE=".papermc" +MC_VERSION="1.16.4" +PAPER_BUILD="latest" + +## ============== DO NOT EDIT THE SCRIPT BELOW UNLESS YOU KNOW WHAT YOU ARE DOING ============== ## + +#cd || exit # Moving to the user folder or exit if it fails. + +[ -d $WORKSPACE ] || mkdir $WORKSPACE +[ -d $WORKSPACE ] || mkdir $WORKSPACE/plugins +cp target/CachedPlayerHeads-*.jar $WORKSPACE/plugins || exit + +# Checking the workspace folder availability. +if [ ! -d $WORKSPACE ]; then + # Create the workspace folder. + mkdir $WORKSPACE +fi + +cd $WORKSPACE || exit # Moving to the workspace fodler or exit if it fails. + +# Check for the paper executable +PAPER_JAR="paper-$MC_VERSION-$PAPER_BUILD.jar" +PAPER_LNK="https://papermc.io/api/v1/paper/$MC_VERSION/$PAPER_BUILD/download" + +if [ ! -f $PAPER_JAR ]; then + wget -O $PAPER_JAR $PAPER_LNK +fi + +/usr/lib/jvm/java-1.8.0-openjdk-amd64/bin/java -agentlib:jdwp=transport=dt_socket,server=y,suspend=n,address=5005 -jar $PAPER_JAR nogui