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); } }