/*
* Largely based off Mojang's AccountsClient code.
* https://github.com/Mojang/AccountsClient
*/
package org.tyrannyofheaven.bukkit.util.uuid;
import static org.tyrannyofheaven.bukkit.util.ToHStringUtils.hasText;
import static org.tyrannyofheaven.bukkit.util.uuid.UuidUtils.uncanonicalizeUuid;
import java.io.DataOutputStream;
import java.io.IOException;
import java.io.InputStreamReader;
import java.io.Reader;
import java.net.HttpURLConnection;
import java.net.URL;
import java.util.ArrayList;
import java.util.Collection;
import java.util.Collections;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.UUID;
import java.util.concurrent.TimeUnit;
import org.json.simple.JSONArray;
import org.json.simple.JSONObject;
import org.json.simple.JSONValue;
import org.json.simple.parser.JSONParser;
import org.json.simple.parser.ParseException;
import com.google.common.base.Charsets;
import com.google.common.cache.Cache;
import com.google.common.cache.CacheBuilder;
import com.google.common.cache.CacheLoader;
import com.google.common.collect.Lists;
public class MojangUuidResolver implements UuidResolver {
private static final String AGENT = "minecraft";
private static final UuidDisplayName NULL_UDN = new UuidDisplayName(UUID.randomUUID(), "NOT FOUND");
private final Cache<String, UuidDisplayName> cache;
public MojangUuidResolver(int cacheMaxSize, long cacheTtl, TimeUnit cacheTtlUnits) {
cache = CacheBuilder.newBuilder()
.maximumSize(cacheMaxSize)
.expireAfterWrite(cacheTtl, cacheTtlUnits)
.build(new CacheLoader<String, UuidDisplayName>() {
@Override
public UuidDisplayName load(String key) throws Exception {
UuidDisplayName udn = _resolve(key);
return udn != null ? udn : NULL_UDN; // Doesn't like nulls, so we use a marker object instead
}
});
}
@Override
public UuidDisplayName resolve(String username) {
if (!hasText(username))
throw new IllegalArgumentException("username must have a value");
try {
UuidDisplayName udn = cache.get(username.toLowerCase());
return udn != NULL_UDN ? udn : null;
}
catch (Exception e) {
e.printStackTrace();
return null;
}
}
@Override
public UuidDisplayName resolve(String username, boolean cacheOnly) {
if (!hasText(username))
throw new IllegalArgumentException("username must have a value");
if (cacheOnly) {
UuidDisplayName udn = cache.asMap().get(username.toLowerCase());
if (udn == null) return null;
return udn != NULL_UDN ? udn : null; // NB Can't tell between "not cached" and "maps to null"
}
else return resolve(username); // Same as normal version
}
@Override
public Map<String, UuidDisplayName> resolve(Collection<String> usernames) throws Exception {
if (usernames == null)
throw new IllegalArgumentException("usernames cannot be null");
Map<String, UuidDisplayName> result = new LinkedHashMap<>();
final int BATCH_SIZE = 97; // Should be <= Mojang's AccountsClient's PROFILES_PER_REQUEST (100)
for (List<String> sublist : Lists.partition(new ArrayList<>(usernames), BATCH_SIZE)) {
List<Profile> searchResult = searchProfiles(sublist);
for (Profile profile : searchResult) {
String username = profile.getName();
UUID uuid = uncanonicalizeUuid(profile.getId());
result.put(username.toLowerCase(), new UuidDisplayName(uuid, username));
}
}
return result;
}
@Override
public void preload(String username, UUID uuid) {
if (!hasText(username))
throw new IllegalArgumentException("username must have a value");
if (uuid == null)
throw new IllegalArgumentException("uuid cannot be null");
cache.asMap().put(username.toLowerCase(), new UuidDisplayName(uuid, username));
}
@Override
public void invalidate(String username) {
if (!hasText(username))
throw new IllegalArgumentException("username must have a value");
cache.invalidate(username.toLowerCase());
}
@Override
public void invalidateAll() {
cache.invalidateAll();
}
private UuidDisplayName _resolve(String username) throws IOException, ParseException {
if (!hasText(username))
throw new IllegalArgumentException("username must have a value");
List<Profile> result = searchProfiles(Collections.singletonList(username));
if (result.size() < 1) return null;
// TODO what to do if there are >1?
Profile p = result.get(0);
String uuidString = p.getId();
UUID uuid;
try {
uuid = uncanonicalizeUuid(uuidString);
}
catch (IllegalArgumentException e) {
return null;
}
String displayName = hasText(p.getName()) ? p.getName() : username;
return new UuidDisplayName(uuid, displayName);
}
private List<Profile> searchProfiles(List<String> usernames) throws IOException, ParseException {
String body = JSONValue.toJSONString(usernames);
URL url = new URL("https://api.mojang.com/profiles/" + AGENT);
HttpURLConnection connection = (HttpURLConnection)url.openConnection();
connection.setRequestMethod("POST");
connection.setRequestProperty("Content-Type", "application/json; charset=UTF-8");
connection.setUseCaches(false);
connection.setDoOutput(true);
connection.setConnectTimeout(15000);
connection.setReadTimeout(15000);
DataOutputStream writer = new DataOutputStream(connection.getOutputStream());
try {
writer.write(body.getBytes(Charsets.UTF_8));
writer.flush();
}
finally {
writer.close();
}
Reader reader = new InputStreamReader(connection.getInputStream());
JSONArray profiles;
try {
JSONParser parser = new JSONParser(); // NB Not thread safe
profiles = (JSONArray)parser.parse(reader);
}
finally {
reader.close();
}
return convertResponse(profiles);
}
private List<Profile> convertResponse(JSONArray profiles) {
List<Profile> result = new ArrayList<>();
for (Object obj : profiles) {
JSONObject jsonProfile = (JSONObject)obj;
String id = (String)jsonProfile.get("id");
String name = (String)jsonProfile.get("name");
result.add(new Profile(id, name));
}
return result;
}
private static class Profile {
private final String id;
private final String name;
private Profile(String id, String name) {
this.id = id;
this.name = name;
}
public String getId() {
return id;
}
public String getName() {
return name;
}
}
}