diff --git a/src/main/java/de/craftlancer/imagemaps/ImageMaps.java b/src/main/java/de/craftlancer/imagemaps/ImageMaps.java index 7b381df..5e15db1 100644 --- a/src/main/java/de/craftlancer/imagemaps/ImageMaps.java +++ b/src/main/java/de/craftlancer/imagemaps/ImageMaps.java @@ -26,6 +26,8 @@ import org.bukkit.map.MapRenderer; import org.bukkit.map.MapView; import org.bukkit.plugin.java.JavaPlugin; +import de.craftlancer.imagemaps.metrics.Metrics; + public class ImageMaps extends JavaPlugin implements Listener { private Map placing = new HashMap(); @@ -41,6 +43,16 @@ public class ImageMaps extends JavaPlugin implements Listener loadMaps(); getCommand("imagemap").setExecutor(new ImageMapCommand(this)); getServer().getPluginManager().registerEvents(this, this); + + try + { + Metrics metrics = new Metrics(this); + metrics.start(); + } + catch (IOException e) + { + getLogger().severe("Failed to load Metrics!"); + } } @Override diff --git a/src/main/java/de/craftlancer/imagemaps/metrics/Metrics.java b/src/main/java/de/craftlancer/imagemaps/metrics/Metrics.java new file mode 100644 index 0000000..c5ff2ec --- /dev/null +++ b/src/main/java/de/craftlancer/imagemaps/metrics/Metrics.java @@ -0,0 +1,897 @@ +/* + * Copyright 2011-2013 Tyler Blair. All rights reserved. + * + * Redistribution and use in source and binary forms, with or without modification, are + * permitted provided that the following conditions are met: + * + * 1. Redistributions of source code must retain the above copyright notice, this list of + * conditions and the following disclaimer. + * + * 2. Redistributions in binary form must reproduce the above copyright notice, this list + * of conditions and the following disclaimer in the documentation and/or other materials + * provided with the distribution. + * + * THIS SOFTWARE IS PROVIDED BY THE AUTHOR ''AS IS'' AND ANY EXPRESS OR IMPLIED + * WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND + * FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE AUTHOR OR + * CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR + * CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR + * SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON + * ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING + * NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF + * ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + * + * The views and conclusions contained in the software and documentation are those of the + * authors and contributors and should not be interpreted as representing official policies, + * either expressed or implied, of anybody else. + */ +package de.craftlancer.imagemaps.metrics; + +import org.bukkit.Bukkit; +import org.bukkit.configuration.InvalidConfigurationException; +import org.bukkit.configuration.file.YamlConfiguration; +import org.bukkit.plugin.Plugin; +import org.bukkit.plugin.PluginDescriptionFile; +import org.bukkit.scheduler.BukkitTask; + +import java.io.BufferedReader; +import java.io.ByteArrayOutputStream; +import java.io.File; +import java.io.IOException; +import java.io.InputStreamReader; +import java.io.OutputStream; +import java.io.UnsupportedEncodingException; +import java.net.Proxy; +import java.net.URL; +import java.net.URLConnection; +import java.net.URLEncoder; +import java.util.Collections; +import java.util.HashSet; +import java.util.Iterator; +import java.util.LinkedHashSet; +import java.util.Set; +import java.util.UUID; +import java.util.logging.Level; +import java.util.zip.GZIPOutputStream; + +@SuppressWarnings({ "synthetic-access", "static-method" }) +public class Metrics +{ + + /** + * The current revision number + */ + private final static int REVISION = 7; + + /** + * The base url of the metrics domain + */ + private static final String BASE_URL = "http://report.mcstats.org"; + + /** + * The url used to report a server's status + */ + private static final String REPORT_URL = "/plugin/%s"; + + /** + * Interval of time to ping (in minutes) + */ + private static final int PING_INTERVAL = 15; + + /** + * The plugin this metrics submits for + */ + private final Plugin plugin; + + /** + * All of the custom graphs to submit to metrics + */ + private final Set graphs = Collections.synchronizedSet(new HashSet()); + + /** + * The plugin configuration file + */ + private final YamlConfiguration configuration; + + /** + * The plugin configuration file + */ + private final File configurationFile; + + /** + * Unique server id + */ + private final String guid; + + /** + * Debug mode + */ + private final boolean debug; + + /** + * Lock for synchronization + */ + private final Object optOutLock = new Object(); + + /** + * The scheduled task + */ + private volatile BukkitTask task = null; + + public Metrics(final Plugin plugin) throws IOException + { + if (plugin == null) + { + throw new IllegalArgumentException("Plugin cannot be null"); + } + + this.plugin = plugin; + + // load the config + configurationFile = getConfigFile(); + configuration = YamlConfiguration.loadConfiguration(configurationFile); + + // add some defaults + configuration.addDefault("opt-out", false); + configuration.addDefault("guid", UUID.randomUUID().toString()); + configuration.addDefault("debug", false); + + // Do we need to create the file? + if (configuration.get("guid", null) == null) + { + configuration.options().header("http://mcstats.org").copyDefaults(true); + configuration.save(configurationFile); + } + + // Load the guid then + guid = configuration.getString("guid"); + debug = configuration.getBoolean("debug", false); + } + + /** + * Construct and create a Graph that can be used to separate specific + * plotters to their own graphs on the metrics + * website. Plotters can be added to the graph object returned. + * + * @param name The name of the graph + * @return Graph object created. Will never return NULL under normal + * circumstances unless bad parameters are given + */ + public Graph createGraph(final String name) + { + if (name == null) + { + throw new IllegalArgumentException("Graph name cannot be null"); + } + + // Construct the graph object + final Graph graph = new Graph(name); + + // Now we can add our graph + graphs.add(graph); + + // and return back + return graph; + } + + /** + * Add a Graph object to BukkitMetrics that represents data for the plugin + * that should be sent to the backend + * + * @param graph The name of the graph + */ + public void addGraph(final Graph graph) + { + if (graph == null) + { + throw new IllegalArgumentException("Graph cannot be null"); + } + + graphs.add(graph); + } + + /** + * Start measuring statistics. This will immediately create an async + * repeating task as the plugin and send the + * initial data to the metrics backend, and then after that it will post in + * increments of PING_INTERVAL * 1200 + * ticks. + * + * @return True if statistics measuring is running, otherwise false. + */ + public boolean start() + { + synchronized (optOutLock) + { + // Did we opt out? + if (isOptOut()) + { + return false; + } + + // Is metrics already running? + if (task != null) + { + return true; + } + + // Begin hitting the server with glorious data + task = plugin.getServer().getScheduler().runTaskTimerAsynchronously(plugin, new Runnable() + { + + private boolean firstPost = true; + + @Override + public void run() + { + try + { + // This has to be synchronized or it can collide with + // the disable method. + synchronized (optOutLock) + { + // Disable Task, if it is running and the server + // owner decided to opt-out + if (isOptOut() && task != null) + { + task.cancel(); + task = null; + // Tell all plotters to stop gathering + // information. + for (Graph graph : graphs) + { + graph.onOptOut(); + } + } + } + + // We use the inverse of firstPost because if it is the + // first time we are posting, + // it is not a interval ping, so it evaluates to FALSE + // Each time thereafter it will evaluate to TRUE, i.e + // PING! + postPlugin(!firstPost); + + // After the first post we set firstPost to false + // Each post thereafter will be a ping + firstPost = false; + } + catch (IOException e) + { + if (debug) + { + Bukkit.getLogger().log(Level.INFO, "[Metrics] " + e.getMessage()); + } + } + } + }, 0, PING_INTERVAL * 1200); + + return true; + } + } + + /** + * Has the server owner denied plugin metrics? + * + * @return true if metrics should be opted out of it + */ + public boolean isOptOut() + { + synchronized (optOutLock) + { + try + { + // Reload the metrics file + configuration.load(getConfigFile()); + } + catch (IOException ex) + { + if (debug) + { + Bukkit.getLogger().log(Level.INFO, "[Metrics] " + ex.getMessage()); + } + return true; + } + catch (InvalidConfigurationException ex) + { + if (debug) + { + Bukkit.getLogger().log(Level.INFO, "[Metrics] " + ex.getMessage()); + } + return true; + } + return configuration.getBoolean("opt-out", false); + } + } + + /** + * Enables metrics for the server by setting "opt-out" to false in the + * config file and starting the metrics task. + * + * @throws java.io.IOException + */ + public void enable() throws IOException + { + // This has to be synchronized or it can collide with the check in the + // task. + synchronized (optOutLock) + { + // Check if the server owner has already set opt-out, if not, set + // it. + if (isOptOut()) + { + configuration.set("opt-out", false); + configuration.save(configurationFile); + } + + // Enable Task, if it is not running + if (task == null) + { + start(); + } + } + } + + /** + * Disables metrics for the server by setting "opt-out" to true in the + * config file and canceling the metrics task. + * + * @throws java.io.IOException + */ + public void disable() throws IOException + { + // This has to be synchronized or it can collide with the check in the + // task. + synchronized (optOutLock) + { + // Check if the server owner has already set opt-out, if not, set + // it. + if (!isOptOut()) + { + configuration.set("opt-out", true); + configuration.save(configurationFile); + } + + // Disable Task, if it is running + if (task != null) + { + task.cancel(); + task = null; + } + } + } + + /** + * Gets the File object of the config file that should be used to store data + * such as the GUID and opt-out status + * + * @return the File object for the config file + */ + public File getConfigFile() + { + // I believe the easiest way to get the base folder (e.g craftbukkit set + // via -P) for plugins to use + // is to abuse the plugin object we already have + // plugin.getDataFolder() => base/plugins/PluginA/ + // pluginsFolder => base/plugins/ + // The base is not necessarily relative to the startup directory. + File pluginsFolder = plugin.getDataFolder().getParentFile(); + + // return => base/plugins/PluginMetrics/config.yml + return new File(new File(pluginsFolder, "PluginMetrics"), "config.yml"); + } + + /** + * Generic method that posts a plugin to the metrics website + */ + private void postPlugin(final boolean isPing) throws IOException + { + // Server software specific section + PluginDescriptionFile description = plugin.getDescription(); + String pluginName = description.getName(); + boolean onlineMode = Bukkit.getServer().getOnlineMode(); // TRUE if + // online mode + // is enabled + String pluginVersion = description.getVersion(); + String serverVersion = Bukkit.getVersion(); + int playersOnline = Bukkit.getServer().getOnlinePlayers().length; + + // END server software specific section -- all code below does not use + // any code outside of this class / Java + + // Construct the post data + StringBuilder json = new StringBuilder(1024); + json.append('{'); + + // The plugin's description file containg all of the plugin data such as + // name, version, author, etc + appendJSONPair(json, "guid", guid); + appendJSONPair(json, "plugin_version", pluginVersion); + appendJSONPair(json, "server_version", serverVersion); + appendJSONPair(json, "players_online", Integer.toString(playersOnline)); + + // New data as of R6 + String osname = System.getProperty("os.name"); + String osarch = System.getProperty("os.arch"); + String osversion = System.getProperty("os.version"); + String java_version = System.getProperty("java.version"); + int coreCount = Runtime.getRuntime().availableProcessors(); + + // normalize os arch .. amd64 -> x86_64 + if (osarch.equals("amd64")) + { + osarch = "x86_64"; + } + + appendJSONPair(json, "osname", osname); + appendJSONPair(json, "osarch", osarch); + appendJSONPair(json, "osversion", osversion); + appendJSONPair(json, "cores", Integer.toString(coreCount)); + appendJSONPair(json, "auth_mode", onlineMode ? "1" : "0"); + appendJSONPair(json, "java_version", java_version); + + // If we're pinging, append it + if (isPing) + { + appendJSONPair(json, "ping", "1"); + } + + if (graphs.size() > 0) + { + synchronized (graphs) + { + json.append(','); + json.append('"'); + json.append("graphs"); + json.append('"'); + json.append(':'); + json.append('{'); + + boolean firstGraph = true; + + final Iterator iter = graphs.iterator(); + + while (iter.hasNext()) + { + Graph graph = iter.next(); + + StringBuilder graphJson = new StringBuilder(); + graphJson.append('{'); + + for (Plotter plotter : graph.getPlotters()) + { + appendJSONPair(graphJson, plotter.getColumnName(), Integer.toString(plotter.getValue())); + } + + graphJson.append('}'); + + if (!firstGraph) + { + json.append(','); + } + + json.append(escapeJSON(graph.getName())); + json.append(':'); + json.append(graphJson); + + firstGraph = false; + } + + json.append('}'); + } + } + + // close json + json.append('}'); + + // Create the url + URL url = new URL(BASE_URL + String.format(REPORT_URL, urlEncode(pluginName))); + + // Connect to the website + URLConnection connection; + + // Mineshafter creates a socks proxy, so we can safely bypass it + // It does not reroute POST requests so we need to go around it + if (isMineshafterPresent()) + { + connection = url.openConnection(Proxy.NO_PROXY); + } + else + { + connection = url.openConnection(); + } + + byte[] uncompressed = json.toString().getBytes(); + byte[] compressed = gzip(json.toString()); + + // Headers + connection.addRequestProperty("User-Agent", "MCStats/" + REVISION); + connection.addRequestProperty("Content-Type", "application/json"); + connection.addRequestProperty("Content-Encoding", "gzip"); + connection.addRequestProperty("Content-Length", Integer.toString(compressed.length)); + connection.addRequestProperty("Accept", "application/json"); + connection.addRequestProperty("Connection", "close"); + + connection.setDoOutput(true); + + if (debug) + { + System.out.println("[Metrics] Prepared request for " + pluginName + " uncompressed=" + uncompressed.length + " compressed=" + compressed.length); + } + + // Write the data + OutputStream os = connection.getOutputStream(); + os.write(compressed); + os.flush(); + + // Now read the response + final BufferedReader reader = new BufferedReader(new InputStreamReader(connection.getInputStream())); + String response = reader.readLine(); + + // close resources + os.close(); + reader.close(); + + if (response == null || response.startsWith("ERR") || response.startsWith("7")) + { + if (response == null) + { + response = "null"; + } + else if (response.startsWith("7")) + { + response = response.substring(response.startsWith("7,") ? 2 : 1); + } + + throw new IOException(response); + } + else + { + // Is this the first update this hour? + if (response.equals("1") || response.contains("This is your first update this hour")) + { + synchronized (graphs) + { + final Iterator iter = graphs.iterator(); + + while (iter.hasNext()) + { + final Graph graph = iter.next(); + + for (Plotter plotter : graph.getPlotters()) + { + plotter.reset(); + } + } + } + } + } + } + + /** + * GZip compress a string of bytes + * + * @param input + */ + public static byte[] gzip(String input) + { + ByteArrayOutputStream baos = new ByteArrayOutputStream(); + GZIPOutputStream gzos = null; + + try + { + gzos = new GZIPOutputStream(baos); + gzos.write(input.getBytes("UTF-8")); + } + catch (IOException e) + { + e.printStackTrace(); + } + finally + { + if (gzos != null) + try + { + gzos.close(); + } + catch (IOException ignore) + { + } + } + + return baos.toByteArray(); + } + + /** + * Check if mineshafter is present. If it is, we need to bypass it to send + * POST requests + * + * @return true if mineshafter is installed on the server + */ + private boolean isMineshafterPresent() + { + try + { + Class.forName("mineshafter.MineServer"); + return true; + } + catch (Exception e) + { + return false; + } + } + + /** + * Appends a json encoded key/value pair to the given string builder. + * + * @param json + * @param key + * @param value + * @throws UnsupportedEncodingException + */ + private static void appendJSONPair(StringBuilder json, String key, String value) throws UnsupportedEncodingException + { + boolean isValueNumeric = false; + + try + { + if (value.equals("0") || !value.endsWith("0")) + { + Double.parseDouble(value); + isValueNumeric = true; + } + } + catch (NumberFormatException e) + { + isValueNumeric = false; + } + + if (json.charAt(json.length() - 1) != '{') + { + json.append(','); + } + + json.append(escapeJSON(key)); + json.append(':'); + + if (isValueNumeric) + { + json.append(value); + } + else + { + json.append(escapeJSON(value)); + } + } + + /** + * Escape a string to create a valid JSON string + * + * @param text + * @return + */ + private static String escapeJSON(String text) + { + StringBuilder builder = new StringBuilder(); + + builder.append('"'); + for (int index = 0; index < text.length(); index++) + { + char chr = text.charAt(index); + + switch (chr) + { + case '"': + case '\\': + builder.append('\\'); + builder.append(chr); + break; + case '\b': + builder.append("\\b"); + break; + case '\t': + builder.append("\\t"); + break; + case '\n': + builder.append("\\n"); + break; + case '\r': + builder.append("\\r"); + break; + default: + if (chr < ' ') + { + String t = "000" + Integer.toHexString(chr); + builder.append("\\u" + t.substring(t.length() - 4)); + } + else + { + builder.append(chr); + } + break; + } + } + builder.append('"'); + + return builder.toString(); + } + + /** + * Encode text as UTF-8 + * + * @param text the text to encode + * @return the encoded text, as UTF-8 + */ + private static String urlEncode(final String text) throws UnsupportedEncodingException + { + return URLEncoder.encode(text, "UTF-8"); + } + + /** + * Represents a custom graph on the website + */ + public static class Graph + { + + /** + * The graph's name, alphanumeric and spaces only :) If it does not + * comply to the above when submitted, it is + * rejected + */ + private final String name; + + /** + * The set of plotters that are contained within this graph + */ + private final Set plotters = new LinkedHashSet(); + + private Graph(final String name) + { + this.name = name; + } + + /** + * Gets the graph's name + * + * @return the Graph's name + */ + public String getName() + { + return name; + } + + /** + * Add a plotter to the graph, which will be used to plot entries + * + * @param plotter the plotter to add to the graph + */ + public void addPlotter(final Plotter plotter) + { + plotters.add(plotter); + } + + /** + * Remove a plotter from the graph + * + * @param plotter the plotter to remove from the graph + */ + public void removePlotter(final Plotter plotter) + { + plotters.remove(plotter); + } + + /** + * Gets an unmodifiable set of the plotter objects in the graph + * + * @return an unmodifiable {@link java.util.Set} of the plotter objects + */ + public Set getPlotters() + { + return Collections.unmodifiableSet(plotters); + } + + @Override + public int hashCode() + { + return name.hashCode(); + } + + @Override + public boolean equals(final Object object) + { + if (!(object instanceof Graph)) + { + return false; + } + + final Graph graph = (Graph) object; + return graph.name.equals(name); + } + + /** + * Called when the server owner decides to opt-out of BukkitMetrics + * while the server is running. + */ + protected void onOptOut() + { + } + } + + /** + * Interface used to collect custom data for a plugin + */ + public static abstract class Plotter + { + + /** + * The plot's name + */ + private final String name; + + /** + * Construct a plotter with the default plot name + */ + public Plotter() + { + this("Default"); + } + + /** + * Construct a plotter with a specific plot name + * + * @param name the name of the plotter to use, which will show up on the + * website + */ + public Plotter(final String name) + { + this.name = name; + } + + /** + * Get the current value for the plotted point. Since this function + * defers to an external function it may or may + * not return immediately thus cannot be guaranteed to be thread + * friendly or safe. This function can be called + * from any thread so care should be taken when accessing resources that + * need to be synchronized. + * + * @return the current value for the point to be plotted. + */ + public abstract int getValue(); + + /** + * Get the column name for the plotted point + * + * @return the plotted point's column name + */ + public String getColumnName() + { + return name; + } + + /** + * Called after the website graphs have been updated + */ + public void reset() + { + } + + @Override + public int hashCode() + { + return getColumnName().hashCode(); + } + + @Override + public boolean equals(final Object object) + { + if (!(object instanceof Plotter)) + { + return false; + } + + final Plotter plotter = (Plotter) object; + return plotter.name.equals(name) && plotter.getValue() == getValue(); + } + } +}