diff --git a/HubThat/Updater.java b/HubThat/Updater.java new file mode 100644 index 0000000..04c5b94 --- /dev/null +++ b/HubThat/Updater.java @@ -0,0 +1,563 @@ +package HubThat; + +import java.io.*; +import java.net.MalformedURLException; +import java.net.URL; +import java.net.URLConnection; +import java.util.Enumeration; +import java.util.logging.Level; +import java.util.zip.ZipEntry; +import java.util.zip.ZipFile; + +import org.bukkit.configuration.file.YamlConfiguration; +import org.bukkit.plugin.Plugin; +import org.bukkit.scheduler.BukkitRunnable; +import org.json.simple.JSONArray; +import org.json.simple.JSONObject; +import org.json.simple.JSONValue; + + + +public class Updater { + + /* Constants */ + + // Remote file's title + private static final String TITLE_VALUE = "name"; + // Remote file's download link + private static final String LINK_VALUE = "downloadUrl"; + // Remote file's release type + private static final String TYPE_VALUE = "releaseType"; + // Remote file's build version + private static final String VERSION_VALUE = "gameVersion"; + // Path to GET + private static final String QUERY = "/servermods/files?projectIds="; + // Slugs will be appended to this to get to the project's RSS feed + private static final String HOST = "https://api.curseforge.com"; + // User-agent when querying Curse + private static final String USER_AGENT = "Updater (by Gravity)"; + // Used for locating version numbers in file names + private static final String DELIMETER = "^v|[\\s_-]"; + // If the version number contains one of these, don't update. + private static final String[] NO_UPDATE_TAG = { "-DEV", "-PRE", "-SNAPSHOT" }; + // Used for downloading files + private static final int BYTE_SIZE = 1024; + // Config key for api key + private static final String API_KEY_CONFIG_KEY = "api-key"; + // Config key for disabling Updater + private static final String DISABLE_CONFIG_KEY = "disable"; + // Default api key value in config + private static final String API_KEY_DEFAULT = "API_KEY"; + // Default disable value in config + private static final boolean DISABLE_DEFAULT = true; + + /* User-provided variables */ + + // Plugin running Updater + private final Plugin plugin; + // Type of update check to run + private final UpdateType type; + // Whether to announce file downloads + private final boolean announce; + // The plugin file (jar) + private final File file; + // The folder that downloads will be placed in + private final File updateFolder; + // The provided callback (if any) + private final UpdateCallback callback; + // Project's Curse ID + private int id = -1; + // BukkitDev ServerMods API key + private String apiKey = null; + + /* Collected from Curse API */ + + private String versionName; + private String versionLink; + private String versionType; + private String versionGameVersion; + + /* Update process variables */ + + // Connection to RSS + private URL url; + // Updater thread + private Thread thread; + // Used for determining the outcome of the update process + private Updater.UpdateResult result = Updater.UpdateResult.SUCCESS; + + /** + * Gives the developer the result of the update process. Can be obtained by called {@link #getResult()} + */ + public enum UpdateResult { + /** + * The updater found an update, and has readied it to be loaded the next time the server restarts/reloads. + */ + SUCCESS, + /** + * The updater did not find an update, and nothing was downloaded. + */ + NO_UPDATE, + /** + * The server administrator has disabled the updating system. + */ + DISABLED, + /** + * The updater found an update, but was unable to download it. + */ + FAIL_DOWNLOAD, + /** + * For some reason, the updater was unable to contact dev.bukkit.org to download the file. + */ + FAIL_DBO, + /** + * When running the version check, the file on DBO did not contain a recognizable version. + */ + FAIL_NOVERSION, + /** + * The id provided by the plugin running the updater was invalid and doesn't exist on DBO. + */ + FAIL_BADID, + /** + * The server administrator has improperly configured their API key in the configuration. + */ + FAIL_APIKEY, + /** + * The updater found an update, but because of the UpdateType being set to NO_DOWNLOAD, it wasn't downloaded. + */ + UPDATE_AVAILABLE + } + + /** + * Allows the developer to specify the type of update that will be run. + */ + public enum UpdateType { + /** + * Run a version check, and then if the file is out of date, download the newest version. + */ + DEFAULT, + /** + * Don't run a version check, just find the latest update and download it. + */ + NO_VERSION_CHECK, + /** + * Get information about the version and the download size, but don't actually download anything. + */ + NO_DOWNLOAD + } + + /** + * Represents the various release types of a file on BukkitDev. + */ + public enum ReleaseType { + /** + * An "alpha" file. + */ + ALPHA, + /** + * A "beta" file. + */ + BETA, + /** + * A "release" file. + */ + RELEASE + } + + /** + * Initialize the updater. + * + * @param plugin The plugin that is checking for an update. + * @param id The dev.bukkit.org id of the project. + * @param file The file that the plugin is running from, get this by doing this.getFile() from within your main class. + * @param type Specify the type of update this will be. See {@link UpdateType} + * @param announce True if the program should announce the progress of new updates in console. + */ + public Updater(Plugin plugin, int id, File file, UpdateType type, boolean announce) { + this(plugin, id, file, type, null, announce); + } + public Updater(Plugin plugin, int id, File file, UpdateType type, UpdateCallback callback) { + this(plugin, id, file, type, callback, false); + } + public Updater(Plugin plugin, int id, File file, UpdateType type, UpdateCallback callback, boolean announce) { + this.plugin = plugin; + this.type = type; + this.announce = announce; + this.file = file; + this.id = id; + this.updateFolder = this.plugin.getServer().getUpdateFolderFile(); + this.callback = callback; + + + + YamlConfiguration config = new YamlConfiguration(); + if (!config.getBoolean(API_KEY_CONFIG_KEY, plugin.getConfig().getBoolean("updates.auto-update"))) { + this.result = UpdateResult.DISABLED; + return; + } + try { + this.url = new URL(Updater.HOST + Updater.QUERY + this.id); + } catch (final MalformedURLException e) { + this.plugin.getLogger().log(Level.SEVERE, "The project ID provided for updating, " + this.id + " is invalid.", e); + this.result = UpdateResult.FAIL_BADID; + } + + if (this.result != UpdateResult.FAIL_BADID) { + this.thread = new Thread(new UpdateRunnable()); + this.thread.start(); + } else { + runUpdater(); + } + } + + + public Updater.UpdateResult getResult() { + this.waitForThread(); + return this.result; + } + public ReleaseType getLatestType() { + this.waitForThread(); + if (this.versionType != null) { + for (ReleaseType type : ReleaseType.values()) { + if (this.versionType.equalsIgnoreCase(type.name())) { + return type; + } + } + } + return null; + } + + public String getLatestGameVersion() { + this.waitForThread(); + return this.versionGameVersion; + } + + public String getLatestName() { + this.waitForThread(); + return this.versionName; + } + public String getLatestFileLink() { + this.waitForThread(); + return this.versionLink; + } + + private void waitForThread() { + if ((this.thread != null) && this.thread.isAlive()) { + try { + this.thread.join(); + } catch (final InterruptedException e) { + this.plugin.getLogger().log(Level.SEVERE, null, e); + } + } + } + + private void saveFile(String file) { + final File folder = this.updateFolder; + + deleteOldFiles(); + if (!folder.exists()) { + this.fileIOOrError(folder, folder.mkdir(), true); + } + downloadFile(); + + // Check to see if it's a zip file, if it is, unzip it. + final File dFile = new File(folder.getAbsolutePath(), file); + if (dFile.getName().endsWith(".zip")) { + // Unzip + this.unzip(dFile.getAbsolutePath()); + } + if (this.announce) { + this.plugin.getLogger().info("Finished updating."); + } + } + + /** + * Download a file and save it to the specified folder. + */ + private void downloadFile() { + BufferedInputStream in = null; + FileOutputStream fout = null; + try { + URL fileUrl = new URL(this.versionLink); + final int fileLength = fileUrl.openConnection().getContentLength(); + in = new BufferedInputStream(fileUrl.openStream()); + fout = new FileOutputStream(new File(this.updateFolder, file.getName())); + + final byte[] data = new byte[Updater.BYTE_SIZE]; + int count; + if (this.announce) { + this.plugin.getLogger().info("About to download a new update: " + this.versionName); + } + long downloaded = 0; + while ((count = in.read(data, 0, Updater.BYTE_SIZE)) != -1) { + downloaded += count; + fout.write(data, 0, count); + final int percent = (int) ((downloaded * 100) / fileLength); + if (this.announce && ((percent % 10) == 0)) { + this.plugin.getLogger().info("Downloading update: " + percent + "% of " + fileLength + " bytes."); + } + } + } catch (Exception ex) { + this.plugin.getLogger().log(Level.WARNING, "The auto-updater tried to download a new update, but was unsuccessful.", ex); + this.result = Updater.UpdateResult.FAIL_DOWNLOAD; + } finally { + try { + if (in != null) { + in.close(); + } + } catch (final IOException ex) { + this.plugin.getLogger().log(Level.SEVERE, null, ex); + } + try { + if (fout != null) { + fout.close(); + } + } catch (final IOException ex) { + this.plugin.getLogger().log(Level.SEVERE, null, ex); + } + } + } + + /** + * Remove possibly leftover files from the update folder. + */ + private void deleteOldFiles() { + //Just a quick check to make sure we didn't leave any files from last time... + File[] list = listFilesOrError(this.updateFolder); + for (final File xFile : list) { + if (xFile.getName().endsWith(".zip")) { + this.fileIOOrError(xFile, xFile.mkdir(), true); + } + } + } + + private void unzip(String file) { + final File fSourceZip = new File(file); + try { + 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()); + this.fileIOOrError(destinationFilePath.getParentFile(), destinationFilePath.getParentFile().mkdirs(), true); + if (!entry.isDirectory()) { + final BufferedInputStream bis = new BufferedInputStream(zipFile.getInputStream(entry)); + int b; + final byte[] buffer = new byte[Updater.BYTE_SIZE]; + final FileOutputStream fos = new FileOutputStream(destinationFilePath); + final BufferedOutputStream bos = new BufferedOutputStream(fos, Updater.BYTE_SIZE); + while ((b = bis.read(buffer, 0, Updater.BYTE_SIZE)) != -1) { + bos.write(buffer, 0, b); + } + bos.flush(); + bos.close(); + bis.close(); + final String name = destinationFilePath.getName(); + if (name.endsWith(".jar") && this.pluginExists(name)) { + File output = new File(this.updateFolder, name); + this.fileIOOrError(output, destinationFilePath.renameTo(output), true); + } + } + } + zipFile.close(); + + // Move any plugin data folders that were included to the right place, Bukkit won't do this for us. + moveNewZipFiles(zipPath); + + } catch (final IOException e) { + this.plugin.getLogger().log(Level.SEVERE, "The auto-updater tried to unzip a new update file, but was unsuccessful.", e); + this.result = Updater.UpdateResult.FAIL_DOWNLOAD; + } finally { + this.fileIOOrError(fSourceZip, fSourceZip.delete(), false); + } + } + + /** + * Find any new files extracted from an update into the plugin's data directory. + * @param zipPath path of extracted files. + */ + private void moveNewZipFiles(String zipPath) { + File[] list = listFilesOrError(new File(zipPath)); + for (final File dFile : list) { + if (dFile.isDirectory() && this.pluginExists(dFile.getName())) { + // Current dir + final File oFile = new File(this.plugin.getDataFolder().getParent(), dFile.getName()); + // List of existing files in the new dir + final File[] dList = listFilesOrError(dFile); + // List of existing files in the current dir + final File[] oList = listFilesOrError(oFile); + for (File cFile : dList) { + // Loop through all the files in the new dir + boolean found = false; + for (final File xFile : oList) { + // Loop through all the contents in the current dir to see if it exists + if (xFile.getName().equals(cFile.getName())) { + found = true; + break; + } + } + if (!found) { + // Move the new file into the current dir + File output = new File(oFile, cFile.getName()); + this.fileIOOrError(output, cFile.renameTo(output), true); + } else { + // This file already exists, so we don't need it anymore. + this.fileIOOrError(cFile, cFile.delete(), false); + } + } + } + this.fileIOOrError(dFile, dFile.delete(), false); + } + File zip = new File(zipPath); + this.fileIOOrError(zip, zip.delete(), false); + } + private boolean pluginExists(String name) { + File[] plugins = listFilesOrError(new File("plugins")); + for (final File file : plugins) { + if (file.getName().equals(name)) { + return true; + } + } + return false; + } + + /** + * Check to see if the program should continue by evaluating whether the plugin is already updated, or shouldn't be updated. + * + * @return true if the version was located and is not the same as the remote's newest. + */ + private boolean versionCheck() { + final String title = this.versionName; + if (this.type != UpdateType.NO_VERSION_CHECK) { + final String localVersion = this.plugin.getDescription().getVersion(); + if (title.split(DELIMETER).length == 2) { + // Get the newest file's version number + final String remoteVersion = title.split(DELIMETER)[1].split(" ")[0]; + + if (this.hasTag(localVersion) || !this.shouldUpdate(localVersion, remoteVersion)) { + // We already have the latest version, or this build is tagged for no-update + this.result = Updater.UpdateResult.NO_UPDATE; + return false; + } + } else { + // The file's name did not contain the string 'vVersion' + final String authorInfo = this.plugin.getDescription().getAuthors().isEmpty() ? "" : " (" + this.plugin.getDescription().getAuthors().get(0) + ")"; + this.plugin.getLogger().warning("There's an Update Error."); + this.result = Updater.UpdateResult.FAIL_NOVERSION; + return false; + } + } + return true; + } + public boolean shouldUpdate(String localVersion, String remoteVersion) { + return !localVersion.equalsIgnoreCase(remoteVersion); + } + private boolean hasTag(String version) { + for (final String string : Updater.NO_UPDATE_TAG) { + if (version.contains(string)) { + return true; + } + } + return false; + } + + private boolean read() { + try { + final URLConnection conn = this.url.openConnection(); + conn.setConnectTimeout(5000); + + if (this.apiKey != null) { + conn.addRequestProperty("X-API-Key", this.apiKey); + } + conn.addRequestProperty("User-Agent", Updater.USER_AGENT); + + conn.setDoOutput(true); + + final BufferedReader reader = new BufferedReader(new InputStreamReader(conn.getInputStream())); + final String response = reader.readLine(); + + final JSONArray array = (JSONArray) JSONValue.parse(response); + + if (array.isEmpty()) { + this.plugin.getLogger().warning("The updater could not find any files for the project id " + this.id); + this.result = UpdateResult.FAIL_BADID; + return false; + } + + JSONObject latestUpdate = (JSONObject) array.get(array.size() - 1); + this.versionName = (String) latestUpdate.get(Updater.TITLE_VALUE); + this.versionLink = (String) latestUpdate.get(Updater.LINK_VALUE); + this.versionType = (String) latestUpdate.get(Updater.TYPE_VALUE); + this.versionGameVersion = (String) latestUpdate.get(Updater.VERSION_VALUE); + + return true; + } catch (final IOException e) { + if (e.getMessage().contains("HTTP response code: 403")) { + this.plugin.getLogger().severe("dev.bukkit.org rejected the API key provided in plugins/Updater/config.yml"); + this.plugin.getLogger().severe("Please double-check your configuration to ensure it is correct."); + this.result = UpdateResult.FAIL_APIKEY; + } else { + this.plugin.getLogger().severe("The updater could not contact dev.bukkit.org for updating."); + this.plugin.getLogger().severe("If you have not recently modified your configuration and this is the first time you are seeing this message, the site may be experiencing temporary downtime."); + this.result = UpdateResult.FAIL_DBO; + } + this.plugin.getLogger().log(Level.SEVERE, null, e); + return false; + } + } + + private void fileIOOrError(File file, boolean result, boolean create) { + if (!result) { + this.plugin.getLogger().severe("The updater could not " + (create ? "create" : "delete") + " file at: " + file.getAbsolutePath()); + } + } + + private File[] listFilesOrError(File folder) { + File[] contents = folder.listFiles(); + if (contents == null) { + this.plugin.getLogger().severe("The updater could not access files at: " + this.updateFolder.getAbsolutePath()); + return new File[0]; + } else { + return contents; + } + } + + public interface UpdateCallback { + void onFinish(Updater updater); + } + + private class UpdateRunnable implements Runnable { + public void run() { + runUpdater(); + } + } + + private void runUpdater() { + if (this.url != null && (this.read() && this.versionCheck())) { + // Obtain the results of the project's file feed + if ((this.versionLink != null) && (this.type != UpdateType.NO_DOWNLOAD)) { + String name = this.file.getName(); + // If it's a zip file, it shouldn't be downloaded as the plugin's name + if (this.versionLink.endsWith(".zip")) { + name = this.versionLink.substring(this.versionLink.lastIndexOf("/") + 1); + } + this.saveFile(name); + } else { + this.result = UpdateResult.UPDATE_AVAILABLE; + } + } + + if (this.callback != null) { + new BukkitRunnable() { + public void run() { + runCallback(); + } + }.runTask(this.plugin); + } + } + + private void runCallback() { + this.callback.onFinish(this); + } +}