Allow any string in WrappedGameProfile(String, String).

We now accept any string in this constructor, to preserve 
backwards compatibility. But, we depreciate its use, as 
WrappedGameProfile(UUID, String) can be used in every Minecraft
version that supports a game profile.

There's also a new warning system that will identify the plugin 
that is using the depreciated method, and print its name to the 
console (at most once every hour).
This commit is contained in:
Kristian S. Stangeland 2014-07-14 04:54:44 +02:00
parent 09e45977f2
commit 4ccd8853c4
6 changed files with 500 additions and 46 deletions

View File

@ -0,0 +1,246 @@
package com.comphenix.protocol.collections;
import java.util.Collection;
import java.util.HashMap;
import java.util.Map;
import java.util.Map.Entry;
import java.util.PriorityQueue;
import java.util.Set;
import java.util.concurrent.TimeUnit;
import com.google.common.base.Function;
import com.google.common.base.Objects;
import com.google.common.base.Preconditions;
import com.google.common.base.Ticker;
import com.google.common.collect.Maps;
import com.google.common.primitives.Longs;
/**
* Represents a hash map where each association may expire after a given time has elapsed.
* <p>
* Note that replaced key-value associations are only collected once the original expiration time has elapsed.
*
* @author Kristian Stangeland
*
* @param <K> - type of the keys.
* @param <V> - type of the values.
*/
public class ExpireHashMap<K, V> {
private class ExpireEntry implements Comparable<ExpireEntry> {
public final long expireTime;
public final K expireKey;
public final V expireValue;
public ExpireEntry(long expireTime, K expireKey, V expireValue) {
this.expireTime = expireTime;
this.expireKey = expireKey;
this.expireValue = expireValue;
}
@Override
public int compareTo(ExpireEntry o) {
return Longs.compare(expireTime, o.expireTime);
}
@Override
public String toString() {
return "ExpireEntry [expireTime=" + expireTime + ", expireKey=" + expireKey
+ ", expireValue=" + expireValue + "]";
}
}
private Map<K, ExpireEntry> keyLookup = new HashMap<K, ExpireEntry>();
private PriorityQueue<ExpireEntry> expireQueue = new PriorityQueue<ExpireEntry>();
// View of keyLookup with direct values
private Map<K, V> valueView = Maps.transformValues(keyLookup, new Function<ExpireEntry, V>() {
@Override
public V apply(ExpireEntry entry) {
return entry.expireValue;
}
});
// Supplied by the constructor
private Ticker ticker;
/**
* Construct a new hash map where each entry may expire at a given time.
*/
public ExpireHashMap() {
this(Ticker.systemTicker());
}
/**
* Construct a new hash map where each entry may expire at a given time.
* @param ticker - supplier of the current time.
*/
public ExpireHashMap(Ticker ticker) {
this.ticker = ticker;
}
/**
* Retrieve the value associated with the given key, if it has not expired.
* @param key - the key.
* @return The value, or NULL if not found or it has expired.
*/
public V get(K key) {
evictExpired();
ExpireEntry entry = keyLookup.get(key);
return entry != null ? entry.expireValue : null;
}
/**
* Associate the given key with the given value, until the expire delay have elapsed.
* @param key - the key.
* @param value - the value.
* @param expireDelay - the amount of time until this association expires. Must be greater than zero.
* @param expireUnit - the unit of the expiration.
* @return Any previously unexpired association with this key, or NULL.
*/
public V put(K key, V value, long expireDelay, TimeUnit expireUnit) {
Preconditions.checkNotNull(expireUnit, "expireUnit cannot be NULL");
Preconditions.checkState(expireDelay > 0, "expireDelay cannot be equal or less than zero.");
evictExpired();
ExpireEntry entry = new ExpireEntry(
ticker.read() + TimeUnit.NANOSECONDS.convert(expireDelay, expireUnit),
key, value
);
ExpireEntry previous = keyLookup.put(key, entry);
// We enqueue its removal
expireQueue.add(entry);
return previous != null ? previous.expireValue : null;
}
/**
* Determine if the given key is referring to an unexpired association in the map.
* @param key - the key.
* @return TRUE if it is, FALSE otherwise.
*/
public boolean containsKey(K key) {
evictExpired();
return keyLookup.containsKey(key);
}
/**
* Determine if the given value is referring to an unexpired association in the map.
* @param value - the value.
* @return TRUE if it is, FALSE otherwise.
*/
public boolean containsValue(V value) {
evictExpired();
// Linear scan is the best we've got
for (ExpireEntry entry : keyLookup.values()) {
if (Objects.equal(value, entry.expireValue)) {
return true;
}
}
return false;
}
/**
* Remove a key and its associated value from the map.
* @param key - the key to remove.
* @return Value of the removed association, NULL otherwise.
*/
public V removeKey(K key) {
evictExpired();
ExpireEntry entry = keyLookup.remove(key);
return entry != null ? entry.expireValue : null;
}
/**
* Retrieve the number of entries in the map.
* @return The number of entries.
*/
public int size() {
evictExpired();
return keyLookup.size();
}
/**
* Retrieve a view of the keys in the current map.
* @return View of the keys.
*/
public Set<K> keySet() {
evictExpired();
return keyLookup.keySet();
}
/**
* Retrieve a view of all the values in the current map.
* @return All the values.
*/
public Collection<V> values() {
evictExpired();
return valueView.values();
}
/**
* Retrieve a view of all the entries in the set.
* @return All the entries.
*/
public Set<Entry<K, V>> entrySet() {
evictExpired();
return valueView.entrySet();
}
/**
* Retrieve a view of this expire map as an ordinary map that does not support insertion.
* @return The map.
*/
public Map<K, V> asMap() {
evictExpired();
return valueView;
}
/**
* Clear all references to key-value pairs that have been removed or replaced before they were naturally evicted.
* <p>
* This operation requires a linear scan of the current entries in the map.
*/
public void collect() {
// First evict what we can
evictExpired();
// Recreate the eviction queue - this is faster than removing entries in the old queue
expireQueue.clear();
expireQueue.addAll(keyLookup.values());
}
/**
* Clear all the entries in the current map.
*/
public void clear() {
keyLookup.clear();
expireQueue.clear();
}
/**
* Evict any expired entries in the map.
* <p>
* This is called automatically by any of the read or write operations.
*/
protected void evictExpired() {
long currentTime = ticker.read();
// Remove expired entries
while (expireQueue.size() > 0 && expireQueue.peek().expireTime <= currentTime) {
ExpireEntry entry = expireQueue.poll();
if (entry == keyLookup.get(entry.expireKey)) {
keyLookup.remove(entry.expireKey);
}
}
}
@Override
public String toString() {
return valueView.toString();
}
}

