#1002: Add Player Profile API

Slight changes may occur as this API is stabilized.

This PR is based on work previously done by DerFrZocker in #938.

By: blablubbabc <lukas@wirsindwir.de>
This commit is contained in:
CraftBukkit/Spigot
2022-02-03 09:25:39 +11:00
parent 0190fa68f9
commit 08891a2e2f
11 changed files with 1159 additions and 6 deletions

View File

@@ -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<PlayerProfile> 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<Property> iterator1 = propertyMap.values().iterator();
Iterator<Property> 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<String, Object> serialize() {
Map<String, Object> map = new LinkedHashMap<>();
if (uniqueId != null) {
map.put("uniqueId", uniqueId.toString());
}
if (name != null) {
map.put("name", name);
}
rebuildDirtyProperties();
if (!properties.isEmpty()) {
List<Object> propertiesData = new ArrayList<>();
properties.forEach((propertyName, property) -> {
propertiesData.add(CraftProfileProperty.serialize(property));
});
map.put("properties", propertiesData);
}
return map;
}
public static CraftPlayerProfile deserialize(Map<String, Object> 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;
}
}

View File

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

View File

@@ -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<String, Object> serialize(@Nonnull Property property) {
Map<String, Object> 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() {
}
}