com.cryptomorin.xseries.XSkull Maven / Gradle / Ivy
Show all versions of XSeries Show documentation
/*
* The MIT License (MIT)
*
* Copyright (c) 2024 Crypto Morin
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
* in the Software without restriction, including without limitation the rights
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in
* all copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED,
* INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR
* PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE
* FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE,
* ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.cryptomorin.xseries;
import com.cryptomorin.xseries.reflection.XReflection;
import com.cryptomorin.xseries.reflection.jvm.FieldMemberHandle;
import com.cryptomorin.xseries.reflection.jvm.MethodMemberHandle;
import com.cryptomorin.xseries.reflection.jvm.ReflectiveNamespace;
import com.cryptomorin.xseries.reflection.minecraft.MinecraftClassHandle;
import com.cryptomorin.xseries.reflection.minecraft.MinecraftMapping;
import com.google.common.base.Charsets;
import com.google.common.collect.Iterables;
import com.google.common.io.CharStreams;
import com.google.gson.JsonElement;
import com.google.gson.JsonObject;
import com.google.gson.JsonParser;
import com.google.gson.internal.Streams;
import com.google.gson.stream.JsonReader;
import com.mojang.authlib.GameProfile;
import com.mojang.authlib.minecraft.MinecraftSessionService;
import com.mojang.authlib.properties.Property;
import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;
import org.bukkit.Bukkit;
import org.bukkit.OfflinePlayer;
import org.bukkit.block.Block;
import org.bukkit.block.BlockState;
import org.bukkit.block.Skull;
import org.bukkit.entity.Player;
import org.bukkit.inventory.ItemStack;
import org.bukkit.inventory.meta.ItemMeta;
import org.bukkit.inventory.meta.SkullMeta;
import org.jetbrains.annotations.ApiStatus;
import javax.annotation.Nonnull;
import javax.annotation.Nullable;
import java.io.IOException;
import java.io.InputStream;
import java.io.InputStreamReader;
import java.lang.invoke.MethodHandle;
import java.net.HttpURLConnection;
import java.net.URL;
import java.nio.charset.StandardCharsets;
import java.util.*;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.ThreadFactory;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.function.Function;
import java.util.function.Supplier;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
/**
* XSkull - Apply skull texture from different sources.
* Skull Meta: hub.spigotmc.org/.../SkullMeta
* Mojang API: wiki.vg/Mojang_API
*
* Some websites to get custom heads:
*
*
* The basic premise behind this API is that the final skull data is contained in a {@link GameProfile}
* either by ID, name or encoded textures URL property.
*
* Different versions of Minecraft client handle this differently. In newer versions the client seem
* to prioritize the texture property over the set UUID and name, in older versions however using the
* same UUID for all GameProfiles caused all skulls (that use base64) to look the same.
* The client is responsible for caching skull textures. If the download were to fail (either because of
* connection issues or invalid values) the client will cache that skull UUID and the skull
* will remain as a steve head until the client is completely restarted.
* I don't know if this cache system works across other servers or is just specific to one server.
*
* @author Crypto Morin
* @version 9.0.0
* @see XMaterial
* @see XReflection
*/
public final class XSkull {
private static final Logger LOGGER = LogManager.getLogger("XSkull");
private static final Object USER_CACHE, MINECRAFT_SESSION_SERVICE;
private static final MethodHandle
FILL_PROFILE_PROPERTIES, GET_PROFILE_BY_NAME, GET_PROFILE_BY_UUID, CACHE_PROFILE,
CRAFT_META_SKULL_PROFILE_GETTER, CRAFT_META_SKULL_PROFILE_SETTER,
CRAFT_SKULL_PROFILE_SETTER, CRAFT_SKULL_PROFILE_GETTER,
PROPERTY_GET_VALUE;
/**
* Some people use this without quotes surrounding the keys, not sure if that'd work.
*/
private static final String TEXTURES_NBT_PROPERTY_PREFIX = "{\"textures\":{\"SKIN\":{\"url\":\"";
/**
* In v1.20.2 there were some changes to the Mojang API.
* Before that version, both UUID and name fields couldn't be null, only one of them.
* It gave the error: {@code Name and ID cannot both be blank}
* Here, "blank" is null for UUID, and {@code Character.isWhitespace} for the name field.
*/
private static final String DEFAULT_PROFILE_NAME = "XSeries";
private static final UUID DEFAULT_PROFILE_UUID = new UUID(0, 0);
private static final Pattern UUID_NO_DASHES = Pattern.compile("([0-9a-fA-F]{8})([0-9a-fA-F]{4})([0-9a-fA-F]{4})([0-9a-fA-F]{4})([0-9a-fA-F]{12})");
/**
* We'll just return an x shaped hardcoded skull.
* minecraft-heads.com
*/
private static final GameProfile DEFAULT_PROFILE = SkullInputType.BASE64.getProfile(
"eyJ0ZXh0dXJlcyI6eyJTS0lOIjp7InVybCI6Imh0dHA6Ly90ZXh0dXJlcy5taW5l" +
"Y3JhZnQubmV0L3RleHR1cmUvYzEwNTkxZTY5MDllNmEyODFiMzcxODM2ZTQ2MmQ2" +
"N2EyYzc4ZmEwOTUyZTkxMGYzMmI0MWEyNmM0OGMxNzU3YyJ9fX0="
);
/**
* In v1.20.2, Mojang switched to {@code record} class types for their {@link Property} class.
*/
private static final boolean NULLABILITY_RECORD_UPDATE = XReflection.supports(20, 2);
/**
* The value after this URL is probably an SHA-252 value that Mojang uses to unique identify player skins.
*
* This wiki documents how to
* get base64 information from player's UUID.
*/
private static final String TEXTURES_BASE_URL = "http://textures.minecraft.net/texture/";
static {
Object userCache, minecraftSessionService;
MethodHandle fillProfileProperties = null, getProfileByName, getProfileByUUID, cacheProfile;
MethodHandle profileSetterMeta, profileGetterMeta, getPropertyValue = null;
ReflectiveNamespace ns = XReflection.namespaced()
.imports(GameProfile.class, MinecraftSessionService.class);
try {
MinecraftClassHandle CraftMetaSkull = ns.ofMinecraft(
"package cb.inventory; class CraftMetaSkull extends CraftMetaItem implements SkullMeta {}"
);
profileGetterMeta = CraftMetaSkull.field("private GameProfile profile;").getter().reflect();
try {
// https://github.com/CryptoMorin/XSeries/issues/169
profileSetterMeta = CraftMetaSkull.method("private void setProfile(GameProfile profile);").reflect();
} catch (NoSuchMethodException e) {
profileSetterMeta = CraftMetaSkull.field("private GameProfile profile;").setter().reflect();
}
MinecraftClassHandle MinecraftServer = ns.ofMinecraft(
"package nms.server; public abstract class MinecraftServer {}"
);
MinecraftClassHandle GameProfileCache = ns.ofMinecraft(
"package nms.server.players; public class GameProfileCache {}"
).map(MinecraftMapping.SPIGOT, "UserCache");
// Added by Bukkit
Object minecraftServer = MinecraftServer.method("public static MinecraftServer getServer();").reflect().invoke();
minecraftSessionService = MinecraftServer.method("public MinecraftSessionService getSessionService();")
.named(/* 1.19.4 */ "ay", /* 1.17.1 */ "getMinecraftSessionService", "az", "ao", "am", /* 1.20.4 */ "aD", /* 1.20.6 */ "ar")
.reflect().invoke(minecraftServer);
userCache = MinecraftServer.method("public GameProfileCache getProfileCache();")
.named("ar", /* 1.18.2 */ "ao", /* 1.20.4 */ "ap", /* 1.20.6 */ "au")
.map(MinecraftMapping.OBFUSCATED, /* 1.9.4 */ "getUserCache")
.reflect().invoke(minecraftServer);
if (!NULLABILITY_RECORD_UPDATE) {
fillProfileProperties = ns.of(MinecraftSessionService.class).method(
"public GameProfile fillProfileProperties(GameProfile profile, boolean flag);"
).reflect();
}
MethodMemberHandle profileByName = GameProfileCache.method().named(/* v1.17.1 */ "getProfile", "a");
MethodMemberHandle profileByUUID = GameProfileCache.method().named(/* v1.17.1 */ "getProfile", "a");
getProfileByName = XReflection.anyOf(
() -> profileByName.signature("public GameProfile get(String username);"),
() -> profileByName.signature("public Optional get(String username);")
).reflect();
getProfileByUUID = XReflection.anyOf(
() -> profileByUUID.signature("public GameProfile get(UUID id);"),
() -> profileByUUID.signature("public Optional get(UUID id);")
).reflect();
cacheProfile = GameProfileCache.method("public void add(GameProfile profile);")
.map(MinecraftMapping.OBFUSCATED, "a").reflect();
} catch (Throwable throwable) {
throw XReflection.throwCheckedException(throwable);
}
MinecraftClassHandle CraftSkull = ns.ofMinecraft(
"package cb.block; public class CraftSkull extends CraftBlockEntityState implements Skull {}"
);
FieldMemberHandle craftProfile = CraftSkull.field("private GameProfile profile;");
if (!NULLABILITY_RECORD_UPDATE) {
getPropertyValue = ns.of(Property.class).method("public String getValue();").unreflect();
}
USER_CACHE = userCache;
MINECRAFT_SESSION_SERVICE = minecraftSessionService;
FILL_PROFILE_PROPERTIES = fillProfileProperties;
GET_PROFILE_BY_NAME = getProfileByName;
GET_PROFILE_BY_UUID = getProfileByUUID;
CACHE_PROFILE = cacheProfile;
PROPERTY_GET_VALUE = getPropertyValue;
CRAFT_META_SKULL_PROFILE_SETTER = profileSetterMeta;
CRAFT_META_SKULL_PROFILE_GETTER = profileGetterMeta;
CRAFT_SKULL_PROFILE_SETTER = craftProfile.setter().unreflect();
CRAFT_SKULL_PROFILE_GETTER = craftProfile.getter().unreflect();
}
/**
* Creates a {@link SkullInstruction} for an {@link ItemStack}.
* This method initializes a new player head.
*
* @return A {@link SkullInstruction} that sets the profile for the generated {@link ItemStack}.
*/
public static SkullInstruction create() {
return of(XMaterial.PLAYER_HEAD.parseItem());
}
/**
* Creates a {@link SkullInstruction} for an {@link ItemStack}.
*
* @param stack The {@link ItemStack} to set the profile for.
* @return A {@link SkullInstruction} that sets the profile for the given {@link ItemStack}.
*/
public static SkullInstruction of(ItemStack stack) {
return new SkullInstruction<>((profile) -> setProfile(stack, profile));
}
/**
* Creates a {@link SkullInstruction} for an {@link ItemMeta}.
*
* @param meta The {@link ItemMeta} to set the profile for.
* @return An {@link SkullInstruction} that sets the profile for the given {@link ItemMeta}.
*/
public static SkullInstruction of(ItemMeta meta) {
return new SkullInstruction<>((profile) -> setProfile(meta, profile));
}
/**
* Creates a {@link SkullInstruction} for a {@link Block}.
*
* @param block The {@link Block} to set the profile for.
* @return An {@link SkullInstruction} that sets the profile for the given {@link Block}.
*/
public static SkullInstruction of(Block block) {
return new SkullInstruction<>((profile -> setProfile(block, profile)));
}
/**
* Creates a {@link SkullInstruction} for a {@link BlockState}.
*
* @param state The {@link BlockState} to set the profile for.
* @return An {@link SkullInstruction} that sets the profile for the given {@link BlockState}.
*/
public static SkullInstruction of(BlockState state) {
return new SkullInstruction<>((profile -> setProfile(state, profile)));
}
/**
* Checks if the provided {@link GameProfile} has a texture property.
*
* @param profile The {@link GameProfile} to check.
* @return {@code true} if the profile has a texture property, {@code false} otherwise.
*/
public static boolean hasTextures(GameProfile profile) {
return getTextureProperty(profile).isPresent();
}
/**
* Retrieves the skin value from the given {@link ItemMeta}.
*
* @param meta The {@link ItemMeta} to retrieve the skin value from.
* @return The skin value as a {@link String}, or {@code null} if not found.
* @throws NullPointerException if {@code meta} is {@code null}.
*/
@Nullable
public static String getSkinValue(@Nonnull ItemMeta meta) {
Objects.requireNonNull(meta, "Skull meta cannot be null");
GameProfile profile = getProfile(meta);
return profile == null ? null : getSkinValue(profile);
}
/**
* Retrieves the skin value from the given {@link BlockState}.
*
* @param state The {@link BlockState} to retrieve the skin value from.
* @return The skin value as a {@link String}, or {@code null} if not found.
* @throws NullPointerException if {@code state} is {@code null}.
*/
@Nullable
public static String getSkinValue(@Nonnull BlockState state) {
Objects.requireNonNull(state, "Block state cannot be null");
GameProfile profile = getProfile(state);
return profile == null ? null : getSkinValue(profile);
}
private static Optional getTextureProperty(GameProfile profile) {
return Optional.ofNullable(Iterables.getFirst(profile.getProperties().get("textures"), null));
}
/**
* Retrieves the skin value from the given {@link GameProfile}.
*
* @param profile The {@link GameProfile} to retrieve the skin value from.
* @return The skin value as a {@link String}, or {@code null} if not found.
* @throws NullPointerException if {@code profile} is {@code null}.
*/
@Nullable
public static String getSkinValue(@Nonnull GameProfile profile) {
Objects.requireNonNull(profile, "Game profile cannot be null");
return getTextureProperty(profile).map(XSkull::getPropertyValue).orElse(null);
}
/**
* Retrieves a {@link GameProfile} from the given {@link UUID}.
*
* @param uuid The {@link UUID} to retrieve the profile from.
* @return The {@link GameProfile} corresponding to the input.
* @throws NullPointerException if {@code uuid} is {@code null}.
*/
@Nonnull
public static GameProfile getProfile(@Nonnull UUID uuid) {
Objects.requireNonNull(uuid, "UUID cannot be null");
GameProfile profile = getCachedProfileByUUID(uuid);
if (hasTextures(profile)) return profile;
return fetchProfile(profile);
}
/**
* Retrieves a {@link GameProfile} based on the provided input string.
*
* @param input The input string to retrieve the profile for.
* @return The {@link GameProfile} corresponding to the input.
* @throws NullPointerException if {@code input} is {@code null}.
*/
@Nonnull
public static GameProfile getProfile(@Nonnull String input) {
Objects.requireNonNull(input, "Input cannot be null");
return getProfileOrDefault(SkullInputType.get(input), input);
}
/**
* Retrieves a {@link GameProfile} based on the provided input type and input string.
*
* @param type The type of the input.
* @param input The input string to retrieve the profile for.
* @return The {@link GameProfile} corresponding to the input type and input string.
* Returns the default profile if the type is {@code null}.
* @throws NullPointerException if {@code input} is {@code null}.
* @implNote This method does not validate the input type.
* If validation is required, consider using {@link XSkull#getProfile(String)}.
*/
@Nonnull
public static GameProfile getProfileOrDefault(@Nullable SkullInputType type, @Nonnull String input) {
Objects.requireNonNull(input, "Input cannot be null");
return type == null ? getDefaultProfile() : type.getProfile(input);
}
/**
* Retrieves a {@link GameProfile} from the given {@link ItemStack}.
*
* @param stack The {@link ItemStack} to retrieve the profile from.
* @return The {@link GameProfile} of the item, or {@code null} if not found.
* @throws NullPointerException if {@code stack} is {@code null}.
*/
@Nullable
public static GameProfile getProfile(@Nonnull ItemStack stack) {
Objects.requireNonNull(stack, "Item stack cannot be null");
ItemMeta meta = stack.getItemMeta();
return getProfile(meta);
}
/**
* Retrieves a {@link GameProfile} from the given {@link ItemMeta}.
*
* @param meta The {@link ItemMeta} to retrieve the profile from.
* @return The {@link GameProfile} of the item meta, or {@code null} if not found.
* @throws NullPointerException if {@code meta} is {@code null}.
*/
@Nullable
public static GameProfile getProfile(@Nonnull ItemMeta meta) {
Objects.requireNonNull(meta, "Item meta cannot be null");
try {
return (GameProfile) CRAFT_META_SKULL_PROFILE_GETTER.invoke((SkullMeta) meta);
} catch (Throwable throwable) {
throw new RuntimeException("Failed to get profile from item meta: " + meta, throwable);
}
}
/**
* Retrieves a {@link GameProfile} from the given {@link Block}.
*
* @param block The {@link Block} to retrieve the profile from.
* @return The {@link GameProfile} of the block, or {@code null} if not found.
* @throws NullPointerException if {@code block} is {@code null}.
*/
@Nullable
public static GameProfile getProfile(@Nonnull Block block) {
Objects.requireNonNull(block, "Block cannot be null");
BlockState state = block.getState();
return getProfile(state);
}
/**
* Retrieves a {@link GameProfile} from the given {@link BlockState}.
*
* @param state The {@link BlockState} to retrieve the profile from.
* @return The {@link GameProfile} of the block state, or {@code null} if not found.
* @throws NullPointerException if {@code state} is {@code null}.
*/
@Nullable
public static GameProfile getProfile(@Nonnull BlockState state) {
Objects.requireNonNull(state, "Block state cannot be null");
try {
return (GameProfile) CRAFT_SKULL_PROFILE_GETTER.invoke(state);
} catch (Throwable throwable) {
throw new RuntimeException("Unable to get profile fr om blockstate: " + state, throwable);
}
}
/**
* Sets the {@link GameProfile} on the given {@link ItemStack}.
*
* @param stack The {@link ItemStack} to set the profile on.
* @param profile The {@link GameProfile} to set.
* @return The {@link ItemStack} with the profile set.
* @throws NullPointerException if {@code stack} or {@code profile} is {@code null}.
*/
@Nonnull
public static ItemStack setProfile(@Nonnull ItemStack stack, @Nullable GameProfile profile) {
Objects.requireNonNull(stack, "Item stack cannot be null");
ItemMeta meta = stack.getItemMeta();
setProfile(Objects.requireNonNull(meta), profile);
stack.setItemMeta(meta);
return stack;
}
/**
* Directly sets the {@link GameProfile} on the given {@link ItemMeta}.
*
* Note: Directly setting the profile is not compatible with {@link SkullMeta#setOwningPlayer(OfflinePlayer)},
* and should be reset by calling {@code setProfile(meta, null)}.
*
* Newer client versions give profiles a higher priority over UUID.
*
*
* @param meta The {@link ItemMeta} to set the profile on.
* @param profile The {@link GameProfile} to set.
* @return The {@link ItemMeta} with the profile set.
* @throws NullPointerException if {@code meta} is {@code null}.
*/
public static ItemMeta setProfile(@Nonnull ItemMeta meta, @Nullable GameProfile profile) {
Objects.requireNonNull(meta, "Item meta cannot be null");
try {
CRAFT_META_SKULL_PROFILE_SETTER.invoke(meta, profile);
} catch (Throwable throwable) {
throw new RuntimeException("Unable to set profile " + profile + " to " + meta, throwable);
}
return meta;
}
/**
* Sets the {@link GameProfile} on the given {@link Block}.
*
* @param block The {@link Block} to set the profile on.
* @param profile The {@link GameProfile} to set.
* @return The {@link Block} with the profile set.
* @throws NullPointerException if {@code block} is {@code null}.
*/
public static Block setProfile(@Nonnull Block block, @Nullable GameProfile profile) {
Objects.requireNonNull(block, "Block cannot be null");
BlockState state = block.getState();
setProfile(state, profile);
state.update(true);
return block;
}
/**
* Sets the {@link GameProfile} on the given {@link BlockState}.
*
* @param state The {@link BlockState} to set the profile on.
* @param profile The {@link GameProfile} to set.
* @return The {@link BlockState} with the profile set.
* @throws NullPointerException if {@code state} is {@code null}.
*/
public static BlockState setProfile(@Nonnull BlockState state, @Nullable GameProfile profile) {
Objects.requireNonNull(state, "Block state cannot be null");
try {
CRAFT_SKULL_PROFILE_SETTER.invoke((Skull) state, profile);
} catch (Throwable throwable) {
throw new RuntimeException("Unable to set profile " + profile + " to " + state, throwable);
}
return state;
}
/**
* Retrieves a cached {@link GameProfile} by username from the user cache.
* If the profile is not found in the cache, creates a new profile with the provided name.
*
* @param username The username of the profile to retrieve from the cache.
* @return The cached {@link GameProfile} corresponding to the username, or a new profile if not found.
*/
private static GameProfile getCachedProfileByUsername(String username) {
try {
@Nullable Object profile = GET_PROFILE_BY_NAME.invoke(USER_CACHE, username);
if (profile instanceof Optional) profile = ((Optional>) profile).orElse(null);
return profile == null ? new GameProfile(DEFAULT_PROFILE_UUID, username) : sanitizeProfile((GameProfile) profile);
} catch (Throwable throwable) {
LOGGER.error("Unable to get cached profile by username: " + username, throwable);
return null;
}
}
/**
* Retrieves a cached {@link GameProfile} by UUID from the user cache.
* If the profile is not found in the cache, creates a new profile with the provided UUID.
*
* @param uuid The UUID of the profile to retrieve from the cache.
* @return The cached {@link GameProfile} corresponding to the UUID, or a new profile if not found.
*/
private static GameProfile getCachedProfileByUUID(UUID uuid) {
try {
@Nullable Object profile = GET_PROFILE_BY_UUID.invoke(USER_CACHE, uuid);
if (profile instanceof Optional) profile = ((Optional>) profile).orElse(null);
return profile == null ? new GameProfile(uuid, DEFAULT_PROFILE_NAME) : sanitizeProfile((GameProfile) profile);
} catch (Throwable throwable) {
LOGGER.error("Unable to get cached profile by UUID: " + uuid, throwable);
return getDefaultProfile();
}
}
/**
* Caches the provided {@link GameProfile} in the user cache.
*
* @param profile The {@link GameProfile} to cache.
*/
private static void cacheProfile(GameProfile profile) {
try {
CACHE_PROFILE.invoke(USER_CACHE, profile);
} catch (Throwable throwable) {
LOGGER.error("Unable to cache profile: " + profile, throwable);
}
}
/**
* Fetches additional properties for the given {@link GameProfile} if possible.
*
* @param profile The {@link GameProfile} for which properties are to be fetched.
* @return The updated {@link GameProfile} with fetched properties, sanitized for consistency.
*/
private static GameProfile fetchProfile(GameProfile profile) {
if (!NULLABILITY_RECORD_UPDATE) {
try {
profile = (GameProfile) FILL_PROFILE_PROPERTIES.invoke(MINECRAFT_SESSION_SERVICE, profile, false);
} catch (Throwable throwable) {
throw new RuntimeException("Unable to fetch profile properties: " + profile, throwable);
}
} else {
// Implemented by YggdrasilMinecraftSessionService
// fetchProfile(UUID profileId, boolean requireSecure)
com.mojang.authlib.yggdrasil.ProfileResult result = ((MinecraftSessionService) MINECRAFT_SESSION_SERVICE)
.fetchProfile(/* get real UUID for offline players */ getRealUUIDOfPlayer(profile.getId()), false);
if (result != null) profile = result.profile();
}
return sanitizeProfile(profile);
}
public static UUID getOfflineUUID(OfflinePlayer player) {
// Vanilla behavior across all platforms.
return UUID.nameUUIDFromBytes(("OfflinePlayer:" + player.getName()).getBytes(StandardCharsets.UTF_8));
}
private static boolean isOnlineMode() {
return Bukkit.getOnlineMode();
}
private static final Map REAL_UUID = new HashMap<>();
@ApiStatus.Internal
public static UUID getRealUUIDOfPlayer(UUID uuid) {
if (isOnlineMode()) return uuid;
OfflinePlayer player = Bukkit.getOfflinePlayer(uuid);
if (!player.hasPlayedBefore()) throw new RuntimeException("Player with UUID " + uuid + " doesn't exist.");
UUID offlineUUID = getOfflineUUID(player);
if (!offlineUUID.equals(uuid)) {
throw new RuntimeException("Expected offline UUID for player doesn't match: Expected " + uuid + ", got " + offlineUUID + " for " + player);
}
try {
UUID realUUID = REAL_UUID.get(uuid);
if (realUUID == null) {
realUUID = UUIDFromName(player.getName());
REAL_UUID.put(uuid, realUUID);
}
return realUUID;
} catch (IOException e) {
throw new RuntimeException(e);
}
}
@Nonnull
private static UUID UUIDFromName(String name) throws IOException {
JsonObject userJson = requestUsernameToUUID(name);
JsonElement idElement = userJson.get("id");
if (idElement == null)
throw new RuntimeException("No 'id' field for UUID request for '" + name + "': " + userJson);
return UUIDFromDashlessString(idElement.getAsString());
}
private static UUID UUIDFromDashlessString(String dashlessUUIDString) {
Matcher matcher = UUID_NO_DASHES.matcher(dashlessUUIDString);
try {
return UUID.fromString(matcher.replaceFirst("$1-$2-$3-$4-$5"));
} catch (IllegalArgumentException ex) {
throw new IllegalArgumentException("Cannot convert from dashless UUID: " + dashlessUUIDString, ex);
}
}
public static JsonObject requestUsernameToUUID(String username) throws IOException {
HttpURLConnection connection = (HttpURLConnection) new URL("https://api.mojang.com/users/profiles/minecraft/" + username).openConnection();
connection.setRequestMethod("GET");
connection.setConnectTimeout(10 * 1000); // 10 seconds
connection.setReadTimeout(30 * 1000); // 30 seconds
connection.setDoInput(true);
connection.setDoOutput(false);
connection.setUseCaches(false);
connection.setAllowUserInteraction(false);
try (
InputStream inputStream = connection.getInputStream();
JsonReader reader = new JsonReader(new InputStreamReader(inputStream, StandardCharsets.UTF_8));
) {
JsonElement json = Streams.parse(reader);
if (json == null || !json.isJsonObject()) {
// For UUID_TO_PROFILE, this happens when HTTP Code 204 (No Content) is given.
// And that happens if the UUID doesn't exist in Mojang servers. (E.g. cracked UUIDs)
throw new RuntimeException("Response from '" + connection.getURL() + "' is not a JSON object with response '"
+ connection.getResponseCode() + ": " + connection.getResponseMessage() + "': " +
CharStreams.toString(new InputStreamReader(connection.getInputStream(), Charsets.UTF_8)));
}
return json.getAsJsonObject();
} catch (IOException ex) {
InputStream errorStream = connection.getErrorStream();
String error = errorStream == null ?
connection.getResponseCode() + ": " + connection.getResponseMessage() :
CharStreams.toString(new InputStreamReader(errorStream, Charsets.UTF_8));
throw new IOException(connection.getURL() + " -> " + error, ex);
}
}
/**
* Sanitizes the provided {@link GameProfile} by removing unnecessary timestamp data
* and caches the profile.
*
* @param profile The {@link GameProfile} to be sanitized.
* @return The sanitized {@link GameProfile}.
*/
@SuppressWarnings("deprecation")
private static GameProfile sanitizeProfile(GameProfile profile) {
JsonObject jsonObject = Optional.ofNullable(getSkinValue(profile)).map(XSkull::decodeBase64)
.map((decoded) -> new JsonParser().parse(decoded).getAsJsonObject())
.orElse(null);
if (jsonObject == null || !jsonObject.has("timestamp")) return profile;
JsonObject texture = new JsonObject();
texture.add("textures", jsonObject.get("textures"));
GameProfile clone = new GameProfile(profile.getId(), profile.getName());
Property property = new Property("textures", encodeBase64(texture.toString()));
clone.getProperties().put("textures", property);
cacheProfile(clone);
return clone;
}
/**
* Retrieves the default {@link GameProfile} used by XSkull.
* This method creates a clone of the default profile to prevent modifications to the original.
*
* @return A clone of the default {@link GameProfile}.
*/
private static GameProfile getDefaultProfile() {
GameProfile clone = new GameProfile(DEFAULT_PROFILE.getId(), DEFAULT_PROFILE.getName());
clone.getProperties().putAll(DEFAULT_PROFILE.getProperties());
return clone;
}
/**
* Retrieves the value of a {@link Property}, handling differences between versions.
*
* @param property The {@link Property} from which to retrieve the value.
* @return The value of the {@link Property}.
* @since 4.0.1
*/
private static String getPropertyValue(Property property) {
if (NULLABILITY_RECORD_UPDATE) return property.value();
try {
return (String) PROPERTY_GET_VALUE.invoke(property);
} catch (Throwable throwable) {
throw new RuntimeException("Unable to get a texture value: " + property, throwable);
}
}
/**
* Encodes the provided string into Base64 format.
*
* @param str The string to encode.
* @return The Base64 encoded string.
*/
private static String encodeBase64(String str) {
return Base64.getEncoder().encodeToString(str.getBytes(StandardCharsets.UTF_8));
}
/**
* Tries to decode the string as a Base64 value.
*
* @param base64 The Base64 string to decode.
* @return the decoded Base64 string if it is a valid Base64 string, or null if not.
*/
private static String decodeBase64(String base64) {
Objects.requireNonNull(base64, "Cannot decode null string");
try {
byte[] bytes = Base64.getDecoder().decode(base64);
return new String(bytes, StandardCharsets.UTF_8);
} catch (IllegalArgumentException exception) {
return null;
}
}
/**
* Represents an action that handles both asynchronous and synchronous workflows
* based on a given {@link SkullInstruction}.
*
* @param The type of the result produced by the action.
*/
public static class SkullAction {
/**
* The instruction that defines how the action is applied.
*/
private final SkullInstruction instruction;
/**
* Constructs a {@code SkullAction} with the specified instruction.
*
* @param instruction The instruction that defines how the action is applied.
*/
protected SkullAction(SkullInstruction instruction) {
this.instruction = instruction;
}
/**
* Sets the profile generated by the instruction to the result type synchronously.
* This is recommended if your code is already not on the main thread, or if you know
* that the skull texture doesn't need additional requests.
*
* What are these additional requests?
* This only applies to offline mode (cracked) servers. Since these servers use
* a cracked version of the player UUIDs and not their real ones, the real UUID
* needs to be known by requesting it from Mojang servers and this request which
* requires internet connection, will delay things a lot.
*
* @return The result after setting the generated profile.
*/
public T apply() {
GameProfile profile = instruction.supplier.get();
return instruction.setter.apply(profile);
}
/**
* Asynchronously applies the instruction to generate a {@link GameProfile} and returns a {@link CompletableFuture}.
* This method is designed for non-blocking execution, allowing tasks to be performed
* in the background without blocking the server's main thread.
*
* Usage example:
* {@code
* XSkull.create().profile(offlinePlayer).applyAsync()
* .thenAcceptAsync(result -> {
* // Additional processing...
* }, runnable -> Bukkit.getScheduler().runTask(plugin, runnable));
* }
*
* @return A {@link CompletableFuture} that will complete asynchronously.
*/
public CompletableFuture applyAsync() {
return CompletableFuture.supplyAsync(this::apply, EXECUTOR);
}
}
/**
* Represents an instruction that sets a property of a {@link GameProfile}.
* It uses a {@link Function} to define how to set the property.
*
* @param The type of the result produced by the {@link #setter} function.
*/
public static class SkullInstruction {
/**
* The function called that applies the given {@link #supplier} to an object that supports it
* such as {@link ItemStack}, {@link SkullMeta} or a {@link BlockState}.
*/
protected final Function setter;
/**
* The final texture that will be supplied to {@link #setter} to be applied.
*/
protected Supplier supplier;
SkullInstruction(Function setter) {
this.setter = setter;
}
/**
* Sets the skull texture based on a string. The input type is resolved based on the value provided.
*
* @param input The input value used to retrieve the {@link GameProfile}. For more information check {@link SkullInputType}
* @return A new {@link SkullAction} instance configured with this {@code SkullInstruction}.
*/
public SkullAction profile(String input) {
this.supplier = () -> getProfile(input);
return new SkullAction<>(this);
}
/**
* Sets the skull texture based on a string with a known type.
*
* @param type The type of the input value.
* @param input The input value to generate the {@link GameProfile}.
* @return A new {@link SkullAction} instance configured with this {@code SkullInstruction}.
*/
public SkullAction profile(SkullInputType type, String input) {
Objects.requireNonNull(type, () -> "Cannot profile from a null input type: " + input);
this.supplier = () -> type.getProfile(input);
return new SkullAction<>(this);
}
/**
* Sets the skull texture based on the specified player UUID.
*
* @param uuid The UUID to generate the {@link GameProfile}.
* @return A new {@link SkullAction} instance configured with this {@code SkullInstruction}.
*/
public SkullAction profile(UUID uuid) {
this.supplier = () -> getProfile(uuid);
return new SkullAction<>(this);
}
/**
* Sets the skull texture based on the specified player.
*
* @param player The player to generate the {@link GameProfile}.
* @return A new {@link SkullAction} instance configured with this {@code SkullInstruction}.
*/
public SkullAction profile(Player player) {
// Why are we using the username instead of getting the cached UUID like profile(player.getUniqueId())?
// If it's about online/offline mode support why should we have a separate method for this instead of
// letting profile(OfflinePlayer) to take care of it?
this.supplier = () -> SkullInputType.USERNAME.getProfile(player.getName());
return new SkullAction<>(this);
}
/**
* Sets the skull texture based on the specified offline player.
* The profile lookup will depend on whether the server is running in online mode.
*
* @param offlinePlayer The offline player to generate the {@link GameProfile}.
* @return A new {@link SkullAction} instance configured with this {@code SkullInstruction}.
*/
public SkullAction profile(OfflinePlayer offlinePlayer) {
this.supplier = () ->
Bukkit.getOnlineMode()
? getProfile(offlinePlayer.getUniqueId())
: getProfileOrDefault(SkullInputType.USERNAME, offlinePlayer.getName());
return new SkullAction<>(this);
}
/**
* Sets the skull texture based on the specified profile.
* If the profile already has textures, it will be used directly. Otherwise, a new profile will be fetched
* based on the UUID or username depending on the server's online mode.
*
* @param profile The profile to be used in the profile setting operation.
* @return A new {@link SkullAction} instance configured with this {@code SkullInstruction}.
*/
public SkullAction profile(GameProfile profile) {
if (hasTextures(profile)) {
this.supplier = () -> profile;
return new SkullAction<>(this);
}
this.supplier = () -> Bukkit.getOnlineMode()
? getProfile(profile.getId())
: getProfileOrDefault(SkullInputType.USERNAME, profile.getName());
return new SkullAction<>(this);
}
}
/**
* An executor service with a fixed thread pool of size 2, used for asynchronous operations.
*/
private static final ExecutorService EXECUTOR = Executors.newFixedThreadPool(2, new PlayerTextureThread());
private static final class PlayerTextureThread implements ThreadFactory {
private static final AtomicInteger COUNT = new AtomicInteger();
@Override
public Thread newThread(@Nonnull final Runnable run) {
final Thread thread = new Thread(run);
thread.setName("Profile Lookup Executor #" + COUNT.getAndIncrement());
thread.setUncaughtExceptionHandler((t, throwable) ->
LOGGER.error("Uncaught exception in thread {}", t.getName(), throwable));
return thread;
}
}
/**
* The {@code SkullInputType} enum represents different types of input patterns that can be used for identifying
* and validating various formats such as texture hashes, URLs, Base64 encoded strings, UUIDs, and usernames.
*/
public enum SkullInputType {
/**
* Represents a texture hash pattern.
* Mojang hashes length are inconsistent, and they don't seem to use uppercase characters.
*
* Currently, the shortest observed hash value is (lenght: 57): 0a4050e7aacc4539202658fdc339dd182d7e322f9fbcc4d5f99b5718a
*
* Example: e5461a215b325fbdf892db67b7bfb60ad2bf1580dc968a15dfb304ccd5e74db
*/
TEXTURE_HASH(Pattern.compile("[0-9a-z]{55,70}")) {
@Override
GameProfile getProfile(String textureHash) {
String base64 = encodeBase64(TEXTURES_NBT_PROPERTY_PREFIX + TEXTURES_BASE_URL + textureHash + "\"}}}");
return profileFromHashAndBase64(textureHash, base64);
}
},
/**
* Represents a texture URL pattern that includes the base URL followed by the texture hash pattern.
*
* Example: http://textures.minecraft.net/texture/e5461a215b325fbdf892db67b7bfb60ad2bf1580dc968a15dfb304ccd5e74db
*/
TEXTURE_URL(Pattern.compile(Pattern.quote(TEXTURES_BASE_URL) + "(?" + TEXTURE_HASH.pattern + ')')) {
@Override
GameProfile getProfile(String textureUrl) {
String hash = extractTextureHash(textureUrl);
return TEXTURE_HASH.getProfile(hash);
}
},
/**
* Represents a Base64 encoded string pattern.
* The base64 pattern that's checked is not a general base64 pattern, but a pattern that
* closely represents the base64 genereated by the NBT data.
*/
BASE64(Pattern.compile("[-A-Za-z0-9+/]{100,}={0,3}")) {
@Override
GameProfile getProfile(String base64) {
return Optional.ofNullable(decodeBase64(base64))
.map(SkullInputType::extractTextureHash)
.map((hash) -> profileFromHashAndBase64(hash, base64))
.orElseGet(XSkull::getDefaultProfile);
}
},
/**
* Represents a UUID pattern, following the standard UUID format.
*/
UUID(Pattern.compile("[A-F\\d]{8}-[A-F\\d]{4}-4[A-F\\d]{3}-([89AB])[A-F\\d]{3}-[A-F\\d]{12}", Pattern.CASE_INSENSITIVE)) {
@Override
GameProfile getProfile(String uuidString) {
return XSkull.getProfile(java.util.UUID.fromString(uuidString));
}
},
/**
* Represents a username pattern, allowing alphanumeric characters and underscores, with a length of 1 to 16 characters.
* Minecraft now requires the username to be at least 3 characters long, but older accounts are still around.
* It also seems that there are a few inactive accounts that use spaces in their usernames?
*/
USERNAME(Pattern.compile("[A-Za-z0-9_]{1,16}")) {
@Override
GameProfile getProfile(String username) {
GameProfile profile = getCachedProfileByUsername(username);
if (hasTextures(profile)) return profile;
return fetchProfile(profile);
}
};
/**
* The regex pattern associated with the input type.
*/
private final Pattern pattern;
private static final SkullInputType[] VALUES = values();
/**
* Constructs a {@code SkullInputType} with the specified regex pattern.
*
* @param pattern The regex pattern associated with the input type.
*/
SkullInputType(Pattern pattern) {
this.pattern = pattern;
}
/**
* Retrieves a {@link GameProfile} based on the provided input string.
*
* @param input The input string to retrieve the profile for.
* @return The {@link GameProfile} corresponding to the input string.
*/
abstract GameProfile getProfile(String input);
/**
* Returns the corresponding {@code SkullInputType} for the given identifier, if it matches any pattern.
*
* @param identifier The string to be checked against the patterns.
* @return The matching {@code InputType}, or {@code null} if no match is found.
*/
@Nullable
public static SkullInputType get(@Nonnull String identifier) {
Objects.requireNonNull(identifier, "Identifier cannot be null");
return Arrays.stream(VALUES)
.filter(value -> value.pattern.matcher(identifier).matches())
.findFirst().orElse(null);
}
/**
* Constructs a {@link GameProfile} using the provided texture hash and base64 string.
*
* @param hash The texture hash used to construct the profile's textures.
* @param base64 The base64 string representing the profile's textures.
* @return The constructed {@link GameProfile}.
* @implNote This method creates a {@link GameProfile} with a UUID derived from the provided hash
* to ensure consistency after restarts.
*/
private static GameProfile profileFromHashAndBase64(String hash, String base64) {
UUID uuid = java.util.UUID.nameUUIDFromBytes(hash.getBytes(StandardCharsets.UTF_8));
GameProfile profile = new GameProfile(uuid, DEFAULT_PROFILE_NAME);
profile.getProperties().put("textures", new Property("textures", base64));
return profile;
}
/**
* Extracts the texture hash from the provided input string.
*
* Will not work reliably if NBT is passed: {"textures":{"SKIN":{"url":"http://textures.minecraft.net/texture/74133f6ac3be2e2499a784efadcfffeb9ace025c3646ada67f3414e5ef3394"}}}
*
* @param input The input string containing the texture hash.
* @return The extracted texture hash.
*/
private static String extractTextureHash(String input) {
Matcher matcher = SkullInputType.TEXTURE_HASH.pattern.matcher(input);
return matcher.find() ? matcher.group() : null;
}
}
}