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