From e27b9da37cd2b3374bf3adb24402d6ecdd89541d Mon Sep 17 00:00:00 2001 From: Redned Date: Wed, 3 Jul 2019 01:40:59 -0500 Subject: [PATCH] Add SpigotUpdater and checker, deprecate Bukkit updater --- pom.xml | 4 +- .../battlepluginupdater/PluginUpdater.java | 10 +- .../battlepluginupdater/SpigotUpdater.java | 254 ++++++++++++++ .../mc/alk/battlepluginupdater/Updater.java | 4 + .../checker/UpdateChecker.java | 314 ++++++++++++++++++ 5 files changed, 581 insertions(+), 5 deletions(-) create mode 100644 src/main/java/mc/alk/battlepluginupdater/SpigotUpdater.java create mode 100644 src/main/java/mc/alk/battlepluginupdater/checker/UpdateChecker.java diff --git a/pom.xml b/pom.xml index fe85372..e5464d3 100644 --- a/pom.xml +++ b/pom.xml @@ -3,7 +3,7 @@ 4.0.0 mc.alk BattlePluginUpdater - 2.0 + 2.1 jar BattlePluginUpdater https://github.com/BattlePlugins/PluginUpdater @@ -50,7 +50,7 @@ org.bukkit bukkit - 1.7.2-R0.1-SNAPSHOT + 1.9-R0.1-SNAPSHOT provided true diff --git a/src/main/java/mc/alk/battlepluginupdater/PluginUpdater.java b/src/main/java/mc/alk/battlepluginupdater/PluginUpdater.java index 9c51057..6ae3096 100644 --- a/src/main/java/mc/alk/battlepluginupdater/PluginUpdater.java +++ b/src/main/java/mc/alk/battlepluginupdater/PluginUpdater.java @@ -2,20 +2,21 @@ import java.io.File; import java.util.HashSet; +import java.util.logging.Level; import mc.euro.version.Version; import mc.euro.version.VersionFactory; import org.bukkit.Bukkit; +import org.bukkit.configuration.file.YamlConfiguration; import org.bukkit.event.EventHandler; import org.bukkit.event.Listener; import org.bukkit.event.player.PlayerJoinEvent; import org.bukkit.plugin.Plugin; /** - * Originally a class that downloaded and updated bukkit plugins. Since the new - * bukkit rules this class has been converted into a wrapper around Gravity's - * Updater class + * @deprecated this class is known for causing issues with plugin downloads. + * Instead, use {@link mc.alk.battlepluginupdater.SpigotUpdater} */ public class PluginUpdater { @@ -104,7 +105,10 @@ public static AnnounceUpdateOption fromString(String name) { * @param updateOption when should we update the plugin * @param announceOption who should recieve announcements about a newer * version + * + * @deprecated use */ + @Deprecated public static void update(final Plugin plugin, final int bukkitId, final File file, final UpdateOption updateOption, final AnnounceUpdateOption announceOption) { diff --git a/src/main/java/mc/alk/battlepluginupdater/SpigotUpdater.java b/src/main/java/mc/alk/battlepluginupdater/SpigotUpdater.java new file mode 100644 index 0000000..55a8f0f --- /dev/null +++ b/src/main/java/mc/alk/battlepluginupdater/SpigotUpdater.java @@ -0,0 +1,254 @@ +package mc.alk.battlepluginupdater; + +import mc.alk.battlepluginupdater.checker.UpdateChecker; + +import org.bukkit.ChatColor; +import org.bukkit.configuration.file.YamlConfiguration; +import org.bukkit.plugin.Plugin; + +import java.io.BufferedInputStream; +import java.io.BufferedOutputStream; +import java.io.File; +import java.io.FileOutputStream; +import java.io.IOException; +import java.net.URL; +import java.util.Enumeration; +import java.util.logging.Level; +import java.util.zip.ZipEntry; +import java.util.zip.ZipFile; + +public class SpigotUpdater { + + private Plugin plugin; + private int pluginId; + private String downloadLink; + + private String updateFolder; + + public SpigotUpdater(Plugin plugin, int pluginId, String downloadLink) { + this.plugin = plugin; + this.pluginId = pluginId; + this.downloadLink = downloadLink; + + this.updateFolder = plugin.getServer().getUpdateFolder(); + } + + public void update() { + File pluginFile = plugin.getDataFolder().getParentFile(); + File updaterFile = new File(pluginFile, "Updater"); + File updaterConfigFile = new File(updaterFile, "config.yml"); + + YamlConfiguration config = new YamlConfiguration(); // Config file + config.options().header("This configuration file affects all plugins using the Updater system (version 2+ - http://forums.bukkit.org/threads/96681/ )" + '\n' + + "If you wish to use your API key, read http://wiki.bukkit.org/ServerMods_API and place it below." + '\n' + + "Some updating systems will not adhere to the disabled value, but these may be turned off in their plugin's configuration."); + config.addDefault("api-key", "PUT_API_KEY_HERE"); + config.addDefault("disable", false); + + if (!updaterFile.exists()) { + updaterFile.mkdir(); + } + + boolean createFile = !updaterConfigFile.exists(); + try { + if (createFile) { + updaterConfigFile.createNewFile(); + config.options().copyDefaults(true); + config.save(updaterConfigFile); + } else { + config.load(updaterConfigFile); + } + } catch (final Exception e) { + if (createFile) { + plugin.getLogger().severe("The updater could not create configuration at " + updaterFile.getAbsolutePath()); + } else { + plugin.getLogger().severe("The updater could not load configuration at " + updaterFile.getAbsolutePath()); + } + plugin.getLogger().log(Level.SEVERE, null, e); + } + + boolean disabled = false; + if (config.contains("disable")) + disabled = config.getBoolean("disable", false); + + if (disabled) { + plugin.getLogger().warning("You have opted-out of auto updating, so you won't know for certain if you're running the latest version."); + return; + } + + UpdateChecker.init(plugin, pluginId).requestUpdateCheck().whenComplete((result, exception) -> { + plugin.getLogger().info(ChatColor.GOLD + "Running " + plugin.getDescription().getName() + " v" + plugin.getDescription().getVersion() + "."); + switch (result.getReason()) { + case UP_TO_DATE: + plugin.getLogger().info(ChatColor.GREEN + "You are currently running the latest version."); + break; + case UNRELEASED_VERSION: + plugin.getLogger().info(ChatColor.DARK_GREEN + "You are currently running a version slightly ahead from release (development build?)"); + break; + case INVALID_JSON: + case UNKNOWN_ERROR: + case COULD_NOT_CONNECT: + case UNAUTHORIZED_QUERY: + plugin.getLogger().warning("An error occurred when trying to update the plugin. If this persists, please contact the BattlePlugins team!"); + break; + case UNSUPPORTED_VERSION_SCHEME: + plugin.getLogger().warning("An error occurred with the plugin version scheme, please contact the BatlePlugins team!"); + case NEW_UPDATE: + plugin.getLogger().info(ChatColor.AQUA + "A new update was found: " + plugin.getDescription().getName() + " " + result.getNewestVersion()); + + File folder = new File(plugin.getDataFolder().getParent(), updateFolder); + downloadFile(folder, plugin.getDescription().getName() + ".jar", result.getNewestVersion(), String.format(downloadLink, result.getNewestVersion())); + } + }); + } + + /** + * Downloads a file from the specified URL into the server's update folder. + * + * @param folder the updates folder location. + * @param file the name of the file to save it as. + * @param link the url of the file. + * @param version the version of the plugin. + */ + private void downloadFile(File folder, String file, String version, String link) { + if (!folder.exists()) { + folder.mkdir(); + } + BufferedInputStream in = null; + FileOutputStream fout = null; + try { + // Download the file + final URL url = new URL(link); + final int fileLength = url.openConnection().getContentLength(); + in = new BufferedInputStream(url.openStream()); + fout = new FileOutputStream(folder.getAbsolutePath() + File.separator + file); + + final byte[] data = new byte[1024]; + int count; + this.plugin.getLogger().info("About to download a new update: " + version); + long downloaded = 0; + while ((count = in.read(data, 0, 1024)) != -1) { + downloaded += count; + fout.write(data, 0, count); + } + //Just a quick check to make sure we didn't leave any files from last time... + for (final File xFile : new File(this.plugin.getDataFolder().getParent(), this.updateFolder).listFiles()) { + if (xFile.getName().endsWith(".zip")) { + xFile.delete(); + } + } + // Check to see if it's a zip file, if it is, unzip it. + final File dFile = new File(folder.getAbsolutePath() + File.separator + file); + if (dFile.getName().endsWith(".zip")) { + // Unzip + this.unzip(dFile.getCanonicalPath()); + } + this.plugin.getLogger().info("Finished updating."); + } catch (final Exception ex) { + this.plugin.getLogger().warning("The auto-updater tried to download a new update, but was unsuccessful."); + } finally { + try { + if (in != null) { + in.close(); + } + if (fout != null) { + fout.close(); + } + } catch (final Exception ex) { + } + } + } + + /** + * Part of Zip-File-Extractor, modified by Gravity for use with Updater. + * + * @param file the location of the file to extract. + */ + private void unzip(String file) { + try { + final File fSourceZip = new File(file); + final String zipPath = file.substring(0, file.length() - 4); + ZipFile zipFile = new ZipFile(fSourceZip); + Enumeration e = zipFile.entries(); + while (e.hasMoreElements()) { + ZipEntry entry = e.nextElement(); + File destinationFilePath = new File(zipPath, entry.getName()); + destinationFilePath.getParentFile().mkdirs(); + if (entry.isDirectory()) { + continue; + } else { + final BufferedInputStream bis = new BufferedInputStream(zipFile.getInputStream(entry)); + int b; + final byte buffer[] = new byte[1024]; + final FileOutputStream fos = new FileOutputStream(destinationFilePath); + final BufferedOutputStream bos = new BufferedOutputStream(fos, 1024); + while ((b = bis.read(buffer, 0, 1024)) != -1) { + bos.write(buffer, 0, b); + } + bos.flush(); + bos.close(); + bis.close(); + final String name = destinationFilePath.getName(); + if (name.endsWith(".jar") && this.pluginFile(name)) { + destinationFilePath.renameTo(new File(this.plugin.getDataFolder().getParent(), this.updateFolder + File.separator + name)); + } + } + entry = null; + destinationFilePath = null; + } + e = null; + zipFile.close(); + zipFile = null; + + // Move any plugin data folders that were included to the right place, Bukkit won't do this for us. + for (final File dFile : new File(zipPath).listFiles()) { + if (dFile.isDirectory()) { + if (this.pluginFile(dFile.getName())) { + final File oFile = new File(this.plugin.getDataFolder().getParent(), dFile.getName()); // Get current dir + final File[] contents = oFile.listFiles(); // List of existing files in the current dir + for (final File cFile : dFile.listFiles()) // Loop through all the files in the new dir + { + boolean found = false; + for (final File xFile : contents) // Loop through contents to see if it exists + { + if (xFile.getName().equals(cFile.getName())) { + found = true; + break; + } + } + if (!found) { + // Move the new file into the current dir + cFile.renameTo(new File(oFile.getCanonicalFile() + File.separator + cFile.getName())); + } else { + // This file already exists, so we don't need it anymore. + cFile.delete(); + } + } + } + } + dFile.delete(); + } + new File(zipPath).delete(); + fSourceZip.delete(); + } catch (final IOException e) { + this.plugin.getLogger().log(Level.SEVERE, "The auto-updater tried to unzip a new update file, but was unsuccessful.", e); + } + new File(file).delete(); + } + + /** + * Check if the name of a jar is one of the plugins currently installed, + * used for extracting the correct files out of a zip. + * + * @param name a name to check for inside the plugins folder. + * @return true if a file inside the plugins folder is named this. + */ + private boolean pluginFile(String name) { + for (final File file : new File("plugins").listFiles()) { + if (file.getName().equals(name)) { + return true; + } + } + return false; + } +} \ No newline at end of file diff --git a/src/main/java/mc/alk/battlepluginupdater/Updater.java b/src/main/java/mc/alk/battlepluginupdater/Updater.java index 1bdcd11..80fc48e 100644 --- a/src/main/java/mc/alk/battlepluginupdater/Updater.java +++ b/src/main/java/mc/alk/battlepluginupdater/Updater.java @@ -1,5 +1,6 @@ package mc.alk.battlepluginupdater; +import mc.alk.battlepluginupdater.checker.UpdateChecker; import org.bukkit.configuration.file.YamlConfiguration; import org.bukkit.plugin.Plugin; import org.json.simple.JSONArray; @@ -44,6 +45,9 @@ * * @author Gravity * @version 2.1 + * + * @deprecated + * Instead, use {@link mc.alk.battlepluginupdater.checker.UpdateChecker} */ public class Updater { diff --git a/src/main/java/mc/alk/battlepluginupdater/checker/UpdateChecker.java b/src/main/java/mc/alk/battlepluginupdater/checker/UpdateChecker.java new file mode 100644 index 0000000..45bc859 --- /dev/null +++ b/src/main/java/mc/alk/battlepluginupdater/checker/UpdateChecker.java @@ -0,0 +1,314 @@ +package mc.alk.battlepluginupdater.checker; + +import java.io.IOException; +import java.io.InputStreamReader; +import java.net.HttpURLConnection; +import java.net.URL; +import java.util.concurrent.CompletableFuture; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +import com.google.common.base.Preconditions; +import com.google.gson.JsonElement; +import com.google.gson.JsonObject; +import com.google.gson.JsonParser; +import com.google.gson.JsonSyntaxException; + +import org.apache.commons.lang.math.NumberUtils; +import org.bukkit.plugin.Plugin; +import org.bukkit.plugin.java.JavaPlugin; + +/** + * A utility class to assist in checking for updates for plugins uploaded to + * SpigotMC. Before any members of this + * class are accessed, {@link #init(Plugin, int)} must be invoked by the plugin, + * preferrably in its {@link JavaPlugin#onEnable()} method, though that is not a + * requirement. + *

