//////////////////////////////////////////////////////////////////////////////////////////////////// // PlotSquared - A plot manager and world generator for the Bukkit API / // Copyright (c) 2014 IntellectualSites/IntellectualCrafters / // / // This program is free software; you can redistribute it and/or modify / // it under the terms of the GNU General Public License as published by / // the Free Software Foundation; either version 3 of the License, or / // (at your option) any later version. / // / // This program is distributed in the hope that it will be useful, / // but WITHOUT ANY WARRANTY; without even the implied warranty of / // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the / // GNU General Public License for more details. / // / // You should have received a copy of the GNU General Public License / // along with this program; if not, write to the Free Software Foundation, / // Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA / // / // You can contact us via: support@intellectualsites.com / //////////////////////////////////////////////////////////////////////////////////////////////////// package com.plotsquared.bukkit.util; 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.lang.reflect.InvocationTargetException; import java.net.Proxy; import java.net.URL; import java.net.URLConnection; import java.net.URLEncoder; import java.util.Collection; import java.util.Collections; import java.util.HashSet; import java.util.LinkedHashSet; import java.util.Set; import java.util.UUID; import java.util.logging.Level; import java.util.zip.GZIPOutputStream; import org.bukkit.Bukkit; import org.bukkit.entity.Player; import org.bukkit.plugin.Plugin; import org.bukkit.plugin.PluginDescriptionFile; import org.bukkit.scheduler.BukkitTask; import com.intellectualcrafters.configuration.InvalidConfigurationException; import com.intellectualcrafters.configuration.file.YamlConfiguration; import com.intellectualcrafters.plot.PS; 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 this.configurationFile = getConfigFile(); this.configuration = YamlConfiguration.loadConfiguration(this.configurationFile); // add some defaults this.configuration.addDefault("opt-out", false); this.configuration.addDefault("guid", UUID.randomUUID().toString()); this.configuration.addDefault("debug", false); // Do we need to create the file? if (this.configuration.get("guid", null) == null) { this.configuration.options().header("http://mcstats.org").copyDefaults(true); this.configuration.save(this.configurationFile); } // Load the guid then this.guid = this.configuration.getString("guid"); this.debug = this.configuration.getBoolean("debug", false); } /** * GZip compress a string of bytes * * @param input * * @return byte[] the file as a byte array */ public static byte[] gzip(final String input) { final ByteArrayOutputStream baos = new ByteArrayOutputStream(); GZIPOutputStream gzos = null; try { gzos = new GZIPOutputStream(baos); gzos.write(input.getBytes("UTF-8")); } catch (final IOException e) { e.printStackTrace(); } finally { if (gzos != null) { try { gzos.close(); } catch (final IOException ignore) { } } } return baos.toByteArray(); } /** * Appends a json encoded key/value pair to the given string builder. * * @param json * @param key * @param value * * @throws UnsupportedEncodingException */ private static void appendJSONPair(final StringBuilder json, final String key, final String value) throws UnsupportedEncodingException { boolean isValueNumeric = false; try { if (value.equals("0") || !value.endsWith("0")) { Double.parseDouble(value); isValueNumeric = true; } } catch (final 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 String */ private static String escapeJSON(final String text) { final StringBuilder builder = new StringBuilder(); builder.append('"'); for (int index = 0; index < text.length(); index++) { final 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 < ' ') { final 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"); } /** * 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 this.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"); } this.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 (this.optOutLock) { // Did we opt out? if (isOptOut()) { return false; } // Is metrics already running? if (this.task != null) { return true; } // Begin hitting the server with glorious data this.task = this.plugin.getServer().getScheduler().runTaskTimerAsynchronously(this.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 (Metrics.this.optOutLock) { // Disable Task, if it is running and the // server // owner decided to opt-out if (isOptOut() && (Metrics.this.task != null)) { Metrics.this.task.cancel(); Metrics.this.task = null; // Tell all plotters to stop gathering // information. for (final Graph graph : Metrics.this.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(!this.firstPost); // After the first post we set firstPost to // false // Each post thereafter will be a ping this.firstPost = false; } catch (final IOException e) { if (Metrics.this.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 (this.optOutLock) { try { // Reload the metrics file this.configuration.load(getConfigFile()); } catch (final IOException ex) { if (this.debug) { Bukkit.getLogger().log(Level.INFO, "[Metrics] " + ex.getMessage()); } return true; } catch (final InvalidConfigurationException ex) { if (this.debug) { Bukkit.getLogger().log(Level.INFO, "[Metrics] " + ex.getMessage()); } return true; } return this.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 (this.optOutLock) { // Check if the server owner has already set opt-out, if not, set // it. if (isOptOut()) { this.configuration.set("opt-out", false); this.configuration.save(this.configurationFile); } // Enable Task, if it is not running if (this.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 (this.optOutLock) { // Check if the server owner has already set opt-out, if not, set // it. if (!isOptOut()) { this.configuration.set("opt-out", true); this.configuration.save(this.configurationFile); } // Disable Task, if it is running if (this.task != null) { this.task.cancel(); this.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. final File pluginsFolder = this.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 final PluginDescriptionFile description = this.plugin.getDescription(); final String pluginName = description.getName(); final boolean onlineMode = Bukkit.getServer().getOnlineMode(); // TRUE // if // online // mode // is // enabled final String pluginVersion = description.getVersion(); final String serverVersion = Bukkit.getVersion(); int playersOnline = 0; try { if (Bukkit.class.getMethod("getOnlinePlayers", new Class[0]).getReturnType() == Collection.class) { playersOnline = ((Collection) Bukkit.class.getMethod("getOnlinePlayers", new Class[0]).invoke(null, new Object[0])).size(); } else { playersOnline = ((Player[]) Bukkit.class.getMethod("getOnlinePlayers", new Class[0]).invoke(null, new Object[0])).length; } } catch (NoSuchMethodException | InvocationTargetException | IllegalAccessException ex) { } // END server software specific section -- all code below does not use // any code outside of this class / Java // Construct the post data final 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", this.guid); appendJSONPair(json, "plugin_version", pluginVersion); appendJSONPair(json, "server_version", serverVersion); appendJSONPair(json, "players_online", Integer.toString(playersOnline)); // New data as of R6 final String osname = System.getProperty("os.name"); String osarch = System.getProperty("os.arch"); final String osversion = System.getProperty("os.version"); final String java_version = System.getProperty("java.version"); final 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 (this.graphs.size() > 0) { synchronized (this.graphs) { json.append(','); json.append('"'); json.append("graphs"); json.append('"'); json.append(':'); json.append('{'); boolean firstGraph = true; for (final Graph graph : this.graphs) { final StringBuilder graphJson = new StringBuilder(); graphJson.append('{'); for (final 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 final 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(); } final byte[] uncompressed = json.toString().getBytes(); final 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 (this.debug) { PS.debug("[Metrics] Prepared request for " + pluginName + " uncompressed=" + uncompressed.length + " compressed=" + compressed.length); } // Write the data final 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 (this.graphs) { for (final Graph graph : this.graphs) { for (final Plotter plotter : graph.getPlotters()) { plotter.reset(); } } } } } } /** * 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 (final Exception e) { return false; } } /** * 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 this.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) { this.plotters.add(plotter); } /** * Remove a plotter from the graph * * @param plotter the plotter to remove from the graph */ public void removePlotter(final Plotter plotter) { this.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(this.plotters); } @Override public int hashCode() { return this.name.hashCode(); } @Override public boolean equals(final Object object) { if (!(object instanceof Graph)) { return false; } final Graph graph = (Graph) object; return graph.name.equals(this.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 this.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(this.name) && (plotter.getValue() == getValue()); } } }