All Downloads are FREE. Search and download functionalities are using the official Maven repository.

com.cryptomorin.xseries.profiles.mojang.MojangAPI Maven / Gradle / Ivy

There is a newer version: 12.1.0
Show newest version
package com.cryptomorin.xseries.profiles.mojang;

import com.cryptomorin.xseries.profiles.PlayerProfiles;
import com.cryptomorin.xseries.profiles.PlayerUUIDs;
import com.cryptomorin.xseries.profiles.ProfilesCore;
import com.cryptomorin.xseries.profiles.exceptions.MojangAPIException;
import com.cryptomorin.xseries.profiles.exceptions.UnknownPlayerException;
import com.cryptomorin.xseries.profiles.objects.ProfileInputType;
import com.cryptomorin.xseries.reflection.XReflection;
import com.google.common.cache.Cache;
import com.google.common.cache.CacheBuilder;
import com.google.common.collect.Iterables;
import com.google.gson.JsonArray;
import com.google.gson.JsonElement;
import com.google.gson.JsonObject;
import com.mojang.authlib.GameProfile;
import com.mojang.authlib.properties.Property;
import com.mojang.authlib.properties.PropertyMap;
import org.jetbrains.annotations.ApiStatus;

import javax.annotation.Nonnull;
import javax.annotation.Nullable;
import java.io.IOException;
import java.time.Duration;
import java.util.*;
import java.util.concurrent.TimeUnit;

@ApiStatus.Internal
public final class MojangAPI {
    private static final MojangProfileCache MOJANG_PROFILE_CACHE = !ProfilesCore.NULLABILITY_RECORD_UPDATE ?
            new MojangProfileCache.GameProfileCache(ProfilesCore.YggdrasilMinecraftSessionService_insecureProfiles) :
            new MojangProfileCache.ProfileResultCache(ProfilesCore.YggdrasilMinecraftSessionService_insecureProfiles);
    /**
     * The 6hr expiration time is probably for players that update their skin, but 6hrs seems a bit too frequent.
     */
    private static final Cache> INSECURE_PROFILES = CacheBuilder.newBuilder()
            .expireAfterWrite(6L, TimeUnit.HOURS).build();

    /**
     * "requireSecure" parameter basically means ignore the cache and also use "unsigned=false" parameter.
     */
    private static final boolean REQUIRE_SECURE_PROFILES = false;

    /**
     * https://wiki.vg/Mojang_API#Username_to_UUID
     */
    private static final MinecraftClient USERNAME_TO_UUID = new MinecraftClient(
            "GET",
            "https://api.mojang.com/users/profiles/minecraft/",
            new RateLimiter(600, Duration.ofMinutes(10))
    );

    /**
     * https://wiki.vg/Mojang_API#Usernames_to_UUIDs
     */
    private static final MinecraftClient USERNAMES_TO_UUIDS = new MinecraftClient(
            "POST",
            "https://api.minecraftservices.com/minecraft/profile/lookup/bulk/byname",
            new RateLimiter(600, Duration.ofMinutes(10))
    );

    /**
     * https://wiki.vg/Mojang_API#UUID_to_Profile_and_Skin.2FCape
     */
    private static final MinecraftClient UUID_TO_PROFILE = new MinecraftClient(
            "GET",
            "https://sessionserver.mojang.com/session/minecraft/profile/",
            new RateLimiter(200, Duration.ofMinutes(1))
    );

    /**
     * @return null if a player with that username is not found.
     */
    @Nullable
    public static UUID requestUsernameToUUID(@Nonnull String username) throws IOException {
        JsonElement requestElement = USERNAME_TO_UUID.session(null).append(username).request();
        if (requestElement == null) return null;

        JsonObject userJson = requestElement.getAsJsonObject();
        JsonElement idElement = userJson.get("id");
        if (idElement == null)
            throw new RuntimeException("No 'id' field for UUID request for '" + username + "': " + userJson);

        return PlayerUUIDs.UUIDFromDashlessString(idElement.getAsString());
    }

