001package net.milkbowl.vault; 002 003/* 004 * Copyright 2011 Tyler Blair. All rights reserved. 005 * 006 * Redistribution and use in source and binary forms, with or without modification, are 007 * permitted provided that the following conditions are met: 008 * 009 * 1. Redistributions of source code must retain the above copyright notice, this list of 010 * conditions and the following disclaimer. 011 * 012 * 2. Redistributions in binary form must reproduce the above copyright notice, this list 013 * of conditions and the following disclaimer in the documentation and/or other materials 014 * provided with the distribution. 015 * 016 * THIS SOFTWARE IS PROVIDED BY THE AUTHOR ''AS IS'' AND ANY EXPRESS OR IMPLIED 017 * WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND 018 * FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE AUTHOR OR 019 * CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR 020 * CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR 021 * SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON 022 * ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING 023 * NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF 024 * ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 025 * 026 * The views and conclusions contained in the software and documentation are those of the 027 * authors and contributors and should not be interpreted as representing official policies, 028 * either expressed or implied, of anybody else. 029 */ 030 031import java.io.BufferedReader; 032import java.io.ByteArrayOutputStream; 033import java.io.File; 034import java.io.IOException; 035import java.io.InputStreamReader; 036import java.io.OutputStream; 037import java.io.UnsupportedEncodingException; 038import java.net.Proxy; 039import java.net.URL; 040import java.net.URLConnection; 041import java.net.URLEncoder; 042import java.util.Collections; 043import java.util.HashSet; 044import java.util.Iterator; 045import java.util.LinkedHashSet; 046import java.util.Set; 047import java.util.UUID; 048import java.util.logging.Level; 049import java.util.zip.GZIPOutputStream; 050 051import net.milkbowl.vault.chat.Chat; 052import net.milkbowl.vault.economy.Economy; 053import net.milkbowl.vault.permission.Permission; 054 055import org.bukkit.Bukkit; 056import org.bukkit.configuration.InvalidConfigurationException; 057import org.bukkit.configuration.file.YamlConfiguration; 058import org.bukkit.plugin.Plugin; 059import org.bukkit.plugin.PluginDescriptionFile; 060import org.bukkit.plugin.RegisteredServiceProvider; 061import org.bukkit.scheduler.BukkitTask; 062 063public class Metrics { 064 065 /** 066 * The current revision number 067 */ 068 private final static int REVISION = 7; 069 070 /** 071 * The base url of the metrics domain 072 */ 073 private static final String BASE_URL = "http://report.mcstats.org"; 074 075 /** 076 * The url used to report a server's status 077 */ 078 private static final String REPORT_URL = "/plugin/%s"; 079 080 /** 081 * Interval of time to ping (in minutes) 082 */ 083 private static final int PING_INTERVAL = 15; 084 085 /** 086 * The plugin this metrics submits for 087 */ 088 private final Plugin plugin; 089 090 /** 091 * All of the custom graphs to submit to metrics 092 */ 093 private final Set<Graph> graphs = Collections.synchronizedSet(new HashSet<Graph>()); 094 095 /** 096 * The plugin configuration file 097 */ 098 private final YamlConfiguration configuration; 099 100 /** 101 * The plugin configuration file 102 */ 103 private final File configurationFile; 104 105 /** 106 * Unique server id 107 */ 108 private final String guid; 109 110 /** 111 * Debug mode 112 */ 113 private final boolean debug; 114 115 /** 116 * Lock for synchronization 117 */ 118 private final Object optOutLock = new Object(); 119 120 /** 121 * The scheduled task 122 */ 123 private volatile BukkitTask task = null; 124 125 public Metrics(Plugin plugin) throws IOException { 126 if (plugin == null) { 127 throw new IllegalArgumentException("Plugin cannot be null"); 128 } 129 130 this.plugin = plugin; 131 132 // load the config 133 configurationFile = getConfigFile(); 134 configuration = YamlConfiguration.loadConfiguration(configurationFile); 135 136 // add some defaults 137 configuration.addDefault("opt-out", false); 138 configuration.addDefault("guid", UUID.randomUUID().toString()); 139 configuration.addDefault("debug", false); 140 141 // Do we need to create the file? 142 if (configuration.get("guid", null) == null) { 143 configuration.options().header("http://mcstats.org").copyDefaults(true); 144 configuration.save(configurationFile); 145 } 146 147 // Load the guid then 148 guid = configuration.getString("guid"); 149 debug = configuration.getBoolean("debug", false); 150 } 151 152 public void findCustomData() { 153 // Create our Economy Graph and Add our Economy plotters 154 Graph econGraph = createGraph("Economy"); 155 RegisteredServiceProvider<Economy> rspEcon = Bukkit.getServer().getServicesManager().getRegistration(Economy.class); 156 Economy econ = null; 157 if (rspEcon != null) { 158 econ = rspEcon.getProvider(); 159 } 160 final String econName = econ != null ? econ.getName() : "No Economy"; 161 econGraph.addPlotter(new Metrics.Plotter(econName) { 162 163 @Override 164 public int getValue() { 165 return 1; 166 } 167 }); 168 169 // Create our Permission Graph and Add our permission Plotters 170 Graph permGraph = createGraph("Permission"); 171 final String permName = Bukkit.getServer().getServicesManager().getRegistration(Permission.class).getProvider().getName(); 172 permGraph.addPlotter(new Metrics.Plotter(permName) { 173 174 @Override 175 public int getValue() { 176 return 1; 177 } 178 }); 179 180 // Create our Chat Graph and Add our chat Plotters 181 Graph chatGraph = createGraph("Chat"); 182 RegisteredServiceProvider<Chat> rspChat = Bukkit.getServer().getServicesManager().getRegistration(Chat.class); 183 Chat chat = null; 184 if (rspChat != null) { 185 chat = rspChat.getProvider(); 186 } 187 final String chatName = chat != null ? chat.getName() : "No Chat"; 188 // Add our Chat Plotters 189 chatGraph.addPlotter(new Metrics.Plotter(chatName) { 190 191 @Override 192 public int getValue() { 193 return 1; 194 } 195 }); 196 } 197 198 /** 199 * Construct and create a Graph that can be used to separate specific plotters to their own graphs on the metrics 200 * website. Plotters can be added to the graph object returned. 201 * 202 * @param name The name of the graph 203 * @return Graph object created. Will never return NULL under normal circumstances unless bad parameters are given 204 */ 205 public Graph createGraph(final String name) { 206 if (name == null) { 207 throw new IllegalArgumentException("Graph name cannot be null"); 208 } 209 210 // Construct the graph object 211 final Graph graph = new Graph(name); 212 213 // Now we can add our graph 214 graphs.add(graph); 215 216 // and return back 217 return graph; 218 } 219 220 /** 221 * Add a Graph object to BukkitMetrics that represents data for the plugin that should be sent to the backend 222 * 223 * @param graph The name of the graph 224 */ 225 public void addGraph(final Graph graph) { 226 if (graph == null) { 227 throw new IllegalArgumentException("Graph cannot be null"); 228 } 229 230 graphs.add(graph); 231 } 232 233 /** 234 * Start measuring statistics. This will immediately create an async repeating task as the plugin and send the 235 * initial data to the metrics backend, and then after that it will post in increments of PING_INTERVAL * 1200 236 * ticks. 237 * 238 * @return True if statistics measuring is running, otherwise false. 239 */ 240 public boolean start() { 241 synchronized (optOutLock) { 242 // Did we opt out? 243 if (isOptOut()) { 244 return false; 245 } 246 247 // Is metrics already running? 248 if (task != null) { 249 return true; 250 } 251 252 // Begin hitting the server with glorious data 253 task = plugin.getServer().getScheduler().runTaskTimerAsynchronously(plugin, new Runnable() { 254 255 private boolean firstPost = true; 256 257 public void run() { 258 try { 259 // This has to be synchronized or it can collide with the disable method. 260 synchronized (optOutLock) { 261 // Disable Task, if it is running and the server owner decided to opt-out 262 if (isOptOut() && task != null) { 263 task.cancel(); 264 task = null; 265 // Tell all plotters to stop gathering information. 266 for (Graph graph : graphs) { 267 graph.onOptOut(); 268 } 269 } 270 } 271 272 // We use the inverse of firstPost because if it is the first time we are posting, 273 // it is not a interval ping, so it evaluates to FALSE 274 // Each time thereafter it will evaluate to TRUE, i.e PING! 275 postPlugin(!firstPost); 276 277 // After the first post we set firstPost to false 278 // Each post thereafter will be a ping 279 firstPost = false; 280 } catch (IOException e) { 281 if (debug) { 282 Bukkit.getLogger().log(Level.INFO, "[Metrics] " + e.getMessage()); 283 } 284 } 285 } 286 }, 0, PING_INTERVAL * 1200); 287 288 return true; 289 } 290 } 291 292 /** 293 * Has the server owner denied plugin metrics? 294 * 295 * @return true if metrics should be opted out of it 296 */ 297 public boolean isOptOut() { 298 synchronized (optOutLock) { 299 try { 300 // Reload the metrics file 301 configuration.load(getConfigFile()); 302 } catch (IOException ex) { 303 if (debug) { 304 Bukkit.getLogger().log(Level.INFO, "[Metrics] " + ex.getMessage()); 305 } 306 return true; 307 } catch (InvalidConfigurationException ex) { 308 if (debug) { 309 Bukkit.getLogger().log(Level.INFO, "[Metrics] " + ex.getMessage()); 310 } 311 return true; 312 } 313 return configuration.getBoolean("opt-out", false); 314 } 315 } 316 317 /** 318 * Enables metrics for the server by setting "opt-out" to false in the config file and starting the metrics task. 319 * 320 * @throws java.io.IOException 321 */ 322 public void enable() throws IOException { 323 // This has to be synchronized or it can collide with the check in the task. 324 synchronized (optOutLock) { 325 // Check if the server owner has already set opt-out, if not, set it. 326 if (isOptOut()) { 327 configuration.set("opt-out", false); 328 configuration.save(configurationFile); 329 } 330 331 // Enable Task, if it is not running 332 if (task == null) { 333 start(); 334 } 335 } 336 } 337 338 /** 339 * Disables metrics for the server by setting "opt-out" to true in the config file and canceling the metrics task. 340 * 341 * @throws java.io.IOException 342 */ 343 public void disable() throws IOException { 344 // This has to be synchronized or it can collide with the check in the task. 345 synchronized (optOutLock) { 346 // Check if the server owner has already set opt-out, if not, set it. 347 if (!isOptOut()) { 348 configuration.set("opt-out", true); 349 configuration.save(configurationFile); 350 } 351 352 // Disable Task, if it is running 353 if (task != null) { 354 task.cancel(); 355 task = null; 356 } 357 } 358 } 359 360 /** 361 * Gets the File object of the config file that should be used to store data such as the GUID and opt-out status 362 * 363 * @return the File object for the config file 364 */ 365 public File getConfigFile() { 366 // I believe the easiest way to get the base folder (e.g craftbukkit set via -P) for plugins to use 367 // is to abuse the plugin object we already have 368 // plugin.getDataFolder() => base/plugins/PluginA/ 369 // pluginsFolder => base/plugins/ 370 // The base is not necessarily relative to the startup directory. 371 File pluginsFolder = plugin.getDataFolder().getParentFile(); 372 373 // return => base/plugins/PluginMetrics/config.yml 374 return new File(new File(pluginsFolder, "PluginMetrics"), "config.yml"); 375 } 376 377 /** 378 * Generic method that posts a plugin to the metrics website 379 */ 380 private void postPlugin(final boolean isPing) throws IOException { 381 // Server software specific section 382 PluginDescriptionFile description = plugin.getDescription(); 383 String pluginName = description.getName(); 384 boolean onlineMode = Bukkit.getServer().getOnlineMode(); // TRUE if online mode is enabled 385 String pluginVersion = description.getVersion(); 386 String serverVersion = Bukkit.getVersion(); 387 int playersOnline = Bukkit.getServer().getOnlinePlayers().length; 388 389 // END server software specific section -- all code below does not use any code outside of this class / Java 390 391 // Construct the post data 392 StringBuilder json = new StringBuilder(1024); 393 json.append('{'); 394 395 // The plugin's description file containg all of the plugin data such as name, version, author, etc 396 appendJSONPair(json, "guid", guid); 397 appendJSONPair(json, "plugin_version", pluginVersion); 398 appendJSONPair(json, "server_version", serverVersion); 399 appendJSONPair(json, "players_online", Integer.toString(playersOnline)); 400 401 // New data as of R6 402 String osname = System.getProperty("os.name"); 403 String osarch = System.getProperty("os.arch"); 404 String osversion = System.getProperty("os.version"); 405 String java_version = System.getProperty("java.version"); 406 int coreCount = Runtime.getRuntime().availableProcessors(); 407 408 // normalize os arch .. amd64 -> x86_64 409 if (osarch.equals("amd64")) { 410 osarch = "x86_64"; 411 } 412 413 appendJSONPair(json, "osname", osname); 414 appendJSONPair(json, "osarch", osarch); 415 appendJSONPair(json, "osversion", osversion); 416 appendJSONPair(json, "cores", Integer.toString(coreCount)); 417 appendJSONPair(json, "auth_mode", onlineMode ? "1" : "0"); 418 appendJSONPair(json, "java_version", java_version); 419 420 // If we're pinging, append it 421 if (isPing) { 422 appendJSONPair(json, "ping", "1"); 423 } 424 425 if (graphs.size() > 0) { 426 synchronized (graphs) { 427 json.append(','); 428 json.append('"'); 429 json.append("graphs"); 430 json.append('"'); 431 json.append(':'); 432 json.append('{'); 433 434 boolean firstGraph = true; 435 436 final Iterator<Graph> iter = graphs.iterator(); 437 438 while (iter.hasNext()) { 439 Graph graph = iter.next(); 440 441 StringBuilder graphJson = new StringBuilder(); 442 graphJson.append('{'); 443 444 for (Plotter plotter : graph.getPlotters()) { 445 appendJSONPair(graphJson, plotter.getColumnName(), Integer.toString(plotter.getValue())); 446 } 447 448 graphJson.append('}'); 449 450 if (!firstGraph) { 451 json.append(','); 452 } 453 454 json.append(escapeJSON(graph.getName())); 455 json.append(':'); 456 json.append(graphJson); 457 458 firstGraph = false; 459 } 460 461 json.append('}'); 462 } 463 } 464 465 // close json 466 json.append('}'); 467 468 // Create the url 469 URL url = new URL(BASE_URL + String.format(REPORT_URL, urlEncode(pluginName))); 470 471 // Connect to the website 472 URLConnection connection; 473 474 // Mineshafter creates a socks proxy, so we can safely bypass it 475 // It does not reroute POST requests so we need to go around it 476 if (isMineshafterPresent()) { 477 connection = url.openConnection(Proxy.NO_PROXY); 478 } else { 479 connection = url.openConnection(); 480 } 481 482 483 byte[] uncompressed = json.toString().getBytes(); 484 byte[] compressed = gzip(json.toString()); 485 486 // Headers 487 connection.addRequestProperty("User-Agent", "MCStats/" + REVISION); 488 connection.addRequestProperty("Content-Type", "application/json"); 489 connection.addRequestProperty("Content-Encoding", "gzip"); 490 connection.addRequestProperty("Content-Length", Integer.toString(compressed.length)); 491 connection.addRequestProperty("Accept", "application/json"); 492 connection.addRequestProperty("Connection", "close"); 493 494 connection.setDoOutput(true); 495 496 if (debug) { 497 System.out.println("[Metrics] Prepared request for " + pluginName + " uncompressed=" + uncompressed.length + " compressed=" + compressed.length); 498 } 499 500 // Write the data 501 OutputStream os = connection.getOutputStream(); 502 os.write(compressed); 503 os.flush(); 504 505 // Now read the response 506 final BufferedReader reader = new BufferedReader(new InputStreamReader(connection.getInputStream())); 507 String response = reader.readLine(); 508 509 // close resources 510 os.close(); 511 reader.close(); 512 513 if (response == null || response.startsWith("ERR") || response.startsWith("7")) { 514 if (response == null) { 515 response = "null"; 516 } else if (response.startsWith("7")) { 517 response = response.substring(response.startsWith("7,") ? 2 : 1); 518 } 519 520 throw new IOException(response); 521 } else { 522 // Is this the first update this hour? 523 if (response.equals("1") || response.contains("This is your first update this hour")) { 524 synchronized (graphs) { 525 final Iterator<Graph> iter = graphs.iterator(); 526 527 while (iter.hasNext()) { 528 final Graph graph = iter.next(); 529 530 for (Plotter plotter : graph.getPlotters()) { 531 plotter.reset(); 532 } 533 } 534 } 535 } 536 } 537 } 538 539 /** 540 * GZip compress a string of bytes 541 * 542 * @param input 543 * @return 544 */ 545 public static byte[] gzip(String input) { 546 ByteArrayOutputStream baos = new ByteArrayOutputStream(); 547 GZIPOutputStream gzos = null; 548 549 try { 550 gzos = new GZIPOutputStream(baos); 551 gzos.write(input.getBytes("UTF-8")); 552 } catch (IOException e) { 553 e.printStackTrace(); 554 } finally { 555 if (gzos != null) try { 556 gzos.close(); 557 } catch (IOException ignore) { 558 } 559 } 560 561 return baos.toByteArray(); 562 } 563 564 /** 565 * Check if mineshafter is present. If it is, we need to bypass it to send POST requests 566 * 567 * @return true if mineshafter is installed on the server 568 */ 569 private boolean isMineshafterPresent() { 570 try { 571 Class.forName("mineshafter.MineServer"); 572 return true; 573 } catch (Exception e) { 574 return false; 575 } 576 } 577 578 /** 579 * Appends a json encoded key/value pair to the given string builder. 580 * 581 * @param json 582 * @param key 583 * @param value 584 * @throws UnsupportedEncodingException 585 */ 586 private static void appendJSONPair(StringBuilder json, String key, String value) throws UnsupportedEncodingException { 587 boolean isValueNumeric; 588 589 try { 590 Double.parseDouble(value); 591 isValueNumeric = true; 592 } catch (NumberFormatException e) { 593 isValueNumeric = false; 594 } 595 596 if (json.charAt(json.length() - 1) != '{') { 597 json.append(','); 598 } 599 600 json.append(escapeJSON(key)); 601 json.append(':'); 602 603 if (isValueNumeric) { 604 json.append(value); 605 } else { 606 json.append(escapeJSON(value)); 607 } 608 } 609 610 /** 611 * Escape a string to create a valid JSON string 612 * 613 * @param text 614 * @return 615 */ 616 private static String escapeJSON(String text) { 617 StringBuilder builder = new StringBuilder(); 618 619 builder.append('"'); 620 for (int index = 0; index < text.length(); index++) { 621 char chr = text.charAt(index); 622 623 switch (chr) { 624 case '"': 625 case '\\': 626 builder.append('\\'); 627 builder.append(chr); 628 break; 629 case '\b': 630 builder.append("\\b"); 631 break; 632 case '\t': 633 builder.append("\\t"); 634 break; 635 case '\n': 636 builder.append("\\n"); 637 break; 638 case '\r': 639 builder.append("\\r"); 640 break; 641 default: 642 if (chr < ' ') { 643 String t = "000" + Integer.toHexString(chr); 644 builder.append("\\u" + t.substring(t.length() - 4)); 645 } else { 646 builder.append(chr); 647 } 648 break; 649 } 650 } 651 builder.append('"'); 652 653 return builder.toString(); 654 } 655 656 /** 657 * Encode text as UTF-8 658 * 659 * @param text the text to encode 660 * @return the encoded text, as UTF-8 661 */ 662 private static String urlEncode(final String text) throws UnsupportedEncodingException { 663 return URLEncoder.encode(text, "UTF-8"); 664 } 665 666 /** 667 * Represents a custom graph on the website 668 */ 669 public static class Graph { 670 671 /** 672 * The graph's name, alphanumeric and spaces only :) If it does not comply to the above when submitted, it is 673 * rejected 674 */ 675 private final String name; 676 677 /** 678 * The set of plotters that are contained within this graph 679 */ 680 private final Set<Plotter> plotters = new LinkedHashSet<Plotter>(); 681 682 private Graph(final String name) { 683 this.name = name; 684 } 685 686 /** 687 * Gets the graph's name 688 * 689 * @return the Graph's name 690 */ 691 public String getName() { 692 return name; 693 } 694 695 /** 696 * Add a plotter to the graph, which will be used to plot entries 697 * 698 * @param plotter the plotter to add to the graph 699 */ 700 public void addPlotter(final Plotter plotter) { 701 plotters.add(plotter); 702 } 703 704 /** 705 * Remove a plotter from the graph 706 * 707 * @param plotter the plotter to remove from the graph 708 */ 709 public void removePlotter(final Plotter plotter) { 710 plotters.remove(plotter); 711 } 712 713 /** 714 * Gets an <b>unmodifiable</b> set of the plotter objects in the graph 715 * 716 * @return an unmodifiable {@link java.util.Set} of the plotter objects 717 */ 718 public Set<Plotter> getPlotters() { 719 return Collections.unmodifiableSet(plotters); 720 } 721 722 @Override 723 public int hashCode() { 724 return name.hashCode(); 725 } 726 727 @Override 728 public boolean equals(final Object object) { 729 if (!(object instanceof Graph)) { 730 return false; 731 } 732 733 final Graph graph = (Graph) object; 734 return graph.name.equals(name); 735 } 736 737 /** 738 * Called when the server owner decides to opt-out of BukkitMetrics while the server is running. 739 */ 740 protected void onOptOut() { 741 } 742 } 743 744 /** 745 * Interface used to collect custom data for a plugin 746 */ 747 public static abstract class Plotter { 748 749 /** 750 * The plot's name 751 */ 752 private final String name; 753 754 /** 755 * Construct a plotter with the default plot name 756 */ 757 public Plotter() { 758 this("Default"); 759 } 760 761 /** 762 * Construct a plotter with a specific plot name 763 * 764 * @param name the name of the plotter to use, which will show up on the website 765 */ 766 public Plotter(final String name) { 767 this.name = name; 768 } 769 770 /** 771 * Get the current value for the plotted point. Since this function defers to an external function it may or may 772 * not return immediately thus cannot be guaranteed to be thread friendly or safe. This function can be called 773 * from any thread so care should be taken when accessing resources that need to be synchronized. 774 * 775 * @return the current value for the point to be plotted. 776 */ 777 public abstract int getValue(); 778 779 /** 780 * Get the column name for the plotted point 781 * 782 * @return the plotted point's column name 783 */ 784 public String getColumnName() { 785 return name; 786 } 787 788 /** 789 * Called after the website graphs have been updated 790 */ 791 public void reset() { 792 } 793 794 @Override 795 public int hashCode() { 796 return getColumnName().hashCode(); 797 } 798 799 @Override 800 public boolean equals(final Object object) { 801 if (!(object instanceof Plotter)) { 802 return false; 803 } 804 805 final Plotter plotter = (Plotter) object; 806 return plotter.name.equals(name) && plotter.getValue() == getValue(); 807 } 808 } 809}