View File

@ -25,6 +25,7 @@ import java.util.Map;
import java.util.Set;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ConcurrentMap;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.logging.Level;
import java.util.logging.Logger;
@ -34,6 +35,7 @@ import org.apache.commons.lang.builder.ToStringStyle;
import org.bukkit.Bukkit;
import org.bukkit.plugin.Plugin;
import com.comphenix.protocol.collections.ExpireHashMap;
import com.comphenix.protocol.error.Report.ReportBuilder;
import com.comphenix.protocol.events.PacketAdapter;
import com.comphenix.protocol.reflect.PrettyPrinter;
@ -83,7 +85,11 @@ public class DetailedErrorReporter implements ErrorReporter {
// Map of global objects
protected Map<String, Object> globalParameters = new HashMap<String, Object>();
// Reports to ignore
private ExpireHashMap<Report, Boolean> rateLimited = new ExpireHashMap<Report, Boolean>();
private Object rateLock = new Object();
/**
* Create a default error reporting system.
*/
@ -218,7 +224,7 @@ public class DetailedErrorReporter implements ErrorReporter {
@Override
public void reportDebug(Object sender, Report report) {
if (logger.isLoggable(Level.FINE)) {
if (logger.isLoggable(Level.FINE) && canReport(report)) {
reportLevel(Level.FINE, sender, report);
}
}
@ -233,11 +239,33 @@ public class DetailedErrorReporter implements ErrorReporter {
@Override
public void reportWarning(Object sender, Report report) {
if (logger.isLoggable(Level.WARNING)) {
if (logger.isLoggable(Level.WARNING) && canReport(report)) {
reportLevel(Level.WARNING, sender, report);
}
}
/**
* Determine if we should print the given report.
* <p>
* The default implementation will check for rate limits.
* @param report - the report to check.
* @return TRUE if we should print it, FALSE otherwise.
*/
protected boolean canReport(Report report) {
long rateLimit = report.getRateLimit();
// Check for rate limit
if (rateLimit > 0) {
synchronized (rateLock) {
if (rateLimited.containsKey(report)) {
return false;
}
rateLimited.put(report, true, rateLimit, TimeUnit.NANOSECONDS);
}
}
return true;
}
private void reportLevel(Level level, Object sender, Report report) {
String message = "[" + pluginName + "] [" + getSenderName(sender) + "] " + report.getReportMessage();
@ -294,6 +322,11 @@ public class DetailedErrorReporter implements ErrorReporter {
}
}
// Secondary rate limit
if (!canReport(report)) {
return;
}
StringWriter text = new StringWriter();
PrintWriter writer = new PrintWriter(text);

View File

@ -0,0 +1,106 @@
package com.comphenix.protocol.error;
import java.io.File;
import java.io.UnsupportedEncodingException;
import java.net.URLDecoder;
import java.security.CodeSource;
import org.bukkit.Bukkit;
import org.bukkit.plugin.Plugin;
import com.google.common.base.Preconditions;
public final class PluginContext {
// Determine plugin folder
private static File pluginFolder;
private PluginContext() {
// Not constructable
}
/**
* Retrieve the name of the plugin that called the last method(s) in the exception.
* @param ex - the exception.
* @return The name of the plugin, or NULL.
*/
public static String getPluginCaller(Exception ex) {
StackTraceElement[] elements = ex.getStackTrace();
String current = getPluginName(elements[0]);
for (int i = 1; i < elements.length; i++) {
String caller = getPluginName(elements[i]);
if (caller != null && !caller.equals(current)) {
return caller;
}
}
return null;
}
/**
* Lookup the plugin that this method invocation belongs to, and return its file name.
* @param element - the method invocation.
* @return Pluing name, or NULL if not found.
*
*/
public static String getPluginName(StackTraceElement element) {
try {
CodeSource codeSource = Class.forName(element.getClassName()).getProtectionDomain().getCodeSource();
if (codeSource != null) {
String encoding = codeSource.getLocation().getPath();
File path = new File(URLDecoder.decode(encoding, "UTF-8"));
if (folderContains(getPluginFolder(), path)) {
return path.getName();
}
}
return null; // Cannot find it
} catch (UnsupportedEncodingException e) {
throw new RuntimeException("Cannot lookup plugin name.", e);
} catch (ClassNotFoundException e) {
throw new RuntimeException("Cannot lookup plugin name.", e);
}
}
/**
* Determine if a folder contains the given file.
* @param folder - the folder.
* @param file - the file.
* @return TRUE if it does, FALSE otherwise.
*/
private static boolean folderContains(File folder, File file) {
Preconditions.checkNotNull(folder, "folder cannot be NULL");
Preconditions.checkNotNull(file, "file cannot be NULL");
// Get absolute versions
folder = folder.getAbsoluteFile();
file = file.getAbsoluteFile();
while (file != null) {
if (folder.equals(file))
return true;
file = file.getParentFile();
}
return false;
}
/**
* Retrieve the folder that contains every plugin on the server.
* @return Folder with every plugin.
*/
private static File getPluginFolder() {
File folder = pluginFolder;
if (folder == null) {
Plugin[] plugins = Bukkit.getPluginManager().getPlugins();
if (plugins.length > 0) {
folder = plugins[0].getDataFolder().getParentFile();
pluginFolder = folder;
}
}
return folder;
}
}

View File

@ -1,5 +1,8 @@
package com.comphenix.protocol.error;
import java.util.Arrays;
import java.util.concurrent.TimeUnit;
import javax.annotation.Nullable;
/**
@ -7,12 +10,15 @@ import javax.annotation.Nullable;
*
* @author Kristian
*/
public class Report {
public class Report {
private final ReportType type;
private final Throwable exception;
private final Object[] messageParameters;
private final Object[] callerParameters;
// Used to rate limit reports that are similar
private final long rateLimit;
/**
* Must be constructed through the factory method in Report.
*/
@ -21,6 +27,7 @@ public class Report {
private Throwable exception;
private Object[] messageParameters;
private Object[] callerParameters;
private long rateLimit;
private ReportBuilder() {
// Don't allow
@ -39,8 +46,8 @@ public class Report {
}
/**
* Set the current exception that occured.
* @param exception - exception that occured.
* Set the current exception that occurred.
* @param exception - exception that occurred.
* @return This builder, for chaining.
*/
public ReportBuilder error(@Nullable Throwable exception) {
@ -68,12 +75,35 @@ public class Report {
return this;
}
/**
* Set the minimum number of nanoseconds to wait until a report of equal type and parameters
* is allowed to be printed again.
* @param rateLimit - number of nanoseconds, or 0 to disable. Cannot be negative.
* @return This builder, for chaining.
*/
public ReportBuilder rateLimit(long rateLimit) {
if (rateLimit < 0)
throw new IllegalArgumentException("Rate limit cannot be less than zero.");
this.rateLimit = rateLimit;
return this;
}
/**
* Set the minimum time to wait until a report of equal type and parameters is allowed to be printed again.
* @param rateLimit - the time, or 0 to disable. Cannot be negative.
* @param rateUnit - the unit of the rate limit.
* @return This builder, for chaining.
*/
public ReportBuilder rateLimit(long rateLimit, TimeUnit rateUnit) {
return rateLimit(TimeUnit.NANOSECONDS.convert(rateLimit, rateUnit));
}
/**
* Construct a new report with the provided input.
* @return A new report.
*/
public Report build() {
return new Report(type, exception, messageParameters, callerParameters);
return new Report(type, exception, messageParameters, callerParameters, rateLimit);
}
}
@ -85,7 +115,7 @@ public class Report {
public static ReportBuilder newBuilder(ReportType type) {
return new ReportBuilder().type(type);
}
/**
* Construct a new report with the given type and parameters.
* @param exception - exception that occured in the caller method.
@ -93,13 +123,28 @@ public class Report {
* @param messageParameters - parameters used to construct the report message.
* @param callerParameters - parameters from the caller method.
*/
protected Report(ReportType type, @Nullable Throwable exception, @Nullable Object[] messageParameters, @Nullable Object[] callerParameters) {
protected Report(ReportType type, @Nullable Throwable exception,
@Nullable Object[] messageParameters, @Nullable Object[] callerParameters) {
this(type, exception, messageParameters, callerParameters, 0);
}
/**
* Construct a new report with the given type and parameters.
* @param exception - exception that occurred in the caller method.
* @param type - the report type that will be used to construct the message.
* @param messageParameters - parameters used to construct the report message.
* @param callerParameters - parameters from the caller method.
* @param rateLimit - minimum number of nanoseconds to wait until a report of equal type and parameters is allowed to be printed again.
*/
protected Report(ReportType type, @Nullable Throwable exception,
@Nullable Object[] messageParameters, @Nullable Object[] callerParameters, long rateLimit) {
if (type == null)
throw new IllegalArgumentException("type cannot be NULL.");
this.type = type;
this.exception = exception;
this.messageParameters = messageParameters;
this.callerParameters = callerParameters;
this.rateLimit = rateLimit;
}
/**
@ -159,4 +204,37 @@ public class Report {
public boolean hasCallerParameters() {
return callerParameters != null && callerParameters.length > 0;
}
/**
* Retrieve desired minimum number of nanoseconds until a report of the same type and parameters should be reprinted.
* <p>
* Note that this may be ignored or modified by the error reporter. Zero indicates no rate limit.
* @return The number of nanoseconds. Never negative.
*/
public long getRateLimit() {
return rateLimit;
}
@Override
public int hashCode() {
final int prime = 31;
int result = 1;
result = prime * result + Arrays.hashCode(callerParameters);
result = prime * result + Arrays.hashCode(messageParameters);
result = prime * result + ((type == null) ? 0 : type.hashCode());
return result;
}
@Override
public boolean equals(Object obj) {
if (this == obj)
return true;
if (obj instanceof Report) {
Report other = (Report) obj;
return type == other.type &&
Arrays.equals(callerParameters, other.callerParameters) &&
Arrays.equals(messageParameters, other.messageParameters);
}
return false;
}
}

View File

@ -28,7 +28,6 @@ import java.util.List;
import java.util.Map;
import java.util.Map.Entry;
import org.bukkit.Bukkit;
import org.bukkit.Material;
import org.bukkit.World;
import org.bukkit.WorldType;

View File

@ -1,28 +1,35 @@
package com.comphenix.protocol.wrappers;
import java.util.UUID;
import java.util.concurrent.TimeUnit;
import net.minecraft.util.com.mojang.authlib.GameProfile;
import net.minecraft.util.com.mojang.authlib.properties.Property;
import org.apache.commons.lang.StringUtils;
import org.bukkit.OfflinePlayer;
import org.bukkit.entity.Player;
import com.comphenix.protocol.ProtocolLibrary;
import com.comphenix.protocol.error.PluginContext;
import com.comphenix.protocol.error.Report;
import com.comphenix.protocol.error.ReportType;
import com.comphenix.protocol.injector.BukkitUnwrapper;
import com.comphenix.protocol.reflect.accessors.Accessors;
import com.comphenix.protocol.reflect.accessors.ConstructorAccessor;
import com.comphenix.protocol.reflect.accessors.FieldAccessor;
import com.comphenix.protocol.utility.MinecraftReflection;
import com.comphenix.protocol.wrappers.collection.ConvertedMultimap;
import com.google.common.base.Charsets;
import com.google.common.base.Objects;
import com.google.common.collect.Multimap;
import net.minecraft.util.com.mojang.authlib.GameProfile;
import net.minecraft.util.com.mojang.authlib.properties.Property;
/**
* Represents a wrapper for a game profile.
* @author Kristian
*/
public class WrappedGameProfile extends AbstractWrapper {
public static final ReportType REPORT_INVALID_UUID = new ReportType("Plugin %s created a profile with '%s' as an UUID.");
// Version 1.7.2 and 1.7.8 respectively
private static final ConstructorAccessor CREATE_STRING_STRING = Accessors.getConstructorAccessorOrNull(GameProfile.class, String.class, String.class);
private static final FieldAccessor GET_UUID_STRING = Accessors.getFieldAcccessorOrNull(GameProfile.class, "id", String.class);
@ -82,21 +89,20 @@ public class WrappedGameProfile extends AbstractWrapper {
* Construct a new game profile with the given properties.
* <p>
* Note that this constructor is very lenient when parsing UUIDs for backwards compatibility reasons.
* Thus - "", " ", "0" and "0-0-0-0" are all equivalent to the the UUID "00000000-0000-0000-0000-000000000000".
* IDs that cannot be parsed as an UUID will be hashed and form a version 3 UUID instead.
* <p>
* This method is deprecated for Minecraft 1.7.8 and above.
* @param id - the UUID of the player.
* @param name - the name of the player.
*/
@Deprecated
public WrappedGameProfile(String id, String name) {
super(GameProfile.class);
if (CREATE_STRING_STRING != null) {
setHandle(CREATE_STRING_STRING.invoke(id, name));
} else {
try {
setHandle(new GameProfile(parseUUID(id), name));
} catch (IllegalArgumentException e) {
throw new IllegalArgumentException("Cannot construct profile [" + id + ", " + name + "]", e);
}
setHandle(new GameProfile(parseUUID(id), name));
}
}
@ -134,33 +140,19 @@ public class WrappedGameProfile extends AbstractWrapper {
* @throws IllegalArgumentException If we cannot parse the text.
*/
private static UUID parseUUID(String id) {
if (id == null)
return null;
// Interpret as zero
if (StringUtils.isBlank(id))
id = "0";
int missing = 4 - StringUtils.countMatches(id, "-");
// Lenient - add missing data
if (missing > 0) {
if (id.length() < 12) {
id += StringUtils.repeat("-0", missing);
} else if (id.length() >= 32) {
StringBuilder builder = new StringBuilder(id);
int position = 8; // Initial position
while (missing > 0 && position < builder.length()) {
builder.insert(position, "-");
position += 5; // 4 in length, plus the hyphen
missing--;
}
id = builder.toString();
} else {
throw new IllegalArgumentException("Invalid partial UUID: " + id);
}
try {
return id != null ? UUID.fromString(id) : null;
} catch (IllegalArgumentException e) {
// Warn once every hour (per plugin)
ProtocolLibrary.getErrorReporter().reportWarning(
WrappedGameProfile.class,
Report.newBuilder(REPORT_INVALID_UUID).
rateLimit(1, TimeUnit.HOURS).
messageParam(PluginContext.getPluginCaller(new Exception()), id)
);
return UUID.nameUUIDFromBytes(id.getBytes(Charsets.UTF_8));
}
return UUID.fromString(id);
}
/**