    /**
     * This method is here just for study purposes.
     * 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 {
            // Expires after every month calendar.add(2, 1); (Persists between restarts)
            @Nullable Object profile = ProfilesCore.GET_PROFILE_BY_NAME.invoke(ProfilesCore.USER_CACHE, username);
            if (profile instanceof Optional) profile = ((Optional) profile).orElse(null);
            GameProfile gameProfile = profile == null ?
                    PlayerProfiles.createGameProfile(PlayerUUIDs.IDENTITY_UUID, username) :
                    PlayerProfiles.sanitizeProfile((GameProfile) profile);
            ProfilesCore.debug("The cached profile for {} -> {}", username, profile);
            return gameProfile;
        } catch (Throwable throwable) {
            ProfilesCore.LOGGER.error("Unable to get cached profile by username: {}", username, throwable);
            return null;
        }
    }

    public static Optional getMojangCachedProfileFromUsername(String username) {
        try {
            return getMojangCachedProfileFromUsername0(username);
        } catch (Throwable e) {
            throw XReflection.throwCheckedException(e);
        }
    }

    private static Optional getMojangCachedProfileFromUsername0(String username) throws Throwable {
        String normalized = username.toLowerCase(Locale.ROOT);
        Object userCacheEntry = ProfilesCore.UserCache_profilesByName.get(normalized);
        Optional optional;

        // We are supposed to be doing UserCacheEntry#getExpiration() check here
        // but the cache already has a regular cleanup task (PlayerList#placeNewPlayer),
        // we don't need that much accuracy.

        if (userCacheEntry != null) {
            // The side effects of setLastAccess() is really insignificant, it's used for UserCache#getTopMRUProfiles()
            // (MRU = Most Recently Used) which saves game profile cache to usercache.json from most accessed to least accessed.
            // This is because of "settings.user-cache-size" spigot.yml option.
            if (ProfilesCore.UserCacheEntry_setLastAccess != null && ProfilesCore.UserCache_getNextOperation != null) {
                // usercache_usercacheentry.setLastAccess(this.getNextOperation());
                long nextOperation = (long) ProfilesCore.UserCache_getNextOperation.invoke(ProfilesCore.USER_CACHE);
                ProfilesCore.UserCacheEntry_setLastAccess.invoke(userCacheEntry, nextOperation);
            }
            optional = Optional.of((GameProfile) ProfilesCore.UserCacheEntry_getProfile.invoke(userCacheEntry));
        } else {
            // optional = lookupGameProfile(this.profileRepository, username); // CraftBukkit - use correct case for offline players
            UUID realUUID = PlayerUUIDs.getRealUUIDOfPlayer(username);
            if (realUUID == null) return Optional.empty();
            GameProfile profile = PlayerProfiles.createGameProfile(
                    PlayerUUIDs.isOnlineMode() ? realUUID : PlayerUUIDs.getOfflineUUID(username),
                    username
            );
            optional = Optional.of(profile);
            // this.add((GameProfile) optional.get());
            cacheProfile(profile);
        }

        return optional;
    }

    public static Map usernamesToUUIDs(@Nonnull Collection usernames, @Nullable ProfileRequestConfiguration config) {
        if (usernames == null || usernames.isEmpty()) throw new IllegalArgumentException("Usernames are null or empty");
        for (String username : usernames) {
            if (username == null || !ProfileInputType.USERNAME.pattern.matcher(username).matches()) {
                throw new IllegalArgumentException("One of the requested usernames is invalid: " + username + " in " + usernames);
            }
        }

        Map mapped = new HashMap<>(usernames.size());
        Set finalUsernames = new HashSet<>(usernames);
        {
            // Remove duplicate & cached names
            Iterator usernameIter = finalUsernames.iterator();
            while (usernameIter.hasNext()) {
                String username = usernameIter.next();
                UUID cached = PlayerUUIDs.USERNAME_TO_ONLINE.get(username);
                if (cached != null) {
                    usernameIter.remove();
                    mapped.put(cached, username);
                }
            }
        }

        if (finalUsernames.isEmpty()) return mapped;
        boolean onlineMode = PlayerUUIDs.isOnlineMode();

        // For some reason, the YggdrasilGameProfileRepository partitions names in pairs instead of 10s.
        // It also "normalizes" names with lowercase and sends the request.
        Iterable> partition = Iterables.partition(finalUsernames, 10);
        for (List batch : partition) {
            JsonArray response;
            try {
                // The wiki says that:
                // BadRequestException is returned when any of the usernames is null or otherwise invalid
                // But I'm not sure what that means in this context... but invalid usernames are just ignored,
                // and no response is contained in the final result regarding them.
                response = USERNAMES_TO_UUIDS.session(config).body(batch).request().getAsJsonArray();
            } catch (IOException ex) {
                throw new MojangAPIException("Failed to request UUIDs for username batch: " + batch, ex);
            }

            for (JsonElement element : response) {
                JsonObject obj = element.getAsJsonObject();
                String name = obj.get("name").getAsString();
                UUID realId = PlayerUUIDs.UUIDFromDashlessString(obj.get("id").getAsString());
                UUID offlineId = PlayerUUIDs.getOfflineUUID(name);

                PlayerUUIDs.USERNAME_TO_ONLINE.put(name, realId);
                PlayerUUIDs.ONLINE_TO_OFFLINE.put(realId, offlineId);
                PlayerUUIDs.OFFLINE_TO_ONLINE.put(offlineId, realId);
                if (!ProfilesCore.UserCache_profilesByName.containsKey(name)) {
                    cacheProfile(PlayerProfiles.createGameProfile(onlineMode ? realId : offlineId, name));
                }

                String prev = mapped.put(realId, name);
                if (prev != null)
                    throw new RuntimeException("Got duplicate usernames for UUID: " + realId + " (" + prev + " -> " + name + ')');
            }
        }

        return mapped;
    }

    /**
     * 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.
     */
    @Nonnull
    public static GameProfile getCachedProfileByUUID(UUID uuid) {
        uuid = PlayerUUIDs.isOnlineMode() ? uuid : PlayerUUIDs.ONLINE_TO_OFFLINE.getOrDefault(uuid, uuid);
        try {
            @Nullable Object profile = ProfilesCore.GET_PROFILE_BY_UUID.invoke(ProfilesCore.USER_CACHE, uuid);
            if (profile instanceof Optional) profile = ((Optional) profile).orElse(null);
            ProfilesCore.debug("The cached profile for {} -> {}", uuid, profile);
            return profile == null ?
                    PlayerProfiles.createNamelessGameProfile(uuid) :
                    PlayerProfiles.sanitizeProfile((GameProfile) profile);
        } catch (Throwable throwable) {
            ProfilesCore.LOGGER.error("Unable to get cached profile by UUID: {}", uuid, throwable);
            return PlayerProfiles.createNamelessGameProfile(uuid);
        }
    }

