diff --git a/paper-server/src/main/java/org/bukkit/craftbukkit/CraftOfflinePlayer.java b/paper-server/src/main/java/org/bukkit/craftbukkit/CraftOfflinePlayer.java index 1a56ad1fc4..a02ae32f54 100644 --- a/paper-server/src/main/java/org/bukkit/craftbukkit/CraftOfflinePlayer.java +++ b/paper-server/src/main/java/org/bukkit/craftbukkit/CraftOfflinePlayer.java @@ -19,10 +19,12 @@ import org.bukkit.Server; import org.bukkit.Statistic; import org.bukkit.configuration.serialization.ConfigurationSerializable; import org.bukkit.configuration.serialization.SerializableAs; +import org.bukkit.craftbukkit.profile.CraftPlayerProfile; import org.bukkit.entity.EntityType; import org.bukkit.entity.Player; import org.bukkit.metadata.MetadataValue; import org.bukkit.plugin.Plugin; +import org.bukkit.profile.PlayerProfile; @SerializableAs("Player") public class CraftOfflinePlayer implements OfflinePlayer, ConfigurationSerializable { @@ -37,10 +39,6 @@ public class CraftOfflinePlayer implements OfflinePlayer, ConfigurationSerializa } - public GameProfile getProfile() { - return profile; - } - @Override public boolean isOnline() { return getPlayer() != null; @@ -74,6 +72,11 @@ public class CraftOfflinePlayer implements OfflinePlayer, ConfigurationSerializa return profile.getId(); } + @Override + public PlayerProfile getPlayerProfile() { + return new CraftPlayerProfile(profile); + } + public Server getServer() { return server; } diff --git a/paper-server/src/main/java/org/bukkit/craftbukkit/CraftServer.java b/paper-server/src/main/java/org/bukkit/craftbukkit/CraftServer.java index f977efb6aa..8c99f76af3 100644 --- a/paper-server/src/main/java/org/bukkit/craftbukkit/CraftServer.java +++ b/paper-server/src/main/java/org/bukkit/craftbukkit/CraftServer.java @@ -187,6 +187,7 @@ import org.bukkit.craftbukkit.metadata.EntityMetadataStore; import org.bukkit.craftbukkit.metadata.PlayerMetadataStore; import org.bukkit.craftbukkit.metadata.WorldMetadataStore; import org.bukkit.craftbukkit.potion.CraftPotionBrewer; +import org.bukkit.craftbukkit.profile.CraftPlayerProfile; import org.bukkit.craftbukkit.scheduler.CraftScheduler; import org.bukkit.craftbukkit.scoreboard.CraftScoreboardManager; import org.bukkit.craftbukkit.structure.CraftStructureManager; @@ -244,6 +245,7 @@ import org.bukkit.plugin.messaging.Messenger; import org.bukkit.plugin.messaging.StandardMessenger; import org.bukkit.potion.Potion; import org.bukkit.potion.PotionEffectType; +import org.bukkit.profile.PlayerProfile; import org.bukkit.scheduler.BukkitWorker; import org.bukkit.structure.StructureManager; import org.bukkit.util.StringUtil; @@ -294,6 +296,7 @@ public final class CraftServer implements Server { static { ConfigurationSerialization.registerClass(CraftOfflinePlayer.class); + ConfigurationSerialization.registerClass(CraftPlayerProfile.class); CraftItemFactory.instance(); } @@ -1643,6 +1646,21 @@ public final class CraftServer implements Server { return result; } + @Override + public PlayerProfile createPlayerProfile(UUID uniqueId, String name) { + return new CraftPlayerProfile(uniqueId, name); + } + + @Override + public PlayerProfile createPlayerProfile(UUID uniqueId) { + return new CraftPlayerProfile(uniqueId, null); + } + + @Override + public PlayerProfile createPlayerProfile(String name) { + return new CraftPlayerProfile(null, name); + } + public OfflinePlayer getOfflinePlayer(GameProfile profile) { OfflinePlayer player = new CraftOfflinePlayer(this, profile); offlinePlayers.put(profile.getId(), player); diff --git a/paper-server/src/main/java/org/bukkit/craftbukkit/block/CraftSkull.java b/paper-server/src/main/java/org/bukkit/craftbukkit/block/CraftSkull.java index a421e6d3a7..b60a96f852 100644 --- a/paper-server/src/main/java/org/bukkit/craftbukkit/block/CraftSkull.java +++ b/paper-server/src/main/java/org/bukkit/craftbukkit/block/CraftSkull.java @@ -14,6 +14,8 @@ import org.bukkit.block.data.BlockData; import org.bukkit.block.data.Directional; import org.bukkit.block.data.Rotatable; import org.bukkit.craftbukkit.entity.CraftPlayer; +import org.bukkit.craftbukkit.profile.CraftPlayerProfile; +import org.bukkit.profile.PlayerProfile; public class CraftSkull extends CraftBlockEntityState implements Skull { @@ -100,6 +102,24 @@ public class CraftSkull extends CraftBlockEntityState implement } } + @Override + public PlayerProfile getOwnerProfile() { + if (!hasOwner()) { + return null; + } + + return new CraftPlayerProfile(profile); + } + + @Override + public void setOwnerProfile(PlayerProfile profile) { + if (profile == null) { + this.profile = null; + } else { + this.profile = CraftPlayerProfile.validateSkullProfile(((CraftPlayerProfile) profile).buildGameProfile()); + } + } + @Override public BlockFace getRotation() { BlockData blockData = getBlockData(); diff --git a/paper-server/src/main/java/org/bukkit/craftbukkit/configuration/ConfigSerializationUtil.java b/paper-server/src/main/java/org/bukkit/craftbukkit/configuration/ConfigSerializationUtil.java new file mode 100644 index 0000000000..6548814b0a --- /dev/null +++ b/paper-server/src/main/java/org/bukkit/craftbukkit/configuration/ConfigSerializationUtil.java @@ -0,0 +1,39 @@ +package org.bukkit.craftbukkit.configuration; + +import java.util.Map; +import java.util.NoSuchElementException; +import java.util.UUID; +import org.bukkit.configuration.serialization.ConfigurationSerializable; + +/** + * Utilities related to the serialization and deserialization of {@link ConfigurationSerializable}s. + */ +public final class ConfigSerializationUtil { + + public static String getString(Map map, String key, boolean nullable) { + return getObject(String.class, map, key, nullable); + } + + public static UUID getUuid(Map map, String key, boolean nullable) { + String uuidString = ConfigSerializationUtil.getString(map, key, nullable); + if (uuidString == null) return null; + return UUID.fromString(uuidString); + } + + public static T getObject(Class clazz, Map map, String key, boolean nullable) { + final Object object = map.get(key); + if (clazz.isInstance(object)) { + return clazz.cast(object); + } + if (object == null) { + if (!nullable) { + throw new NoSuchElementException(map + " does not contain " + key); + } + return null; + } + throw new IllegalArgumentException(key + "(" + object + ") is not a valid " + clazz); + } + + private ConfigSerializationUtil() { + } +} diff --git a/paper-server/src/main/java/org/bukkit/craftbukkit/entity/CraftPlayer.java b/paper-server/src/main/java/org/bukkit/craftbukkit/entity/CraftPlayer.java index f852d6d993..1cf4e71d53 100644 --- a/paper-server/src/main/java/org/bukkit/craftbukkit/entity/CraftPlayer.java +++ b/paper-server/src/main/java/org/bukkit/craftbukkit/entity/CraftPlayer.java @@ -119,6 +119,7 @@ import org.bukkit.craftbukkit.conversations.ConversationTracker; import org.bukkit.craftbukkit.inventory.CraftItemStack; import org.bukkit.craftbukkit.map.CraftMapView; import org.bukkit.craftbukkit.map.RenderData; +import org.bukkit.craftbukkit.profile.CraftPlayerProfile; import org.bukkit.craftbukkit.scoreboard.CraftScoreboard; import org.bukkit.craftbukkit.util.CraftChatMessage; import org.bukkit.craftbukkit.util.CraftMagicNumbers; @@ -139,6 +140,7 @@ import org.bukkit.map.MapView; import org.bukkit.metadata.MetadataValue; import org.bukkit.plugin.Plugin; import org.bukkit.plugin.messaging.StandardMessenger; +import org.bukkit.profile.PlayerProfile; import org.bukkit.scoreboard.Scoreboard; @DelegateDeserialization(CraftOfflinePlayer.class) @@ -188,6 +190,11 @@ public class CraftPlayer extends CraftHumanEntity implements Player { return server.getPlayer(getUniqueId()) != null; } + @Override + public PlayerProfile getPlayerProfile() { + return new CraftPlayerProfile(getProfile()); + } + @Override public InetSocketAddress getAddress() { if (getHandle().connection == null) return null; diff --git a/paper-server/src/main/java/org/bukkit/craftbukkit/inventory/CraftMetaSkull.java b/paper-server/src/main/java/org/bukkit/craftbukkit/inventory/CraftMetaSkull.java index cc0c0e16fd..1ad14d41ce 100644 --- a/paper-server/src/main/java/org/bukkit/craftbukkit/inventory/CraftMetaSkull.java +++ b/paper-server/src/main/java/org/bukkit/craftbukkit/inventory/CraftMetaSkull.java @@ -15,8 +15,10 @@ import org.bukkit.configuration.serialization.DelegateDeserialization; import org.bukkit.craftbukkit.entity.CraftPlayer; import org.bukkit.craftbukkit.inventory.CraftMetaItem.ItemMetaKey; import org.bukkit.craftbukkit.inventory.CraftMetaItem.SerializableMeta; +import org.bukkit.craftbukkit.profile.CraftPlayerProfile; import org.bukkit.craftbukkit.util.CraftMagicNumbers; import org.bukkit.inventory.meta.SkullMeta; +import org.bukkit.profile.PlayerProfile; @DelegateDeserialization(SerializableMeta.class) class CraftMetaSkull extends CraftMetaItem implements SkullMeta { @@ -52,7 +54,12 @@ class CraftMetaSkull extends CraftMetaItem implements SkullMeta { CraftMetaSkull(Map map) { super(map); if (profile == null) { - setOwner(SerializableMeta.getString(map, SKULL_OWNER.BUKKIT, true)); + Object object = map.get(SKULL_OWNER.BUKKIT); + if (object instanceof PlayerProfile) { + setOwnerProfile((PlayerProfile) object); + } else { + setOwner(SerializableMeta.getString(map, SKULL_OWNER.BUKKIT, true)); + } } } @@ -187,6 +194,24 @@ class CraftMetaSkull extends CraftMetaItem implements SkullMeta { return true; } + @Override + public PlayerProfile getOwnerProfile() { + if (!hasOwner()) { + return null; + } + + return new CraftPlayerProfile(profile); + } + + @Override + public void setOwnerProfile(PlayerProfile profile) { + if (profile == null) { + setProfile(null); + } else { + setProfile(CraftPlayerProfile.validateSkullProfile(((CraftPlayerProfile) profile).buildGameProfile())); + } + } + @Override int applyHash() { final int original; @@ -220,7 +245,7 @@ class CraftMetaSkull extends CraftMetaItem implements SkullMeta { Builder serialize(Builder builder) { super.serialize(builder); if (hasOwner()) { - return builder.put(SKULL_OWNER.BUKKIT, this.profile.getName()); + return builder.put(SKULL_OWNER.BUKKIT, new CraftPlayerProfile(this.profile)); } return builder; } diff --git a/paper-server/src/main/java/org/bukkit/craftbukkit/profile/CraftPlayerProfile.java b/paper-server/src/main/java/org/bukkit/craftbukkit/profile/CraftPlayerProfile.java new file mode 100644 index 0000000000..472457b0d0 --- /dev/null +++ b/paper-server/src/main/java/org/bukkit/craftbukkit/profile/CraftPlayerProfile.java @@ -0,0 +1,276 @@ +package org.bukkit.craftbukkit.profile; + +import com.google.common.base.Preconditions; +import com.google.common.collect.Iterables; +import com.mojang.authlib.GameProfile; +import com.mojang.authlib.properties.Property; +import com.mojang.authlib.properties.PropertyMap; +import java.util.ArrayList; +import java.util.Iterator; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.UUID; +import java.util.concurrent.CompletableFuture; +import java.util.stream.Collectors; +import javax.annotation.Nonnull; +import javax.annotation.Nullable; +import net.minecraft.SystemUtils; +import net.minecraft.server.dedicated.DedicatedServer; +import org.apache.commons.lang.StringUtils; +import org.bukkit.Bukkit; +import org.bukkit.configuration.serialization.SerializableAs; +import org.bukkit.craftbukkit.CraftServer; +import org.bukkit.craftbukkit.configuration.ConfigSerializationUtil; +import org.bukkit.profile.PlayerProfile; +import org.bukkit.profile.PlayerTextures; + +@SerializableAs("PlayerProfile") +public final class CraftPlayerProfile implements PlayerProfile { + + @Nonnull + public static GameProfile validateSkullProfile(@Nonnull GameProfile gameProfile) { + // The GameProfile needs to contain either both a uuid and textures, or a name. + // The GameProfile always has a name or a uuid, so checking if it has a name is sufficient. + boolean isValidSkullProfile = (gameProfile.getName() != null) + || gameProfile.getProperties().containsKey(CraftPlayerTextures.PROPERTY_NAME); + Preconditions.checkArgument(isValidSkullProfile, "The skull profile is missing a name or textures!"); + return gameProfile; + } + + @Nullable + public static Property getProperty(@Nonnull GameProfile profile, String propertyName) { + return Iterables.getFirst(profile.getProperties().get(propertyName), null); + } + + private final UUID uniqueId; + private final String name; + + private final PropertyMap properties = new PropertyMap(); + private final CraftPlayerTextures textures = new CraftPlayerTextures(this); + + public CraftPlayerProfile(UUID uniqueId, String name) { + Preconditions.checkArgument((uniqueId != null) || !StringUtils.isBlank(name), "uniqueId is null or name is blank"); + this.uniqueId = uniqueId; + this.name = name; + } + + // The Map of properties of the given GameProfile is not immutable. This captures a snapshot of the properties of + // the given GameProfile at the time this CraftPlayerProfile is created. + public CraftPlayerProfile(@Nonnull GameProfile gameProfile) { + this(gameProfile.getId(), gameProfile.getName()); + properties.putAll(gameProfile.getProperties()); + } + + private CraftPlayerProfile(@Nonnull CraftPlayerProfile other) { + this(other.uniqueId, other.name); + this.properties.putAll(other.properties); + this.textures.copyFrom(other.textures); + } + + @Override + public UUID getUniqueId() { + return uniqueId; + } + + @Override + public String getName() { + return name; + } + + @Nullable + Property getProperty(String propertyName) { + return Iterables.getFirst(properties.get(propertyName), null); + } + + void setProperty(String propertyName, @Nullable Property property) { + // Assert: (property == null) || property.getName().equals(propertyName) + removeProperty(propertyName); + if (property != null) { + properties.put(property.getName(), property); + } + } + + void removeProperty(String propertyName) { + properties.removeAll(propertyName); + } + + void rebuildDirtyProperties() { + textures.rebuildPropertyIfDirty(); + } + + @Override + public CraftPlayerTextures getTextures() { + return textures; + } + + @Override + public void setTextures(@Nullable PlayerTextures textures) { + if (textures == null) { + this.textures.clear(); + } else { + this.textures.copyFrom(textures); + } + } + + @Override + public boolean isComplete() { + return (uniqueId != null) && (name != null) && !textures.isEmpty(); + } + + @Override + public CompletableFuture update() { + return CompletableFuture.supplyAsync(this::getUpdatedProfile, SystemUtils.backgroundExecutor()); + } + + private CraftPlayerProfile getUpdatedProfile() { + DedicatedServer server = ((CraftServer) Bukkit.getServer()).getServer(); + GameProfile profile = this.buildGameProfile(); + + // If missing, look up the uuid by name: + if (profile.getId() == null) { + profile = server.getProfileCache().get(profile.getName()).orElse(profile); + } + + // Look up properties such as the textures: + if (profile.getId() != null) { + GameProfile newProfile = server.getSessionService().fillProfileProperties(profile, true); + if (newProfile != null) { + profile = newProfile; + } + } + + return new CraftPlayerProfile(profile); + } + + // This always returns a new GameProfile instance to ensure that property changes to the original or previously + // built GameProfiles don't affect the use of this profile in other contexts. + @Nonnull + public GameProfile buildGameProfile() { + rebuildDirtyProperties(); + GameProfile profile = new GameProfile(uniqueId, name); + profile.getProperties().putAll(properties); + return profile; + } + + @Override + public String toString() { + rebuildDirtyProperties(); + StringBuilder builder = new StringBuilder(); + builder.append("CraftPlayerProfile [uniqueId="); + builder.append(uniqueId); + builder.append(", name="); + builder.append(name); + builder.append(", properties="); + builder.append(toString(properties)); + builder.append("]"); + return builder.toString(); + } + + private static String toString(@Nonnull PropertyMap propertyMap) { + StringBuilder builder = new StringBuilder(); + builder.append("{"); + propertyMap.asMap().forEach((propertyName, properties) -> { + builder.append(propertyName); + builder.append("="); + builder.append(properties.stream().map(CraftProfileProperty::toString).collect(Collectors.joining(",", "[", "]"))); + }); + builder.append("}"); + return builder.toString(); + } + + @Override + public boolean equals(Object obj) { + if (this == obj) return true; + if (!(obj instanceof CraftPlayerProfile)) return false; + CraftPlayerProfile other = (CraftPlayerProfile) obj; + if (!Objects.equals(uniqueId, other.uniqueId)) return false; + if (!Objects.equals(name, other.name)) return false; + + rebuildDirtyProperties(); + other.rebuildDirtyProperties(); + if (!equals(properties, other.properties)) return false; + return true; + } + + private static boolean equals(@Nonnull PropertyMap propertyMap, @Nonnull PropertyMap other) { + if (propertyMap.size() != other.size()) return false; + // We take the order of properties into account here, because it is + // also relevant in the serialized and NBT forms of GameProfiles. + Iterator iterator1 = propertyMap.values().iterator(); + Iterator iterator2 = other.values().iterator(); + while (iterator1.hasNext()) { + if (!iterator2.hasNext()) return false; + Property property1 = iterator1.next(); + Property property2 = iterator2.next(); + if (!CraftProfileProperty.equals(property1, property2)) { + return false; + } + } + return !iterator2.hasNext(); + } + + @Override + public int hashCode() { + rebuildDirtyProperties(); + int result = 1; + result = 31 * result + Objects.hashCode(uniqueId); + result = 31 * result + Objects.hashCode(name); + result = 31 * result + hashCode(properties); + return result; + } + + private static int hashCode(PropertyMap propertyMap) { + int result = 1; + for (Property property : propertyMap.values()) { + result = 31 * result + CraftProfileProperty.hashCode(property); + } + return result; + } + + @Override + public CraftPlayerProfile clone() { + return new CraftPlayerProfile(this); + } + + @Override + public Map serialize() { + Map map = new LinkedHashMap<>(); + if (uniqueId != null) { + map.put("uniqueId", uniqueId.toString()); + } + if (name != null) { + map.put("name", name); + } + rebuildDirtyProperties(); + if (!properties.isEmpty()) { + List propertiesData = new ArrayList<>(); + properties.forEach((propertyName, property) -> { + propertiesData.add(CraftProfileProperty.serialize(property)); + }); + map.put("properties", propertiesData); + } + return map; + } + + public static CraftPlayerProfile deserialize(Map map) { + UUID uniqueId = ConfigSerializationUtil.getUuid(map, "uniqueId", true); + String name = ConfigSerializationUtil.getString(map, "name", true); + + // This also validates the deserialized unique id and name (ensures that not both are null): + CraftPlayerProfile profile = new CraftPlayerProfile(uniqueId, name); + + if (map.containsKey("properties")) { + for (Object propertyData : (List) map.get("properties")) { + if (!(propertyData instanceof Map)) { + throw new IllegalArgumentException("Property data (" + propertyData + ") is not a valid Map"); + } + Property property = CraftProfileProperty.deserialize((Map) propertyData); + profile.properties.put(property.getName(), property); + } + } + + return profile; + } +} diff --git a/paper-server/src/main/java/org/bukkit/craftbukkit/profile/CraftPlayerTextures.java b/paper-server/src/main/java/org/bukkit/craftbukkit/profile/CraftPlayerTextures.java new file mode 100644 index 0000000000..674a0d4255 --- /dev/null +++ b/paper-server/src/main/java/org/bukkit/craftbukkit/profile/CraftPlayerTextures.java @@ -0,0 +1,317 @@ +package org.bukkit.craftbukkit.profile; + +import com.google.common.base.Preconditions; +import com.google.gson.JsonObject; +import com.google.gson.JsonPrimitive; +import com.mojang.authlib.minecraft.MinecraftProfileTexture; +import com.mojang.authlib.properties.Property; +import java.net.MalformedURLException; +import java.net.URL; +import java.util.Locale; +import java.util.Objects; +import javax.annotation.Nonnull; +import javax.annotation.Nullable; +import org.bukkit.craftbukkit.util.JsonHelper; +import org.bukkit.profile.PlayerTextures; + +final class CraftPlayerTextures implements PlayerTextures { + + static final String PROPERTY_NAME = "textures"; + private static final String MINECRAFT_HOST = "textures.minecraft.net"; + private static final String MINECRAFT_PATH = "/texture/"; + + private static void validateTextureUrl(@Nullable URL url) { + // Null represents an unset texture and is therefore valid. + if (url == null) return; + + Preconditions.checkArgument(url.getHost().equals(MINECRAFT_HOST), "Expected host '%s' but got '%s'", MINECRAFT_HOST, url.getHost()); + Preconditions.checkArgument(url.getPath().startsWith(MINECRAFT_PATH), "Expected path starting with '%s' but got '%s", MINECRAFT_PATH, url.getPath()); + } + + @Nullable + private static URL parseUrl(@Nullable String urlString) { + if (urlString == null) return null; + try { + return new URL(urlString); + } catch (MalformedURLException e) { + return null; + } + } + + @Nullable + private static SkinModel parseSkinModel(@Nullable String skinModelName) { + if (skinModelName == null) return null; + try { + return SkinModel.valueOf(skinModelName.toUpperCase(Locale.ROOT)); + } catch (IllegalArgumentException e) { + return null; + } + } + + private final CraftPlayerProfile profile; + + // The textures data is loaded lazily: + private boolean loaded = false; + private JsonObject data; // Immutable contents (only read) + private long timestamp; + + // Lazily decoded textures data that can subsequently be overwritten: + private URL skin; + private SkinModel skinModel = SkinModel.CLASSIC; + private URL cape; + + // Dirty: Indicates a change that requires a rebuild of the property. + // This also indicates an invalidation of any previously present textures data that is specific to official + // GameProfiles, such as the property signature, timestamp, profileId, playerName, etc.: Any modifications by + // plugins that affect the textures property immediately invalidate all attributes that are specific to official + // GameProfiles (even if these modifications are later reverted). + private boolean dirty = false; + + CraftPlayerTextures(@Nonnull CraftPlayerProfile profile) { + this.profile = profile; + } + + void copyFrom(@Nonnull PlayerTextures other) { + if (other == this) return; + Preconditions.checkArgument(other instanceof CraftPlayerTextures, "Expecting CraftPlayerTextures, got %s", other.getClass().getName()); + CraftPlayerTextures otherTextures = (CraftPlayerTextures) other; + clear(); + Property texturesProperty = otherTextures.getProperty(); + profile.setProperty(PROPERTY_NAME, texturesProperty); + if (texturesProperty != null + && (!Objects.equals(profile.getUniqueId(), otherTextures.profile.getUniqueId()) + || !Objects.equals(profile.getName(), otherTextures.profile.getName()))) { + // We might need to rebuild the textures property for this profile: + // TODO Only rebuild if the textures property actually stores an incompatible profileId/playerName? + ensureLoaded(); + markDirty(); + rebuildPropertyIfDirty(); + } + } + + private void ensureLoaded() { + if (loaded) return; + loaded = true; + + Property property = getProperty(); + if (property == null) return; + + data = CraftProfileProperty.decodePropertyValue(property.getValue()); + if (data != null) { + JsonObject texturesMap = JsonHelper.getObjectOrNull(data, "textures"); + loadSkin(texturesMap); + loadCape(texturesMap); + loadTimestamp(); + } + } + + private void loadSkin(@Nullable JsonObject texturesMap) { + if (texturesMap == null) return; + JsonObject texture = JsonHelper.getObjectOrNull(texturesMap, MinecraftProfileTexture.Type.SKIN.name()); + if (texture == null) return; + + String skinUrlString = JsonHelper.getStringOrNull(texture, "url"); + this.skin = parseUrl(skinUrlString); + this.skinModel = loadSkinModel(texture); + + // Special case: If a skin is present, but no skin model, we use the default classic skin model. + if (skinModel == null && skin != null) { + skinModel = SkinModel.CLASSIC; + } + } + + @Nullable + private static SkinModel loadSkinModel(@Nullable JsonObject texture) { + if (texture == null) return null; + JsonObject metadata = JsonHelper.getObjectOrNull(texture, "metadata"); + if (metadata == null) return null; + + String skinModelName = JsonHelper.getStringOrNull(metadata, "model"); + return parseSkinModel(skinModelName); + } + + private void loadCape(@Nullable JsonObject texturesMap) { + if (texturesMap == null) return; + JsonObject texture = JsonHelper.getObjectOrNull(texturesMap, MinecraftProfileTexture.Type.CAPE.name()); + if (texture == null) return; + + String skinUrlString = JsonHelper.getStringOrNull(texture, "url"); + this.cape = parseUrl(skinUrlString); + } + + private void loadTimestamp() { + if (data == null) return; + JsonPrimitive timestamp = JsonHelper.getPrimitiveOrNull(data, "timestamp"); + if (timestamp == null) return; + + try { + this.timestamp = timestamp.getAsLong(); + } catch (NumberFormatException e) { + } + } + + private void markDirty() { + dirty = true; + + // Clear any cached but no longer valid data: + data = null; + timestamp = 0L; + } + + @Override + public boolean isEmpty() { + ensureLoaded(); + return (skin == null) && (cape == null); + } + + @Override + public void clear() { + profile.removeProperty(PROPERTY_NAME); + loaded = false; + data = null; + timestamp = 0L; + skin = null; + skinModel = SkinModel.CLASSIC; + cape = null; + dirty = false; + } + + @Override + public URL getSkin() { + ensureLoaded(); + return skin; + } + + @Override + public void setSkin(URL skinUrl) { + setSkin(skinUrl, SkinModel.CLASSIC); + } + + @Override + public void setSkin(URL skinUrl, SkinModel skinModel) { + validateTextureUrl(skinUrl); + if (skinModel == null) skinModel = SkinModel.CLASSIC; + // This also loads the textures if necessary: + if (Objects.equals(getSkin(), skinUrl) && Objects.equals(getSkinModel(), skinModel)) return; + this.skin = skinUrl; + this.skinModel = (skinUrl != null) ? skinModel : SkinModel.CLASSIC; + markDirty(); + } + + @Override + public SkinModel getSkinModel() { + ensureLoaded(); + return skinModel; + } + + @Override + public URL getCape() { + ensureLoaded(); + return cape; + } + + @Override + public void setCape(URL capeUrl) { + validateTextureUrl(capeUrl); + // This also loads the textures if necessary: + if (Objects.equals(getCape(), capeUrl)) return; + this.cape = capeUrl; + markDirty(); + } + + @Override + public long getTimestamp() { + ensureLoaded(); + return timestamp; + } + + @Override + public boolean isSigned() { + if (dirty) return false; + Property property = getProperty(); + return property != null && CraftProfileProperty.hasValidSignature(property); + } + + @Nullable + Property getProperty() { + rebuildPropertyIfDirty(); + return profile.getProperty(PROPERTY_NAME); + } + + void rebuildPropertyIfDirty() { + if (!dirty) return; + // Assert: loaded + dirty = false; + + if (isEmpty()) { + profile.removeProperty(PROPERTY_NAME); + return; + } + + // This produces a new textures property that does not contain any attributes that are specific to official + // GameProfiles (such as the property signature, timestamp, profileId, playerName, etc.). + // Information on the format of the textures property: + // * https://minecraft.fandom.com/wiki/Head#Item_data + // * https://wiki.vg/Mojang_API#UUID_to_Profile_and_Skin.2FCape + // The order of Json object elements is important. + JsonObject propertyData = new JsonObject(); + + if (skin != null) { + JsonObject texturesMap = JsonHelper.getOrCreateObject(propertyData, "textures"); + JsonObject skinTexture = JsonHelper.getOrCreateObject(texturesMap, MinecraftProfileTexture.Type.SKIN.name()); + skinTexture.addProperty("url", skin.toExternalForm()); + + // Special case: If the skin model is classic (i.e. default), omit it. + // Assert: skinModel != null + if (skinModel != SkinModel.CLASSIC) { + JsonObject metadata = JsonHelper.getOrCreateObject(skinTexture, "metadata"); + metadata.addProperty("model", skinModel.name().toLowerCase(Locale.ROOT)); + } + } + + if (cape != null) { + JsonObject texturesMap = JsonHelper.getOrCreateObject(propertyData, "textures"); + JsonObject skinTexture = JsonHelper.getOrCreateObject(texturesMap, MinecraftProfileTexture.Type.CAPE.name()); + skinTexture.addProperty("url", cape.toExternalForm()); + } + + this.data = propertyData; + + // We use the compact formatter here since this is more likely to match the output of existing popular tools + // that also create profiles with custom textures: + String encodedTexturesData = CraftProfileProperty.encodePropertyValue(propertyData, CraftProfileProperty.JsonFormatter.COMPACT); + Property property = new Property(PROPERTY_NAME, encodedTexturesData); + profile.setProperty(PROPERTY_NAME, property); + } + + private JsonObject getData() { + ensureLoaded(); + rebuildPropertyIfDirty(); + return data; + } + + @Override + public String toString() { + StringBuilder builder = new StringBuilder(); + builder.append("CraftPlayerTextures [data="); + builder.append(getData()); + builder.append("]"); + return builder.toString(); + } + + @Override + public int hashCode() { + Property property = getProperty(); + return (property == null) ? 0 : CraftProfileProperty.hashCode(property); + } + + @Override + public boolean equals(Object obj) { + if (this == obj) return true; + if (!(obj instanceof CraftPlayerTextures)) return false; + CraftPlayerTextures other = (CraftPlayerTextures) obj; + Property property = getProperty(); + Property otherProperty = other.getProperty(); + return CraftProfileProperty.equals(property, otherProperty); + } +} diff --git a/paper-server/src/main/java/org/bukkit/craftbukkit/profile/CraftProfileProperty.java b/paper-server/src/main/java/org/bukkit/craftbukkit/profile/CraftProfileProperty.java new file mode 100644 index 0000000000..9d7715f39a --- /dev/null +++ b/paper-server/src/main/java/org/bukkit/craftbukkit/profile/CraftProfileProperty.java @@ -0,0 +1,139 @@ +package org.bukkit.craftbukkit.profile; + +import com.google.gson.Gson; +import com.google.gson.GsonBuilder; +import com.google.gson.JsonElement; +import com.google.gson.JsonObject; +import com.google.gson.JsonParseException; +import com.google.gson.JsonParser; +import com.mojang.authlib.properties.Property; +import com.mojang.authlib.yggdrasil.YggdrasilMinecraftSessionService; +import java.nio.charset.StandardCharsets; +import java.security.KeyFactory; +import java.security.PublicKey; +import java.security.spec.X509EncodedKeySpec; +import java.util.Base64; +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.Objects; +import javax.annotation.Nonnull; +import javax.annotation.Nullable; +import org.apache.commons.io.IOUtils; +import org.bukkit.craftbukkit.configuration.ConfigSerializationUtil; + +final class CraftProfileProperty { + + /** + * Different JSON formatting styles to use for encoded property values. + */ + public interface JsonFormatter { + + /** + * A {@link JsonFormatter} that uses a compact formatting style. + */ + public static final JsonFormatter COMPACT = new JsonFormatter() { + + private final Gson gson = new GsonBuilder().create(); + + @Override + public String format(JsonElement jsonElement) { + return gson.toJson(jsonElement); + } + }; + + public String format(JsonElement jsonElement); + } + + private static final PublicKey PUBLIC_KEY; + + static { + try { + X509EncodedKeySpec spec = new X509EncodedKeySpec(IOUtils.toByteArray(YggdrasilMinecraftSessionService.class.getResourceAsStream("/yggdrasil_session_pubkey.der"))); + PUBLIC_KEY = KeyFactory.getInstance("RSA").generatePublic(spec); + } catch (Exception e) { + throw new Error("Could not find yggdrasil_session_pubkey.der! This indicates a bug."); + } + } + + public static boolean hasValidSignature(@Nonnull Property property) { + return property.hasSignature() && property.isSignatureValid(PUBLIC_KEY); + } + + @Nullable + private static String decodeBase64(@Nonnull String encoded) { + try { + return new String(Base64.getDecoder().decode(encoded), StandardCharsets.UTF_8); + } catch (IllegalArgumentException e) { + return null; // Invalid input + } + } + + @Nullable + public static JsonObject decodePropertyValue(@Nonnull String encodedPropertyValue) { + String json = decodeBase64(encodedPropertyValue); + if (json == null) return null; + try { + JsonElement jsonElement = JsonParser.parseString(json); + if (!jsonElement.isJsonObject()) return null; + return jsonElement.getAsJsonObject(); + } catch (JsonParseException e) { + return null; // Invalid input + } + } + + @Nonnull + public static String encodePropertyValue(@Nonnull JsonObject propertyValue, @Nonnull JsonFormatter formatter) { + String json = formatter.format(propertyValue); + return Base64.getEncoder().encodeToString(json.getBytes(StandardCharsets.UTF_8)); + } + + @Nonnull + public static String toString(@Nonnull Property property) { + StringBuilder builder = new StringBuilder(); + builder.append("{"); + builder.append("name="); + builder.append(property.getName()); + builder.append(", value="); + builder.append(property.getValue()); + builder.append(", signature="); + builder.append(property.getSignature()); + builder.append("}"); + return builder.toString(); + } + + public static int hashCode(@Nonnull Property property) { + int result = 1; + result = 31 * result + Objects.hashCode(property.getName()); + result = 31 * result + Objects.hashCode(property.getValue()); + result = 31 * result + Objects.hashCode(property.getSignature()); + return result; + } + + public static boolean equals(@Nullable Property property, @Nullable Property other) { + if (property == null || other == null) return (property == other); + if (!Objects.equals(property.getValue(), other.getValue())) return false; + if (!Objects.equals(property.getName(), other.getName())) return false; + if (!Objects.equals(property.getSignature(), other.getSignature())) return false; + return true; + } + + public static Map serialize(@Nonnull Property property) { + Map map = new LinkedHashMap<>(); + map.put("name", property.getName()); + map.put("value", property.getValue()); + if (property.hasSignature()) { + map.put("signature", property.getSignature()); + } + return map; + } + + public static Property deserialize(@Nonnull Map map) { + String name = ConfigSerializationUtil.getString(map, "name", false); + String value = ConfigSerializationUtil.getString(map, "value", false); + String signature = ConfigSerializationUtil.getString(map, "signature", true); + return new Property(name, value, signature); + } + + private CraftProfileProperty() { + } +} diff --git a/paper-server/src/main/java/org/bukkit/craftbukkit/util/JsonHelper.java b/paper-server/src/main/java/org/bukkit/craftbukkit/util/JsonHelper.java new file mode 100644 index 0000000000..fea5ea47f2 --- /dev/null +++ b/paper-server/src/main/java/org/bukkit/craftbukkit/util/JsonHelper.java @@ -0,0 +1,49 @@ +package org.bukkit.craftbukkit.util; + +import com.google.gson.JsonElement; +import com.google.gson.JsonObject; +import com.google.gson.JsonPrimitive; +import javax.annotation.Nonnull; +import javax.annotation.Nullable; + +public final class JsonHelper { + + @Nullable + public static JsonObject getObjectOrNull(@Nonnull JsonObject parent, @Nonnull String key) { + JsonElement element = parent.get(key); + return (element instanceof JsonObject) ? (JsonObject) element : null; + } + + @Nonnull + public static JsonObject getOrCreateObject(@Nonnull JsonObject parent, @Nonnull String key) { + JsonObject jsonObject = getObjectOrNull(parent, key); + if (jsonObject == null) { + jsonObject = new JsonObject(); + parent.add(key, jsonObject); + } + return jsonObject; + } + + @Nullable + public static JsonPrimitive getPrimitiveOrNull(@Nonnull JsonObject parent, @Nonnull String key) { + JsonElement element = parent.get(key); + return (element instanceof JsonPrimitive) ? (JsonPrimitive) element : null; + } + + @Nullable + public static String getStringOrNull(JsonObject parent, String key) { + JsonPrimitive primitive = getPrimitiveOrNull(parent, key); + return (primitive != null) ? primitive.getAsString() : null; + } + + public static void setOrRemove(@Nonnull JsonObject parent, @Nonnull String key, @Nullable JsonElement value) { + if (value == null) { + parent.remove(key); + } else { + parent.add(key, value); + } + } + + private JsonHelper() { + } +} diff --git a/paper-server/src/test/java/org/bukkit/craftbukkit/profile/PlayerProfileTest.java b/paper-server/src/test/java/org/bukkit/craftbukkit/profile/PlayerProfileTest.java new file mode 100644 index 0000000000..ba3b67fea7 --- /dev/null +++ b/paper-server/src/test/java/org/bukkit/craftbukkit/profile/PlayerProfileTest.java @@ -0,0 +1,260 @@ +package org.bukkit.craftbukkit.profile; + +import com.mojang.authlib.GameProfile; +import com.mojang.authlib.properties.Property; +import java.net.MalformedURLException; +import java.net.URL; +import java.util.UUID; +import org.bukkit.configuration.InvalidConfigurationException; +import org.bukkit.configuration.file.YamlConfiguration; +import org.bukkit.configuration.serialization.ConfigurationSerialization; +import org.bukkit.profile.PlayerProfile; +import org.bukkit.profile.PlayerTextures; +import org.junit.Assert; +import org.junit.Test; + +public class PlayerProfileTest { + + /* + { + "timestamp" : 1636282649111, + "profileId" : "29a4042b05ab4c7294607aa3b567e8da", + "profileName" : "DerFrZocker", + "signatureRequired" : true, + "textures" : { + "SKIN" : { + "url" : "http://textures.minecraft.net/texture/284dbf60700b9882c0c2ad1943b515cc111f0b4e562a9a36682495636d846754", + "metadata" : { + "model" : "slim" + } + }, + "CAPE" : { + "url" : "http://textures.minecraft.net/texture/2340c0e03dd24a11b15a8b33c2a7e9e32abb2051b2481d0ba7defd635ca7a933" + } + } + } + */ + private static final UUID UNIQUE_ID = UUID.fromString("29a4042b-05ab-4c72-9460-7aa3b567e8da"); + private static final String NAME = "DerFrZocker"; + private static final URL SKIN; + private static final URL CAPE; + static { + try { + SKIN = new URL("http://textures.minecraft.net/texture/284dbf60700b9882c0c2ad1943b515cc111f0b4e562a9a36682495636d846754"); + CAPE = new URL("http://textures.minecraft.net/texture/2340c0e03dd24a11b15a8b33c2a7e9e32abb2051b2481d0ba7defd635ca7a933"); + } catch (MalformedURLException e) { + throw new RuntimeException(e); + } + } + private static final long TIMESTAMP = 1636282649111L; + private static final String VALUE = "ewogICJ0aW1lc3RhbXAiIDogMTYzNjI4MjY0OTExMSwKICAicHJvZmlsZUlkIiA6ICIyOWE0MDQyYjA1YWI0YzcyOTQ2MDdhYTNiNTY3ZThkYSIsCiAgInByb2ZpbGVOYW1lIiA6ICJEZXJGclpvY2tlciIsCiAgInNpZ25hdHVyZVJlcXVpcmVkIiA6IHRydWUsCiAgInRleHR1cmVzIiA6IHsKICAgICJTS0lOIiA6IHsKICAgICAgInVybCIgOiAiaHR0cDovL3RleHR1cmVzLm1pbmVjcmFmdC5uZXQvdGV4dHVyZS8yODRkYmY2MDcwMGI5ODgyYzBjMmFkMTk0M2I1MTVjYzExMWYwYjRlNTYyYTlhMzY2ODI0OTU2MzZkODQ2NzU0IiwKICAgICAgIm1ldGFkYXRhIiA6IHsKICAgICAgICAibW9kZWwiIDogInNsaW0iCiAgICAgIH0KICAgIH0sCiAgICAiQ0FQRSIgOiB7CiAgICAgICJ1cmwiIDogImh0dHA6Ly90ZXh0dXJlcy5taW5lY3JhZnQubmV0L3RleHR1cmUvMjM0MGMwZTAzZGQyNGExMWIxNWE4YjMzYzJhN2U5ZTMyYWJiMjA1MWIyNDgxZDBiYTdkZWZkNjM1Y2E3YTkzMyIKICAgIH0KICB9Cn0="; + private static final String SIGNATURE = "lci91bn2RkJyQ6gGlfTaTJW3afJopeB5Sud2cgVlQJgEvL3j7kIvnCls+2otzVnI6tzbzoXSHbSnfxs7QW+Rv8bGcoH3lAC8UAkwu6ZOLjSxf3e0l4VJJ5lQncI8PG75tGQuQTldnriAhvtV6Q5c0J7aef0bpin+N31NChh/JdZcjpz9zKXkbNph/sZybGY9OlzBcn0Wd8ZVJOKzTLRKtjC8Z7Eu1pd6ZY6WgAoM+nwzag4EAwk+5HhZxSw/r8tentoGK/6/r8oleIDMJVxDPOglnJoFQJMKjC5nrsNBYx59O7I89JDN02jNIdPSdfPwnbgSiaPzIb+o9AA775iDBsF1bPIZ99dc2cXggVA10eQhSaSWRwfDQ0kkiv9YmdKuPpNhewbmTF4bGz0H3v71pOMHT6bvV5qq7IT3XgqK3YwDrIxH2kpE2K6jsbldjDF2uKs0DPDkjPZArT0L/TxwEf02QzLVxU3ctCk6J7VvGQHTqF9vQHnJLWQNjoXG2W4NfPtH2IaYqiecX0PMc6eL+5RtlCs6viRawx8gOjSEKs3MtvV3BqWB3EDFUc1quuLEiDS3R2NSVScOS7CWhiQWCeh2fjm4lnPHA9OmhoMZcnuy0sdPMDu2Omjd8vVZDv/mqlf6Z7O8+mQSockpOFHmaYhTIGO3qRjdMmQdB3YGLVE="; + // {"textures":{"SKIN":{"url":"http://textures.minecraft.net/texture/b72144309873464f239d9ae0ec49d2e7f9670552cda8a7a85a76282dd09e14dd"}}} + private static final String COMPACT_VALUE = "eyJ0ZXh0dXJlcyI6eyJTS0lOIjp7InVybCI6Imh0dHA6Ly90ZXh0dXJlcy5taW5lY3JhZnQubmV0L3RleHR1cmUvMjg0ZGJmNjA3MDBiOTg4MmMwYzJhZDE5NDNiNTE1Y2MxMTFmMGI0ZTU2MmE5YTM2NjgyNDk1NjM2ZDg0Njc1NCJ9fX0="; + + private static CraftPlayerProfile buildPlayerProfile() { + GameProfile gameProfile = new GameProfile(UNIQUE_ID, NAME); + gameProfile.getProperties().put(CraftPlayerTextures.PROPERTY_NAME, new Property(CraftPlayerTextures.PROPERTY_NAME, VALUE, SIGNATURE)); + return new CraftPlayerProfile(gameProfile); + } + + @Test + public void testProvidedValues() { + Property property = new Property(CraftPlayerTextures.PROPERTY_NAME, VALUE, SIGNATURE); + Assert.assertTrue("Invalid test property signature, has the public key changed?", CraftProfileProperty.hasValidSignature(property)); + } + + @Test + public void testProfileCreation() { + // Invalid profiles: + Assert.assertThrows(IllegalArgumentException.class, () -> { + new CraftPlayerProfile(null, null); + }); + Assert.assertThrows(IllegalArgumentException.class, () -> { + new CraftPlayerProfile(null, ""); + }); + Assert.assertThrows(IllegalArgumentException.class, () -> { + new CraftPlayerProfile(null, " "); + }); + + // Valid profiles: + new CraftPlayerProfile(UNIQUE_ID, null); + new CraftPlayerProfile(null, NAME); + new CraftPlayerProfile(UNIQUE_ID, NAME); + } + + @Test + public void testGameProfileWrapping() { + // Invalid profiles: + Assert.assertThrows(NullPointerException.class, () -> { + new CraftPlayerProfile(null); + }); + + // Valid profiles: + CraftPlayerProfile profile1 = new CraftPlayerProfile(new GameProfile(UNIQUE_ID, NAME)); + Assert.assertEquals("Unique id is not the same", UNIQUE_ID, profile1.getUniqueId()); + Assert.assertEquals("Name is not the same", NAME, profile1.getName()); + + CraftPlayerProfile profile2 = new CraftPlayerProfile(new GameProfile(UNIQUE_ID, null)); + Assert.assertEquals("Unique id is not the same", UNIQUE_ID, profile2.getUniqueId()); + Assert.assertEquals("Name is not null", null, profile2.getName()); + + CraftPlayerProfile profile3 = new CraftPlayerProfile(new GameProfile(null, NAME)); + Assert.assertEquals("Unique id is not null", null, profile3.getUniqueId()); + Assert.assertEquals("Name is not the same", NAME, profile3.getName()); + } + + @Test + public void testTexturesLoading() { + CraftPlayerProfile profile = buildPlayerProfile(); + Assert.assertEquals("Unique id is not the same", UNIQUE_ID, profile.getUniqueId()); + Assert.assertEquals("Name is not the same", NAME, profile.getName()); + Assert.assertEquals("Skin url is not the same", SKIN, profile.getTextures().getSkin()); + Assert.assertEquals("Skin model is not the same", PlayerTextures.SkinModel.SLIM, profile.getTextures().getSkinModel()); + Assert.assertEquals("Cape url is not the same", CAPE, profile.getTextures().getCape()); + Assert.assertEquals("Timestamp is not the same", TIMESTAMP, profile.getTextures().getTimestamp()); + } + + @Test + public void testBuildGameProfile() { + CraftPlayerProfile profile = buildPlayerProfile(); + GameProfile gameProfile = profile.buildGameProfile(); + Assert.assertNotNull("GameProfile is null", gameProfile); + + Property property = CraftPlayerProfile.getProperty(gameProfile, CraftPlayerTextures.PROPERTY_NAME); + Assert.assertNotNull("Textures property is null", property); + Assert.assertEquals("Property values are not the same", VALUE, property.getValue()); + Assert.assertEquals("Names are not the same", NAME, gameProfile.getName()); + Assert.assertEquals("Unique ids are not the same", UNIQUE_ID, gameProfile.getId()); + Assert.assertTrue("Signature is missing", property.hasSignature()); + Assert.assertTrue("Signature is not valid", CraftProfileProperty.hasValidSignature(property)); + } + + @Test + public void testBuildGameProfileReturnsNewInstance() { + CraftPlayerProfile profile = buildPlayerProfile(); + GameProfile gameProfile1 = profile.buildGameProfile(); + GameProfile gameProfile2 = profile.buildGameProfile(); + Assert.assertTrue("CraftPlayerProfile#buildGameProfile() does not produce a new instance", gameProfile1 != gameProfile2); + } + + @Test + public void testSignatureValidation() { + CraftPlayerProfile profile = buildPlayerProfile(); + Assert.assertTrue("Signature is not valid", profile.getTextures().isSigned()); + } + + @Test + public void testSignatureInvalidation() { + CraftPlayerProfile profile = buildPlayerProfile(); + profile.getTextures().setSkin(null); + Assert.assertTrue("Textures has a timestamp", profile.getTextures().getTimestamp() == 0L); + Assert.assertTrue("Textures signature is valid", !profile.getTextures().isSigned()); + + // Ensure that the invalidation is preserved when the property is rebuilt: + profile.rebuildDirtyProperties(); + Assert.assertTrue("Rebuilt textures has a timestamp", profile.getTextures().getTimestamp() == 0L); + Assert.assertTrue("Rebuilt textures signature is valid", !profile.getTextures().isSigned()); + } + + @Test + public void testSetSkinResetsSkinModel() { + CraftPlayerProfile profile = buildPlayerProfile(); + Assert.assertEquals("Skin model is not the same", PlayerTextures.SkinModel.SLIM, profile.getTextures().getSkinModel()); + profile.getTextures().setSkin(SKIN); + Assert.assertEquals("Skin model was not reset by skin change", PlayerTextures.SkinModel.CLASSIC, profile.getTextures().getSkinModel()); + } + + @Test + public void testSetTextures() { + CraftPlayerProfile profile = buildPlayerProfile(); + CraftPlayerProfile profile2 = new CraftPlayerProfile(new GameProfile(UNIQUE_ID, NAME)); + + Assert.assertTrue("profile has no textures", !profile.getTextures().isEmpty()); + Assert.assertTrue("profile2 has textures", profile2.getTextures().isEmpty()); + + profile2.setTextures(profile.getTextures()); + Assert.assertTrue("profile2 has no textures", !profile2.getTextures().isEmpty()); + Assert.assertEquals("copied profile textures are not the same", profile.getTextures(), profile2.getTextures()); + + profile2.setTextures(null); + Assert.assertTrue("cleared profile2 has textures", profile2.getTextures().isEmpty()); + Assert.assertTrue("cleared profile2 has textures timestamp", profile2.getTextures().getTimestamp() == 0L); + Assert.assertTrue("cleared profile2 has signed textures", !profile2.getTextures().isSigned()); + } + + @Test + public void testClearTextures() { + CraftPlayerProfile profile = buildPlayerProfile(); + Assert.assertTrue("profile has no textures", !profile.getTextures().isEmpty()); + + profile.getTextures().clear(); + Assert.assertTrue("cleared profile has textures", profile.getTextures().isEmpty()); + Assert.assertTrue("cleared profile has textures timestamp", profile.getTextures().getTimestamp() == 0L); + Assert.assertTrue("cleared profile has signed textures", !profile.getTextures().isSigned()); + } + + @Test + public void testCustomSkin() { + CraftPlayerProfile profile = new CraftPlayerProfile(UNIQUE_ID, NAME); + profile.getTextures().setSkin(SKIN); + Assert.assertEquals("profile with custom skin does not match expected value", COMPACT_VALUE, profile.getTextures().getProperty().getValue()); + } + + @Test + public void testEquals() { + CraftPlayerProfile profile1 = buildPlayerProfile(); + CraftPlayerProfile profile2 = buildPlayerProfile(); + CraftPlayerProfile profile3 = new CraftPlayerProfile(new GameProfile(UNIQUE_ID, NAME)); + CraftPlayerProfile profile4 = new CraftPlayerProfile(new GameProfile(UNIQUE_ID, NAME)); + CraftPlayerProfile profile5 = new CraftPlayerProfile(new GameProfile(UNIQUE_ID, null)); + CraftPlayerProfile profile6 = new CraftPlayerProfile(new GameProfile(null, NAME)); + + Assert.assertEquals("profile1 and profile2 are not equal", profile1, profile2); + Assert.assertEquals("profile3 and profile4 are not equal", profile3, profile4); + Assert.assertNotEquals("profile1 and profile3 are equal", profile1, profile3); + Assert.assertNotEquals("profile4 and profile5 are equal", profile4, profile5); + Assert.assertNotEquals("profile4 and profile6 are equal", profile4, profile6); + } + + @Test + public void testTexturesEquals() { + CraftPlayerProfile profile1 = buildPlayerProfile(); + CraftPlayerProfile profile2 = buildPlayerProfile(); + Assert.assertEquals("Profile textures are not equal", profile1.getTextures(), profile2.getTextures()); + + profile1.getTextures().setCape(null); + Assert.assertNotEquals("Modified profile textures are still equal", profile1.getTextures(), profile2.getTextures()); + + profile2.getTextures().setCape(null); + Assert.assertEquals("Modified profile textures are not equal", profile1.getTextures(), profile2.getTextures()); + } + + @Test + public void testClone() { + PlayerProfile profile = buildPlayerProfile(); + PlayerProfile copy = profile.clone(); + Assert.assertEquals("profile and copy are not equal", profile, copy); + + // New copies are independent (don't affect the original profile): + copy.getTextures().setSkin(null); + Assert.assertEquals("copy is not independent", SKIN, profile.getTextures().getSkin()); + } + + @Test + public void testSerializationFullProfile() throws InvalidConfigurationException { + ConfigurationSerialization.registerClass(CraftPlayerProfile.class); + PlayerProfile playerProfile = buildPlayerProfile(); + YamlConfiguration configuration = new YamlConfiguration(); + + configuration.set("test", playerProfile); + + String saved = configuration.saveToString(); + + configuration = new YamlConfiguration(); + configuration.loadFromString(saved); + + Assert.assertTrue(configuration.contains("test")); + Assert.assertEquals("Profiles are not equal", playerProfile, configuration.get("test")); + } +}