diff --git a/build.gradle.kts b/build.gradle.kts index 952364fa..5ab08725 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -35,7 +35,7 @@ allprojects { // NMS (for jitpack compilation) maven("https://repo.codemc.org/repository/nms/") - // bStats, mcMMO, BentoBox + // mcMMO, BentoBox maven("https://repo.codemc.org/repository/maven-public/") // Spigot API, Bungee API diff --git a/eco-core/core-plugin/build.gradle b/eco-core/core-plugin/build.gradle index d6f52610..4734a403 100644 --- a/eco-core/core-plugin/build.gradle +++ b/eco-core/core-plugin/build.gradle @@ -2,7 +2,6 @@ group 'com.willfp' version rootProject.version dependencies { - implementation 'org.bstats:bstats-bukkit:1.7' implementation('net.kyori:adventure-text-minimessage:4.1.0-SNAPSHOT') { exclude group: 'net.kyori', module: 'adventure-api' } diff --git a/eco-core/core-plugin/src/main/kotlin/com/willfp/eco/internal/spigot/EcoHandler.kt b/eco-core/core-plugin/src/main/kotlin/com/willfp/eco/internal/spigot/EcoHandler.kt index 1501b1a4..d9f2d7e6 100644 --- a/eco-core/core-plugin/src/main/kotlin/com/willfp/eco/internal/spigot/EcoHandler.kt +++ b/eco-core/core-plugin/src/main/kotlin/com/willfp/eco/internal/spigot/EcoHandler.kt @@ -118,7 +118,7 @@ class EcoHandler : com.willfp.eco.internal.spigot.EcoSpigotPlugin(), Handler { } override fun registerBStats(plugin: EcoPlugin) { - MetricHandler.createMetrics(plugin, this.ecoPlugin) + MetricHandler.createMetrics(plugin) } override fun getRequirementFactory(): EcoRequirementFactory { diff --git a/eco-core/core-plugin/src/main/kotlin/com/willfp/eco/internal/spigot/integrations/bstats/MetricHandler.kt b/eco-core/core-plugin/src/main/kotlin/com/willfp/eco/internal/spigot/integrations/bstats/MetricHandler.kt index 372b80c5..3d2d3a44 100644 --- a/eco-core/core-plugin/src/main/kotlin/com/willfp/eco/internal/spigot/integrations/bstats/MetricHandler.kt +++ b/eco-core/core-plugin/src/main/kotlin/com/willfp/eco/internal/spigot/integrations/bstats/MetricHandler.kt @@ -1,21 +1,9 @@ package com.willfp.eco.internal.spigot.integrations.bstats import com.willfp.eco.core.EcoPlugin -import org.bstats.bukkit.Metrics -import org.bukkit.configuration.file.YamlConfiguration -import java.io.File object MetricHandler { - fun createMetrics(plugin: EcoPlugin, ecoPlugin: EcoPlugin) { - val bStatsFolder = File(plugin.dataFolder.parentFile, "bStats") - val configFile = File(bStatsFolder, "config.yml") - val config = YamlConfiguration.loadConfiguration(configFile) - - if (config.isSet("serverUuid")) { - config.set("enabled", ecoPlugin.configYml.getBool("enable-bstats")) - config.save(configFile) - } - - Metrics(plugin, plugin.bStatsId) + fun createMetrics(plugin: EcoPlugin) { + Metrics(plugin) } } \ No newline at end of file diff --git a/eco-core/core-plugin/src/main/kotlin/com/willfp/eco/internal/spigot/integrations/bstats/Metrics.kt b/eco-core/core-plugin/src/main/kotlin/com/willfp/eco/internal/spigot/integrations/bstats/Metrics.kt new file mode 100644 index 00000000..a79c06d4 --- /dev/null +++ b/eco-core/core-plugin/src/main/kotlin/com/willfp/eco/internal/spigot/integrations/bstats/Metrics.kt @@ -0,0 +1,391 @@ +package com.willfp.eco.internal.spigot.integrations.bstats + +import com.willfp.eco.core.EcoPlugin +import org.bukkit.Bukkit +import org.bukkit.configuration.file.YamlConfiguration +import java.io.BufferedReader +import java.io.ByteArrayOutputStream +import java.io.DataOutputStream +import java.io.File +import java.io.IOException +import java.io.InputStreamReader +import java.net.URL +import java.nio.charset.StandardCharsets +import java.util.Arrays +import java.util.Objects +import java.util.UUID +import java.util.concurrent.Executors +import java.util.concurrent.TimeUnit +import java.util.function.BiConsumer +import java.util.function.Consumer +import java.util.function.Supplier +import java.util.logging.Level +import java.util.stream.Collectors +import java.util.zip.GZIPOutputStream +import javax.net.ssl.HttpsURLConnection + +@Suppress("UNCHECKED_CAST") +class Metrics(private val plugin: EcoPlugin) { + private val metricsBase: MetricsBase + + private fun appendPlatformData(builder: JsonObjectBuilder) { + builder.appendField("playerAmount", playerAmount) + builder.appendField("onlineMode", if (Bukkit.getOnlineMode()) 1 else 0) + builder.appendField("bukkitVersion", Bukkit.getVersion()) + builder.appendField("bukkitName", Bukkit.getName()) + builder.appendField("javaVersion", System.getProperty("java.version")) + builder.appendField("osName", System.getProperty("os.name")) + builder.appendField("osArch", System.getProperty("os.arch")) + builder.appendField("osVersion", System.getProperty("os.version")) + builder.appendField("coreCount", Runtime.getRuntime().availableProcessors()) + } + + private fun appendServiceData(builder: JsonObjectBuilder) { + builder.appendField("pluginVersion", plugin.description.version) + } + + private val playerAmount: Int + get() = Bukkit.getOnlinePlayers().size + + class MetricsBase( + private val platform: String, + private val serverUuid: String, + private val serviceId: Int, + private val enabled: Boolean, + private val appendPlatformDataConsumer: Consumer, + private val appendServiceDataConsumer: Consumer, + private val submitTaskConsumer: Consumer?, + private val checkServiceEnabledSupplier: Supplier, + private val errorLogger: BiConsumer, + private val infoLogger: Consumer, + private val logErrors: Boolean, + private val logSentData: Boolean, + private val logResponseStatusText: Boolean + ) { + private val customCharts: MutableSet = HashSet() + + private fun startSubmitting() { + val submitTask = Runnable { + if (!enabled || !checkServiceEnabledSupplier.get()) { + // Submitting data or service is disabled + scheduler.shutdown() + return@Runnable + } + if (submitTaskConsumer != null) { + submitTaskConsumer.accept(Runnable { submitData() }) + } else { + submitData() + } + } + // Many servers tend to restart at a fixed time at xx:00 which causes an uneven distribution + // of requests on the + // bStats backend. To circumvent this problem, we introduce some randomness into the initial + // and second delay. + // WARNING: You must not modify and part of this Metrics class, including the submit delay or + // frequency! + // WARNING: Modifying this code will get your plugin banned on bStats. Just don't do it! + val initialDelay = (1000 * 60 * (3 + Math.random() * 3)).toLong() + val secondDelay = (1000 * 60 * (Math.random() * 30)).toLong() + scheduler.schedule(submitTask, initialDelay, TimeUnit.MILLISECONDS) + scheduler.scheduleAtFixedRate( + submitTask, initialDelay + secondDelay, (1000 * 60 * 30).toLong(), TimeUnit.MILLISECONDS + ) + } + + private fun submitData() { + val baseJsonBuilder = JsonObjectBuilder() + appendPlatformDataConsumer.accept(baseJsonBuilder) + val serviceJsonBuilder = JsonObjectBuilder() + appendServiceDataConsumer.accept(serviceJsonBuilder) + val chartData = customCharts.stream() + .map { customChart: CustomChart -> + customChart.getRequestJsonObject( + errorLogger, logErrors + ) + } + .filter { obj: JsonObjectBuilder.JsonObject? -> Objects.nonNull(obj) } + .toArray() + + serviceJsonBuilder.appendField("id", serviceId) + serviceJsonBuilder.appendField( + "customCharts", + chartData as Array + ) + baseJsonBuilder.appendField("service", serviceJsonBuilder.build()) + baseJsonBuilder.appendField("serverUUID", serverUuid) + baseJsonBuilder.appendField("metricsVersion", METRICS_VERSION) + val data = baseJsonBuilder.build() + scheduler.execute { + try { + // Send the data + sendData(data) + } catch (e: Exception) { + // do nothing + } + } + } + + @Throws(Exception::class) + private fun sendData(data: JsonObjectBuilder.JsonObject) { + if (logSentData) { + infoLogger.accept("Sent bStats metrics data: $data") + } + val url = String.format(REPORT_URL, platform) + val connection = URL(url).openConnection() as HttpsURLConnection + // Compress the data to save bandwidth + val compressedData = compress(data.toString()) + connection.requestMethod = "POST" + connection.addRequestProperty("Accept", "application/json") + connection.addRequestProperty("Connection", "close") + connection.addRequestProperty("Content-Encoding", "gzip") + connection.addRequestProperty("Content-Length", compressedData!!.size.toString()) + connection.setRequestProperty("Content-Type", "application/json") + connection.setRequestProperty("User-Agent", "Metrics-Service/1") + connection.doOutput = true + DataOutputStream(connection.outputStream).use { outputStream -> outputStream.write(compressedData) } + val builder = StringBuilder() + BufferedReader(InputStreamReader(connection.inputStream)).use { bufferedReader -> + var line: String? + while (bufferedReader.readLine().also { line = it } != null) { + builder.append(line) + } + } + if (logResponseStatusText) { + infoLogger.accept("Sent data to bStats and received response: $builder") + } + } + + private fun checkRelocation() { + // You can use the property to disable the check in your test environment + if (System.getProperty("bstats.relocatecheck") == null + || System.getProperty("bstats.relocatecheck") != "false" + ) { + // Maven's Relocate is clever and changes strings, too. So we have to use this little + // "trick" ... :D + val defaultPackage = String( + byteArrayOf( + 'o'.code.toByte(), + 'r'.code.toByte(), + 'g'.code.toByte(), + '.'.code.toByte(), + 'b'.code.toByte(), + 's'.code.toByte(), + 't'.code.toByte(), + 'a'.code.toByte(), + 't'.code.toByte(), + 's'.code.toByte() + ) + ) + val examplePackage = String( + byteArrayOf( + 'y'.code.toByte(), + 'o'.code.toByte(), + 'u'.code.toByte(), + 'r'.code.toByte(), + '.'.code.toByte(), + 'p'.code.toByte(), + 'a'.code.toByte(), + 'c'.code.toByte(), + 'k'.code.toByte(), + 'a'.code.toByte(), + 'g'.code.toByte(), + 'e'.code.toByte() + ) + ) + // We want to make sure no one just copy & pastes the example and uses the wrong package + // names + check( + !(MetricsBase::class.java.getPackage().name.startsWith( + defaultPackage + ) + || MetricsBase::class.java.getPackage().name.startsWith( + examplePackage + )) + ) { "bStats Metrics class has not been relocated correctly!" } + } + } + + companion object { + const val METRICS_VERSION = "2.2.1" + private val scheduler = + Executors.newScheduledThreadPool(1) { task: Runnable? -> Thread(task, "bStats-Metrics") } + private const val REPORT_URL = "https://bStats.org/api/v2/data/%s" + + private fun compress(str: String?): ByteArray? { + if (str == null) { + return null + } + val outputStream = ByteArrayOutputStream() + GZIPOutputStream(outputStream).use { gzip -> gzip.write(str.toByteArray(StandardCharsets.UTF_8)) } + return outputStream.toByteArray() + } + } + + init { + checkRelocation() + if (enabled) { + startSubmitting() + } + } + } + + + abstract class CustomChart protected constructor(chartId: String?) { + private val chartId: String + fun getRequestJsonObject( + errorLogger: BiConsumer, logErrors: Boolean + ): JsonObjectBuilder.JsonObject? { + val builder = JsonObjectBuilder() + builder.appendField("chartId", chartId) + try { + val data = chartData + ?: // If the data is null we don't send the chart. + return null + builder.appendField("data", data) + } catch (t: Throwable) { + if (logErrors) { + errorLogger.accept("Failed to get data for custom chart with id $chartId", t) + } + return null + } + return builder.build() + } + + protected abstract val chartData: JsonObjectBuilder.JsonObject? + + init { + requireNotNull(chartId) { "chartId must not be null" } + this.chartId = chartId + } + } + + class JsonObjectBuilder { + private var builder: StringBuilder? = StringBuilder() + private var hasAtLeastOneField = false + + fun appendField(key: String?, value: String?): JsonObjectBuilder { + requireNotNull(value) { "JSON value must not be null" } + appendFieldUnescaped(key, "\"" + escape(value) + "\"") + return this + } + + fun appendField(key: String?, value: Int): JsonObjectBuilder { + appendFieldUnescaped(key, value.toString()) + return this + } + + fun appendField(key: String?, `object`: JsonObject?): JsonObjectBuilder { + requireNotNull(`object`) { "JSON object must not be null" } + appendFieldUnescaped(key, `object`.toString()) + return this + } + + fun appendField(key: String?, values: Array?): JsonObjectBuilder { + requireNotNull(values) { "JSON values must not be null" } + val escapedValues = Arrays.stream(values).map { obj: JsonObject -> obj.toString() } + .collect(Collectors.joining(",")) + appendFieldUnescaped(key, "[$escapedValues]") + return this + } + + private fun appendFieldUnescaped(key: String?, escapedValue: String) { + checkNotNull(builder) { "JSON has already been built" } + requireNotNull(key) { "JSON key must not be null" } + if (hasAtLeastOneField) { + builder!!.append(",") + } + builder!!.append("\"").append(escape(key)).append("\":").append(escapedValue) + hasAtLeastOneField = true + } + + fun build(): JsonObject { + checkNotNull(builder) { "JSON has already been built" } + val obj = JsonObject( + builder!!.append("}").toString() + ) + builder = null + return obj + } + + class JsonObject(private val value: String) { + override fun toString(): String { + return value + } + } + + companion object { + private fun escape(value: String): String { + val builder = StringBuilder() + for (element in value) { + if (element == '"') { + builder.append("\\\"") + } else if (element == '\\') { + builder.append("\\\\") + } else if (element <= '\u000F') { + builder.append("\\u000").append(Integer.toHexString(element.code)) + } else if (element <= '\u001F') { + builder.append("\\u00").append(Integer.toHexString(element.code)) + } else { + builder.append(element) + } + } + return builder.toString() + } + } + + init { + builder!!.append("{") + } + } + + init { + // Get the config file + val bStatsFolder = File(plugin.dataFolder.parentFile, "bStats") + val configFile = File(bStatsFolder, "config.yml") + val config = YamlConfiguration.loadConfiguration(configFile) + if (!config.isSet("serverUuid")) { + config.addDefault("enabled", true) + config.addDefault("serverUuid", UUID.randomUUID().toString()) + config.addDefault("logFailedRequests", false) + config.addDefault("logSentData", false) + config.addDefault("logResponseStatusText", false) + // Inform the server owners about bStats + config + .options() + .header( + """ + bStats (https://bStats.org) collects some basic information for plugin authors, like how + many people use their plugin and their total player count. It's recommended to keep bStats + enabled, but if you're not comfortable with this, you can turn this setting off. There is no + performance penalty associated with having metrics enabled, and data sent to bStats is fully + anonymous. + """.trimIndent() + ) + .copyDefaults(true) + try { + config.save(configFile) + } catch (ignored: IOException) { + } + } + // Load the data + val serverUUID = config.getString("serverUuid")!! + val logErrors = config.getBoolean("logFailedRequests", false) + val logSentData = config.getBoolean("logSentData", false) + val logResponseStatusText = config.getBoolean("logResponseStatusText", false) + metricsBase = MetricsBase( + "bukkit", + serverUUID, + plugin.bStatsId, + true, + { builder: JsonObjectBuilder -> appendPlatformData(builder) }, + { builder: JsonObjectBuilder -> appendServiceData(builder) }, + { submitDataTask: Runnable? -> Bukkit.getScheduler().runTask(plugin, submitDataTask!!) }, + { plugin.isEnabled }, + { message: String?, error: Throwable? -> this.plugin.logger.log(Level.WARNING, message, error) }, + { message: String? -> this.plugin.logger.log(Level.INFO, message) }, + logErrors, + logSentData, + logResponseStatusText + ) + } +} \ No newline at end of file