    /**
     * Caches the provided {@link GameProfile} in the user cache.
     * These caches are also stored in {@code usercache.json} (file specified in net.minecraft.server.Services).
     *
     * @param profile The {@link GameProfile} to cache.
     */
    private static void cacheProfile(GameProfile profile) {
        try {
            ProfilesCore.CACHE_PROFILE.invoke(ProfilesCore.USER_CACHE, profile);
            ProfilesCore.debug("Profile is now cached: {}", profile);
        } catch (Throwable throwable) {
            ProfilesCore.LOGGER.error("Unable to cache profile {}", profile);
            throwable.printStackTrace();
        }
    }

    /**
     * Fetches additional properties for the given {@link GameProfile} if possible (like the texture) and caches the result.
     *
     * @param profile The {@link GameProfile} for which properties are to be fetched.
     * @return The updated {@link GameProfile} with fetched properties, sanitized for consistency.
     * @throws UnknownPlayerException if a player with the specified profile properties (username and UUID) doesn't exist.
     */
    @SuppressWarnings("OptionalAssignedToNull")
    @Nonnull
    public static GameProfile getOrFetchProfile(@Nonnull final GameProfile profile) throws UnknownPlayerException {
        // Get real UUID for offline players
        UUID realUUID;
        if (profile.getName().equals(PlayerProfiles.DEFAULT_PROFILE_NAME)) {
            // We will assume that the requested UUID is the real one
            // since the server cache didn't find it and that player never
            // joined this server.
            // There is no way to tell if this is fake or real UUID, the
            // closest we can get is to just request it from the server
            // and see if it exists. (We can't reverse UUID.nameUUIDFromBytes)
            realUUID = profile.getId();
        } else {
            realUUID = PlayerUUIDs.getRealUUIDOfPlayer(profile.getName(), profile.getId());
            if (realUUID == null) {
                throw new UnknownPlayerException("Player with the given properties not found: " + profile);
            }
        }

        Optional cached = INSECURE_PROFILES.getIfPresent(realUUID);
        // noinspection OptionalAssignedToNull
        if (cached != null) {
            ProfilesCore.debug("Found cached profile from UUID ({}): {} -> {}", realUUID, profile, cached);
            if (cached.isPresent()) return cached.get();
            else throw new UnknownPlayerException("Player with the given properties not found: " + profile);
        }

        Optional mojangCache = MOJANG_PROFILE_CACHE.get(realUUID, profile);
        if (mojangCache != null) {
            INSECURE_PROFILES.put(realUUID, mojangCache);
            if (mojangCache.isPresent()) return mojangCache.get();
            else throw new UnknownPlayerException("Player with the given properties not found: " + profile);
        }

        JsonElement request;
        try {
            request = UUID_TO_PROFILE.session(null)
                    .append(PlayerUUIDs.toUndashedUUID(realUUID) + "?unsigned=" + !REQUIRE_SECURE_PROFILES)
                    .request();
        } catch (IOException e) {
            throw new RuntimeException(e);
        }
        if (request == null) {
            INSECURE_PROFILES.put(realUUID, Optional.empty());
            MOJANG_PROFILE_CACHE.cache(new PlayerProfile(realUUID, profile, null, null));
            throw new UnknownPlayerException("Player with the given properties not found: " + profile);
        }
        JsonObject profileData = request.getAsJsonObject();

        UUID id = PlayerUUIDs.UUIDFromDashlessString(profileData.get("id").getAsString());
        String name = profileData.get("name").getAsString();
        GameProfile fetchedProfile = PlayerProfiles.createGameProfile(id, name);

        JsonElement propertiesEle = profileData.get("properties");
        if (propertiesEle != null) {
            JsonArray props = propertiesEle.getAsJsonArray();
            PropertyMap properties = fetchedProfile.getProperties();
            for (JsonElement prop : props) {
                JsonObject obj = prop.getAsJsonObject();
                String propName = obj.get("name").getAsString();
                String propValue = obj.get("value").getAsString();
                JsonElement sig = obj.get("signature");

                Property property;
                if (sig != null) {
                    property = new Property(propName, propValue, sig.getAsString());
                } else {
                    property = new Property(propName, propValue);
                }

                properties.put(propName, property);
            }
        }

        List profileActions = new ArrayList<>();
        JsonElement profileActionsElement = profileData.get("profileActions");
        if (profileActionsElement != null) {
            for (JsonElement action : profileActionsElement.getAsJsonArray()) {
                profileActions.add(action.getAsString());
            }
        }

        fetchedProfile = PlayerProfiles.sanitizeProfile(fetchedProfile);
        cacheProfile(fetchedProfile);

        INSECURE_PROFILES.put(realUUID, Optional.of(fetchedProfile));
        MOJANG_PROFILE_CACHE.cache(new PlayerProfile(realUUID, profile, fetchedProfile, profileActions));

        return fetchedProfile;
    }
}




© 2015 - 2025 Weber Informatics LLC | Privacy Policy