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}