diff --git a/pom.xml b/pom.xml index 48341179e..6151d304c 100644 --- a/pom.xml +++ b/pom.xml @@ -184,6 +184,13 @@ + + + org.spigotmc + spigot + ${spigot.version} + provided + org.spigotmc diff --git a/src/main/java/world/bentobox/bentobox/Settings.java b/src/main/java/world/bentobox/bentobox/Settings.java index 59bae00fa..a00be87a4 100644 --- a/src/main/java/world/bentobox/bentobox/Settings.java +++ b/src/main/java/world/bentobox/bentobox/Settings.java @@ -126,6 +126,12 @@ public class Settings implements ConfigObject { @ConfigEntry(path = "panel.filler-material", since = "1.14.0") private Material panelFillerMaterial = Material.LIGHT_BLUE_STAINED_GLASS_PANE; + @ConfigComment("Defines how long player skin texture link is stored into local cache before it is requested again.") + @ConfigComment("Defined value is in the minutes.") + @ConfigComment("Value 0 will not clear cache until server restart.") + @ConfigEntry(path = "panel.head-cache-time", since = "1.14.1") + private long playerHeadCacheTime = 60; + /* * Logs */ @@ -714,4 +720,27 @@ public class Settings implements ConfigObject { public void setPanelFillerMaterial(Material panelFillerMaterial) { this.panelFillerMaterial = panelFillerMaterial; } + + + /** + * Method Settings#getPlayerHeadCacheTime returns the playerHeadCacheTime of this object. + * + * @return the playerHeadCacheTime (type long) of this object. + * @since 1.14.1 + */ + public long getPlayerHeadCacheTime() + { + return playerHeadCacheTime; + } + + + /** + * Method Settings#setPlayerHeadCacheTime sets new value for the playerHeadCacheTime of this object. + * @param playerHeadCacheTime new value for this object. + * @since 1.14.1 + */ + public void setPlayerHeadCacheTime(long playerHeadCacheTime) + { + this.playerHeadCacheTime = playerHeadCacheTime; + } } diff --git a/src/main/java/world/bentobox/bentobox/api/panels/PanelItem.java b/src/main/java/world/bentobox/bentobox/api/panels/PanelItem.java index 5d85ace1a..1fe59e9ff 100644 --- a/src/main/java/world/bentobox/bentobox/api/panels/PanelItem.java +++ b/src/main/java/world/bentobox/bentobox/api/panels/PanelItem.java @@ -161,7 +161,10 @@ public class PanelItem { } public void setHead(ItemStack itemStack) { + // update amount before replacing. + itemStack.setAmount(this.icon.getAmount()); this.icon = itemStack; + // Get the meta meta = icon.getItemMeta(); if (meta != null) { diff --git a/src/main/java/world/bentobox/bentobox/util/heads/HeadCache.java b/src/main/java/world/bentobox/bentobox/util/heads/HeadCache.java index 346c09963..474d89032 100644 --- a/src/main/java/world/bentobox/bentobox/util/heads/HeadCache.java +++ b/src/main/java/world/bentobox/bentobox/util/heads/HeadCache.java @@ -1,35 +1,157 @@ package world.bentobox.bentobox.util.heads; +import org.bukkit.Material; import org.bukkit.inventory.ItemStack; +import org.bukkit.inventory.meta.ItemMeta; +import java.lang.reflect.Field; +import java.util.UUID; +import com.mojang.authlib.GameProfile; +import com.mojang.authlib.properties.Property; + +import world.bentobox.bentobox.BentoBox; + /** - * @since 1.14.0 - * @author tastybento + * This would allow to implement changeable player head for server owner. + * @since 1.14.1 + * @author tastybento, BONNe1704 */ -public class HeadCache { - private final ItemStack head; - private final long timestamp; +public class HeadCache +{ + // --------------------------------------------------------------------- + // Section: Variables + // --------------------------------------------------------------------- + /** - * @param head - head ItemStack - * @param timestamp - timestamp when made + * Username for cached head. */ - public HeadCache(ItemStack head, long timestamp) { - super(); - this.head = head; + private final String userName; + + /** + * Userid for cached head. + */ + private final UUID userId; + + /** + * Base64 Encoded texture link to given player skin. + */ + public final String encodedTextureLink; + + /** + * Time when head was created. Setting it to 0 will result in keeping head in cache + * for ever. + */ + private final long timestamp; + + + // --------------------------------------------------------------------- + // Section: Constructors + // --------------------------------------------------------------------- + + + /** + * Constructor HeadCache creates a new HeadCache instance. + * + * @param userName of type String + * @param userId of type String + * @param encodedTextureLink of type String + */ + public HeadCache(String userName, UUID userId, String encodedTextureLink) + { + this(userName, userId, encodedTextureLink, System.currentTimeMillis()); + } + + + /** + * Constructor HeadCache creates a new HeadCache instance. + * + * @param userName of type String + * @param userId of type UUID + * @param encodedTextureLink of type String + * @param timestamp of type long + */ + public HeadCache(String userName, + UUID userId, + String encodedTextureLink, + long timestamp) + { + this.userName = userName; + this.encodedTextureLink = encodedTextureLink; + this.userId = userId; this.timestamp = timestamp; } + + + // --------------------------------------------------------------------- + // Section: Methods + // --------------------------------------------------------------------- + + /** - * @return the head + * Returns a new Player head with a cached texture. Be AWARE, usage does not use clone + * method. If for some reason item stack is stored directly, then use clone in return + * :) + * + * @return an ItemStack of the custom head. */ - public ItemStack getHead() { - return head; + public ItemStack getPlayerHead() + { + ItemStack item = new ItemStack(Material.PLAYER_HEAD); + ItemMeta meta = item.getItemMeta(); + + // Set correct Skull texture + if (meta != null && this.encodedTextureLink != null && !this.encodedTextureLink.isEmpty()) + { + GameProfile profile = new GameProfile(this.userId, this.userName); + profile.getProperties().put("textures", + new Property("textures", this.encodedTextureLink)); + + try + { + Field profileField = meta.getClass().getDeclaredField("profile"); + profileField.setAccessible(true); + profileField.set(meta, profile); + item.setItemMeta(meta); + } + catch (NoSuchFieldException | IllegalArgumentException | IllegalAccessException e) + { + BentoBox.getInstance().log("Error while creating Skull Icon"); + } + } + + return item; } + + /** - * @return the timestamp + * Method HeadCache#getUserName returns the userName of this object. + * + * @return the userName (type String) of this object. */ - public long getTimestamp() { + public String getUserName() + { + return this.userName; + } + + + /** + * Method HeadCache#getTimestamp returns the timestamp of this object. + * + * @return the timestamp (type long) of this object. + */ + public long getTimestamp() + { return timestamp; } + /** + * Method HeadCache#getUserId returns the userId of this object. + * + * @return the userId (type UUID) of this object. + */ + public UUID getUserId() + { + return this.userId; + } } diff --git a/src/main/java/world/bentobox/bentobox/util/heads/HeadGetter.java b/src/main/java/world/bentobox/bentobox/util/heads/HeadGetter.java index 2c1a7c338..ca3059438 100644 --- a/src/main/java/world/bentobox/bentobox/util/heads/HeadGetter.java +++ b/src/main/java/world/bentobox/bentobox/util/heads/HeadGetter.java @@ -1,78 +1,291 @@ package world.bentobox.bentobox.util.heads; +import com.google.gson.Gson; +import com.google.gson.JsonElement; +import com.google.gson.JsonObject; +import java.io.BufferedReader; +import java.io.InputStreamReader; +import java.net.URL; +import java.nio.charset.StandardCharsets; import java.util.HashMap; import java.util.HashSet; import java.util.Iterator; import java.util.Map; import java.util.Map.Entry; import java.util.Set; +import java.util.UUID; +import java.util.stream.Collectors; import org.bukkit.Bukkit; -import org.bukkit.Material; -import org.bukkit.inventory.ItemStack; -import org.bukkit.inventory.meta.SkullMeta; +import org.eclipse.jdt.annotation.Nullable; import world.bentobox.bentobox.BentoBox; import world.bentobox.bentobox.api.panels.PanelItem; -public class HeadGetter { - private static Map cachedHeads = new HashMap<>(); + +/** + * This class manages getting player heads for requester. + * @author tastybento, BONNe1704 + */ +public class HeadGetter +{ + /** + * Local cache for storing player heads. + */ + private static final Map cachedHeads = new HashMap<>(); + + /** + * Local cache for storing requested names and items which must be updated. + */ private static final Map names = new HashMap<>(); - private static final long TOO_LONG = 360000; // 3 minutes - private BentoBox plugin; - private static Map> headRequesters = new HashMap<>(); + + /** + * Requesters of player heads. + */ + private static final Map> headRequesters = new HashMap<>(); + + /** + * Instance of plugin. + */ + private final BentoBox plugin; + /** * @param plugin - plugin */ - public HeadGetter(BentoBox plugin) { - super(); + public HeadGetter(BentoBox plugin) + { this.plugin = plugin; - runPlayerHeadGetter(); + this.runPlayerHeadGetter(); } - @SuppressWarnings("deprecation") - private void runPlayerHeadGetter() { + + /** + * @param panelItem - head to update + * @param requester - callback class + */ + public static void getHead(PanelItem panelItem, HeadRequester requester) + { + // Freshen cache + // If memory is an issue we sacrifice performance? + // cachedHeads.values().removeIf(cache -> System.currentTimeMillis() - cache.getTimestamp() > TOO_LONG); + + HeadCache cache = cachedHeads.get(panelItem.getPlayerHeadName()); + + // Get value from config. Multiply value to 60 000 as internally it uses miliseconds. + // Config value stores minutes. + long cacheTimeout = BentoBox.getInstance().getSettings().getPlayerHeadCacheTime() * 60 * 1000; + + // to avoid every time clearing stored heads (as they may become very large) + // just check if requested cache exists and compare it with value from plugin settings. + // If timestamp is set to 0, then it must be kept forever. + // If settings time is set to 0, then always use cache. + if (cache != null && + cache.getTimestamp() != 0 && + cacheTimeout > 0 && + System.currentTimeMillis() - cache.getTimestamp() <= cacheTimeout) + { + panelItem.setHead(cachedHeads.get(panelItem.getPlayerHeadName()).getPlayerHead()); + requester.setHead(panelItem); + } + else + { + // Get the name + headRequesters.computeIfAbsent(panelItem.getPlayerHeadName(), k -> new HashSet<>()). + add(requester); + names.put(panelItem.getPlayerHeadName(), panelItem); + } + } + + + /** + * This method allows to add HeadCache object into local cache. + * It will provide addons to use HeadGetter cache directly. + * @param cache Cache object that need to be added into local cache. + * @since 1.14.1 + */ + public static void addToCache(HeadCache cache) + { + cachedHeads.put(cache.getUserName(), cache); + } + + +// --------------------------------------------------------------------- +// Section: Private methods +// --------------------------------------------------------------------- + + + /** + * This is main task that runs once every 20 ticks and tries to get a player head. + */ + private void runPlayerHeadGetter() + { Bukkit.getScheduler().runTaskTimerAsynchronously(plugin, () -> { - synchronized(names) { + synchronized (names) + { Iterator> it = names.entrySet().iterator(); - if (it.hasNext()) { - Entry en = it.next(); - ItemStack playerSkull = new ItemStack(Material.PLAYER_HEAD, en.getValue().getItem().getAmount()); - SkullMeta meta = (SkullMeta) playerSkull.getItemMeta(); - meta.setOwner(en.getKey()); - playerSkull.setItemMeta(meta); + + if (it.hasNext()) + { + Entry elementEntry = it.next(); + + // TODO: In theory BentoBox could use User instance to find existing user UUID's. + // It would avoid one API call. + final String userName = elementEntry.getKey(); + + // Use cached userId as userId will not change :) + UUID userId = cachedHeads.containsKey(userName) ? + cachedHeads.get(userName).getUserId() : + HeadGetter.getUserIdFromName(userName); + + // Create new cache object. + HeadCache cache = new HeadCache(userName, + userId, + HeadGetter.getTextureFromUUID(userId)); + // Save in cache - cachedHeads.put(en.getKey(), new HeadCache(playerSkull, System.currentTimeMillis())); + cachedHeads.put(userName, cache); + // Tell requesters the head came in - if (headRequesters.containsKey(en.getKey())) { - for (HeadRequester req : headRequesters.get(en.getKey())) { - en.getValue().setHead(playerSkull.clone()); - Bukkit.getServer().getScheduler().runTask(plugin, () -> req.setHead(en.getValue())); + if (headRequesters.containsKey(userName)) + { + for (HeadRequester req : headRequesters.get(userName)) + { + elementEntry.getValue().setHead(cache.getPlayerHead()); + + Bukkit.getServer().getScheduler().runTaskAsynchronously(plugin, + () -> req.setHead(elementEntry.getValue())); } } + it.remove(); } } }, 0L, 20L); } + /** - * @param panelItem - head to update - * @param requester - callback class + * This method gets and returns userId from mojang web API based on user name. + * @param name user which Id must be returned. + * @return String value for user Id. */ - public static void getHead(PanelItem panelItem, HeadRequester requester) { - // Freshen cache - cachedHeads.values().removeIf(c -> System.currentTimeMillis() - c.getTimestamp() > TOO_LONG); - // Check if in cache - if (cachedHeads.containsKey(panelItem.getPlayerHeadName())) { - panelItem.setHead(cachedHeads.get(panelItem.getPlayerHeadName()).getHead().clone()); - requester.setHead(panelItem); - } else { - // Get the name - headRequesters.computeIfAbsent(panelItem.getPlayerHeadName(), k -> new HashSet<>()).add(requester); - names.put(panelItem.getPlayerHeadName(), panelItem); + private static UUID getUserIdFromName(String name) + { + UUID userId; + + try + { + Gson gsonReader = new Gson(); + + // Get mojang user-id from given nickname + JsonObject jsonObject = gsonReader.fromJson( + HeadGetter.getURLContent("https://api.mojang.com/users/profiles/minecraft/" + name), + JsonObject.class); + /* + * Returned Json Object: + { + name: USER_NAME, + id: USER_ID + } + */ + + // Mojang returns ID without `-`. So it is necessary to insert them back. + // Well technically it is not necessary and can use just a string instead of UUID. + // UUID just looks more fancy :) + String userIdString = jsonObject.get("id").toString(). + replace("\"", ""). + replaceFirst("([0-9a-fA-F]{8})([0-9a-fA-F]{4})([0-9a-fA-F]{4})([0-9a-fA-F]{4})([0-9a-fA-F]+)", + "$1-$2-$3-$4-$5"); + + userId = UUID.fromString(userIdString); } + catch (Exception ignored) + { + // Return random if failed? + userId = UUID.randomUUID(); + } + + return userId; } -} + + /** + * This method gets and returns base64 encoded link to player skin texture, based on + * given player UUID. + * + * @param userId UUID value for the user. + * @return Encoded player skin texture or null. + */ + private static @Nullable String getTextureFromUUID(UUID userId) + { + try + { + Gson gsonReader = new Gson(); + + // Get user encoded texture value. + JsonObject jsonObject = gsonReader.fromJson( + HeadGetter.getURLContent("https://sessionserver.mojang.com/session/minecraft/profile/" + userId.toString()), + JsonObject.class); + + /* + * Returned Json Object: + { + id: USER_ID, + name: USER_NAME, + properties: [ + { + name: "textures", + value: ENCODED_BASE64_TEXTURE + } + ] + } + */ + + String decodedTexture = ""; + + for (JsonElement element : jsonObject.getAsJsonArray("properties")) + { + JsonObject object = element.getAsJsonObject(); + + if (object.has("name") && + object.get("name").getAsString().equals("textures")) + { + decodedTexture = object.get("value").getAsString(); + break; + } + } + + return decodedTexture; + } + catch (Exception ignored) + { + } + + return null; + } + + + /** + * This method gets page content of requested url + * + * @param requestedUrl Url which content must be returned. + * @return Content of a page or empty string. + */ + private static String getURLContent(String requestedUrl) + { + String returnValue; + + try (BufferedReader reader = new BufferedReader( + new InputStreamReader(new URL(requestedUrl).openStream(), StandardCharsets.UTF_8))) + { + returnValue = reader.lines().collect(Collectors.joining()); + } + catch (Exception ignored) + { + returnValue = ""; + } + + return returnValue; + } +} \ No newline at end of file diff --git a/src/main/resources/config.yml b/src/main/resources/config.yml index b876ee0c0..04532e494 100644 --- a/src/main/resources/config.yml +++ b/src/main/resources/config.yml @@ -82,6 +82,11 @@ panel: # Defines the Material of the item that fills the gaps (in the header, etc.) of most panels. # Added since 1.14.0. filler-material: LIGHT_BLUE_STAINED_GLASS_PANE + # Defines how long player skin texture link is stored into local cache before it is requested again. + # Defined value is in the minutes. + # Value 0 will not clear cache until server restart. + # Added since 1.14.1. + head-cache-time: 60 logs: # Toggle whether superflat chunks regeneration should be logged in the server logs or not. # It can be spammy if there are a lot of superflat chunks to regenerate.