+ * This class performs asynchronous queries to SpiGet, + * an REST server which is updated periodically. If the results of {@link #requestUpdateCheck()} + * are inconsistent with what is published on SpigotMC, it may be due to SpiGet's cache. + * Results will be updated in due time. + * + * @author Parker Hawke - 2008Choco + */ +public final class UpdateChecker { + + public static final VersionScheme VERSION_SCHEME_DECIMAL = (first, second) -> { + String[] firstSplit = splitVersionInfo(first), secondSplit = splitVersionInfo(second); + if (firstSplit == null || secondSplit == null) return null; + + for (int i = 0; i < Math.min(firstSplit.length, secondSplit.length); i++) { + int currentValue = NumberUtils.toInt(firstSplit[i]), newestValue = NumberUtils.toInt(secondSplit[i]); + + if (newestValue > currentValue) { + return second; + } else if (newestValue < currentValue) { + return first; + } + } + + return (secondSplit.length > firstSplit.length) ? second : first; + }; + + private static final String USER_AGENT = "CHOCO-update-checker"; + private static final String UPDATE_URL = "https://api.spiget.org/v2/resources/%d/versions?size=1&sort=-releaseDate"; + private static final Pattern DECIMAL_SCHEME_PATTERN = Pattern.compile("\\d+(?:\\.\\d+)*"); + + private static UpdateChecker instance; + + private UpdateResult lastResult = null; + + private final Plugin plugin; + private final int pluginID; + private final VersionScheme versionScheme; + + private UpdateChecker(Plugin plugin, int pluginID, VersionScheme versionScheme) { + this.plugin = plugin; + this.pluginID = pluginID; + this.versionScheme = versionScheme; + } + + /** + * Request an update check to SpiGet. This request is asynchronous and may not complete + * immediately as an HTTP GET request is published to the SpiGet API. + * + * @return a future update result + */ + public CompletableFuture requestUpdateCheck() { + return CompletableFuture.supplyAsync(() -> { + int responseCode = -1; + try { + URL url = new URL(String.format(UPDATE_URL, pluginID)); + HttpURLConnection connection = (HttpURLConnection) url.openConnection(); + connection.addRequestProperty("User-Agent", USER_AGENT); + + InputStreamReader reader = new InputStreamReader(connection.getInputStream()); + responseCode = connection.getResponseCode(); + + JsonElement element = new JsonParser().parse(reader); + if (!element.isJsonArray()) { + return new UpdateResult(UpdateReason.INVALID_JSON); + } + + reader.close(); + + JsonObject versionObject = element.getAsJsonArray().get(0).getAsJsonObject(); + String current = plugin.getDescription().getVersion(), newest = versionObject.get("name").getAsString(); + String latest = versionScheme.compareVersions(current, newest); + + if (latest == null) { + return new UpdateResult(UpdateReason.UNSUPPORTED_VERSION_SCHEME); + } else if (latest.equals(current)) { + return new UpdateResult(current.equals(newest) ? UpdateReason.UP_TO_DATE : UpdateReason.UNRELEASED_VERSION); + } else if (latest.equals(newest)) { + return new UpdateResult(UpdateReason.NEW_UPDATE, latest); + } + } catch (IOException e) { + return new UpdateResult(UpdateReason.COULD_NOT_CONNECT); + } catch (JsonSyntaxException e) { + return new UpdateResult(UpdateReason.INVALID_JSON); + } + + return new UpdateResult(responseCode == 401 ? UpdateReason.UNAUTHORIZED_QUERY : UpdateReason.UNKNOWN_ERROR); + }); + } + + /** + * Get the last update result that was queried by {@link #requestUpdateCheck()}. If no update + * check was performed since this class' initialization, this method will return null. + * + * @return the last update check result. null if none. + */ + public UpdateResult getLastResult() { + return lastResult; + } + + private static String[] splitVersionInfo(String version) { + Matcher matcher = DECIMAL_SCHEME_PATTERN.matcher(version); + if (!matcher.find()) return null; + + return matcher.group().split("\\."); + } + + /** + * Initialize this update checker with the specified values and return its instance. If an instance + * of UpdateChecker has already been initialized, this method will act similarly to {@link #get()} + * (which is recommended after initialization). + * + * @param plugin the plugin for which to check updates. Cannot be null + * @param pluginID the ID of the plugin as identified in the SpigotMC resource link. For example, + * "https://www.spigotmc.org/resources/veinminer.12038/" would expect "12038" as a value. The + * value must be greater than 0 + * @param versionScheme a custom version scheme parser. Cannot be null + * + * @return the UpdateChecker instance + */ + public static UpdateChecker init(Plugin plugin, int pluginID, VersionScheme versionScheme) { + Preconditions.checkArgument(plugin != null, "Plugin cannot be null"); + Preconditions.checkArgument(pluginID > 0, "Plugin ID must be greater than 0"); + Preconditions.checkArgument(versionScheme != null, "null version schemes are unsupported"); + + return (instance == null) ? instance = new UpdateChecker(plugin, pluginID, versionScheme) : instance; + } + + /** + * Initialize this update checker with the specified values and return its instance. If an instance + * of UpdateChecker has already been initialized, this method will act similarly to {@link #get()} + * (which is recommended after initialization). + * + * @param plugin the plugin for which to check updates. Cannot be null + * @param pluginID the ID of the plugin as identified in the SpigotMC resource link. For example, + * "https://www.spigotmc.org/resources/veinminer.12038/" would expect "12038" as a value. The + * value must be greater than 0 + * + * @return the UpdateChecker instance + */ + public static UpdateChecker init(Plugin plugin, int pluginID) { + return init(plugin, pluginID, VERSION_SCHEME_DECIMAL); + } + + /** + * Get the initialized instance of UpdateChecker. If {@link #init(Plugin, int)} has not yet been + * invoked, this method will throw an exception. + * + * @return the UpdateChecker instance + */ + public static UpdateChecker get() { + Preconditions.checkState(instance != null, "Instance has not yet been initialized. Be sure #init() has been invoked"); + return instance; + } + + /** + * Check whether the UpdateChecker has been initialized or not (if {@link #init(Plugin, int)} + * has been invoked) and {@link #get()} is safe to use. + * + * @return true if initialized, false otherwise + */ + public static boolean isInitialized() { + return instance != null; + } + + + /** + * A functional interface to compare two version Strings with similar version schemes. + */ + @FunctionalInterface + public static interface VersionScheme { + + /** + * Compare two versions and return the higher of the two. If null is returned, it is assumed + * that at least one of the two versions are unsupported by this version scheme parser. + * + * @param first the first version to check + * @param second the second version to check + * + * @return the greater of the two versions. null if unsupported version schemes + */ + public String compareVersions(String first, String second); + + } + + /** + * A constant reason for the result of {@link UpdateResult}. + */ + public static enum UpdateReason { + + /** + * A new update is available for download on SpigotMC. + */ + NEW_UPDATE, // The only reason that requires an update + + /** + * A successful connection to the SpiGet API could not be established. + */ + COULD_NOT_CONNECT, + + /** + * The JSON retrieved from SpiGet was invalid or malformed. + */ + INVALID_JSON, + + /** + * A 401 error was returned by the SpiGet API. + */ + UNAUTHORIZED_QUERY, + + /** + * The version of the plugin installed on the server is greater than the one uploaded + * to SpigotMC's resources section. + */ + UNRELEASED_VERSION, + + /** + * An unknown error occurred. + */ + UNKNOWN_ERROR, + + /** + * The plugin uses an unsupported version scheme, therefore a proper comparison between + * versions could not be made. + */ + UNSUPPORTED_VERSION_SCHEME, + + /** + * The plugin is up to date with the version released on SpigotMC's resources section. + */ + UP_TO_DATE; + + } + + /** + * Represents a result for an update query performed by {@link UpdateChecker#requestUpdateCheck()}. + */ + public final class UpdateResult { + + private final UpdateReason reason; + private final String newestVersion; + + { // An actual use for initializer blocks. This is madness! + UpdateChecker.this.lastResult = this; + } + + private UpdateResult(UpdateReason reason, String newestVersion) { + this.reason = reason; + this.newestVersion = newestVersion; + } + + private UpdateResult(UpdateReason reason) { + Preconditions.checkArgument(reason != UpdateReason.NEW_UPDATE, "Reasons that require updates must also provide the latest version String"); + this.reason = reason; + this.newestVersion = plugin.getDescription().getVersion(); + } + + /** + * Get the constant reason of this result. + * + * @return the reason + */ + public UpdateReason getReason() { + return reason; + } + + /** + * Check whether or not this result requires the user to update. + * + * @return true if requires update, false otherwise + */ + public boolean requiresUpdate() { + return reason == UpdateReason.NEW_UPDATE; + } + + /** + * Get the latest version of the plugin. This may be the currently installed version, it + * may not be. This depends entirely on the result of the update. + * + * @return the newest version of the plugin + */ + public String getNewestVersion() { + return newestVersion; + } + + } + +} \ No newline at end of file