From b386cacb5eb1ba9405078c422d8a847ee6264bfc Mon Sep 17 00:00:00 2001 From: Phoenix616 Date: Mon, 4 Mar 2024 19:28:02 +0100 Subject: [PATCH] Improve the Updater (Fixes #541, #586) - Use Modrinth for version checks and downloads - Fix a possible deadlock in the version check - Actually compare the version numbers in the check - Add verification of the sha1 hash sum of the downloaded file --- .../java/com/Acrobot/ChestShop/ChestShop.java | 7 +- .../Acrobot/ChestShop/Updater/Updater.java | 155 +++++++++++++----- 2 files changed, 121 insertions(+), 41 deletions(-) diff --git a/src/main/java/com/Acrobot/ChestShop/ChestShop.java b/src/main/java/com/Acrobot/ChestShop/ChestShop.java index f598435..8d999f6 100644 --- a/src/main/java/com/Acrobot/ChestShop/ChestShop.java +++ b/src/main/java/com/Acrobot/ChestShop/ChestShop.java @@ -89,6 +89,7 @@ import java.io.IOException; import java.util.ArrayList; import java.util.LinkedHashMap; import java.util.List; +import java.util.Locale; import java.util.Map; import java.util.UUID; import java.util.concurrent.ExecutorService; @@ -545,17 +546,17 @@ public class ChestShop extends JavaPlugin { if (Properties.TURN_OFF_UPDATES) { getLogger().info("Auto-updater is disabled. If you want the plugin to automatically download new releases then set 'TURN_OFF_UPDATES' to 'false' in your config.yml!"); if (!Properties.TURN_OFF_UPDATE_NOTIFIER) { - final Updater updater = new Updater(this, PROJECT_BUKKITDEV_ID, this.getFile(), Updater.UpdateType.NO_DOWNLOAD, true); + final Updater updater = new Updater(this, getPluginName().toLowerCase(Locale.ROOT), this.getFile(), Updater.UpdateType.NO_DOWNLOAD, true); getServer().getScheduler().runTaskAsynchronously(this, () -> { if (updater.getResult() == Updater.UpdateResult.UPDATE_AVAILABLE) { - getLogger().info("There is a new version available: " + updater.getLatestName() + ". You can download it from https://dev.bukkit.org/projects/" + PROJECT_BUKKITDEV_ID); + getLogger().info("There is a new version available: " + updater.getLatestName() + ". You can download it from https://modrinth.com/plugin/" + getPluginName().toLowerCase(Locale.ROOT)); } }); } return; } - new Updater(this, PROJECT_BUKKITDEV_ID, this.getFile(), Updater.UpdateType.DEFAULT, true); + new Updater(this, getPluginName().toLowerCase(Locale.ROOT), this.getFile(), Updater.UpdateType.DEFAULT, true); } private static final String PROJECT_JENKINS_JOB_URL = "https://ci.minebench.de/job/ChestShop-3/"; diff --git a/src/main/java/com/Acrobot/ChestShop/Updater/Updater.java b/src/main/java/com/Acrobot/ChestShop/Updater/Updater.java index 741b5dc..41d3ed4 100644 --- a/src/main/java/com/Acrobot/ChestShop/Updater/Updater.java +++ b/src/main/java/com/Acrobot/ChestShop/Updater/Updater.java @@ -6,6 +6,8 @@ package com.Acrobot.ChestShop.Updater; +import com.google.common.hash.Hashing; +import com.google.common.io.Files; import org.bukkit.configuration.file.YamlConfiguration; import org.bukkit.plugin.Plugin; import org.json.simple.JSONArray; @@ -19,6 +21,8 @@ import java.net.URLConnection; import java.util.Enumeration; import java.util.Locale; import java.util.logging.Level; +import java.util.regex.Matcher; +import java.util.regex.Pattern; import java.util.zip.ZipEntry; import java.util.zip.ZipFile; @@ -45,6 +49,7 @@ public final class Updater { private UpdateType type; private String versionName; private String versionLink; + private String versionHash; private String versionType; private String versionGameVersion; @@ -54,21 +59,21 @@ public final class Updater { private File file; // The plugin's file private Thread thread; // Updater thread - private int id = -1; // Project's Curse ID - private String apiKey = null; // BukkitDev ServerMods API key - private static final String TITLE_VALUE = "name"; // Gets remote file's title - private static final String LINK_VALUE = "downloadUrl"; // Gets remote file's download link - private static final String TYPE_VALUE = "releaseType"; // Gets remote file's release type - private static final String VERSION_VALUE = "gameVersion"; // Gets remote file's build version - private static final String QUERY = "/servermods/files?projectIds="; // Path to GET - private static final String HOST = "https://api.curseforge.com"; // Slugs will be appended to this to get to the project's RSS feed + private String id; // Project's Curse ID + private String apiKey = null; // Modrinth API key + private static final String TITLE_VALUE = "version_number"; // Gets remote version + private static final String FILES_VALUE = "files"; // Gets all files associated with that version + private static final String LINK_VALUE = "url"; // Gets remote file's download link + private static final String TYPE_VALUE = "version_type"; // Gets remote file's release type + private static final String VERSION_VALUE = "game_versions"; // Gets remote file's build version + private static final String QUERY = "/v2/project/%projectid%/version"; // Path to GET + private static final String HOST = "https://api.modrinth.com"; // Slugs will be appended to this to get to the project's versions - private static final String USER_AGENT = "Updater (by Gravity)"; - private static final String delimiter = "^v|[\\s_-]v"; // Used for locating version numbers in file names + private static final String USER_AGENT = "Updater v2.1 (by Gravity) - Modified by Phoenix616 for Modrinth"; + private static final Pattern VERSION_PATTERN = Pattern.compile("(\\d+\\.\\d+(?>\\.\\d+)?)"); // Used for locating version numbers in file names private static final String[] NO_UPDATE_TAG = { "-DEV", "-PRE", "-SNAPSHOT" }; // If the version number contains one of these, don't update. private static final int BYTE_SIZE = 1024; // Used for downloading files private final YamlConfiguration config = new YamlConfiguration(); // Config file - private String updateFolder;// The folder that downloads will be placed in private Updater.UpdateResult result = Updater.UpdateResult.SUCCESS; // Used for determining the outcome of the update process /** @@ -110,7 +115,11 @@ public final class Updater { /** * The updater found an update, but because of the UpdateType being set to NO_DOWNLOAD, it wasn't downloaded. */ - UPDATE_AVAILABLE + UPDATE_AVAILABLE, + /** + * The downloaded file does not match the SHA1 hash sum provided by the api. + */ + FAIL_HASH, } /** @@ -153,27 +162,26 @@ public final class Updater { * Initialize the updater. * * @param plugin The plugin that is checking for an update. - * @param id The dev.bukkit.org id of the project. + * @param id The 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) { + public Updater(Plugin plugin, String id, File file, UpdateType type, boolean announce) { this.plugin = plugin; this.type = type; this.announce = announce; this.file = file; this.id = id; - this.updateFolder = plugin.getServer().getUpdateFolder(); final File pluginFile = plugin.getDataFolder().getParentFile(); final File updaterFile = new File(pluginFile, "Updater"); final File updaterConfigFile = new File(updaterFile, "config.yml"); - this.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' + this.config.options().header("This configuration file affects all plugins using the Updater system (version 2+ )" + '\n' + + "If you wish to use your API key, then you can get it from https://modrinth.com/settings/pats 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."); - this.config.addDefault("api-key", "PUT_API_KEY_HERE"); + this.config.addDefault("modrinth-key", "PUT_PAT_HERE"); this.config.addDefault("disable", false); if (!updaterFile.exists()) { @@ -211,7 +219,7 @@ public final class Updater { this.apiKey = key; try { - this.url = new URL(Updater.HOST + Updater.QUERY + id); + this.url = new URL(Updater.HOST + Updater.QUERY.replace("%projectid%", id)); } catch (final MalformedURLException e) { plugin.getLogger().log(Level.SEVERE, "The project ID provided for updating, " + id + " is invalid.", e); this.result = UpdateResult.FAIL_BADID; @@ -240,6 +248,16 @@ public final class Updater { */ public ReleaseType getLatestType() { this.waitForThread(); + return getLatestTypeInternal(); + } + + /** + * Get the latest version's release type without waiting for the thread to finish. + * + * @return latest version's release type. + * @see ReleaseType + */ + private ReleaseType getLatestTypeInternal() { if (this.versionType != null) { for (ReleaseType type : ReleaseType.values()) { if (this.versionType.equals(type.name().toLowerCase(Locale.ROOT))) { @@ -309,8 +327,9 @@ public final class Updater { // Download the file final URL url = new URL(link); final int fileLength = url.openConnection().getContentLength(); + final File targetFile = new File(folder, file); try (BufferedInputStream in = new BufferedInputStream(url.openStream()); - FileOutputStream fout = new FileOutputStream(folder.getAbsolutePath() + File.separator + file)) { + FileOutputStream fout = new FileOutputStream(targetFile)) { final byte[] data = new byte[Updater.BYTE_SIZE]; int count; @@ -318,16 +337,27 @@ public final class Updater { this.plugin.getLogger().info("About to download a new update: " + this.versionName); } long downloaded = 0; + int lastAnnouncePercent = 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)) { + if (this.announce && lastAnnouncePercent != percent && ((percent % 10) == 0)) { + lastAnnouncePercent = percent; this.plugin.getLogger().info("Downloading update: " + percent + "% of " + fileLength + " bytes."); } } + // Check sha1 sum of the downloaded file + if (this.versionHash != null) { + final String fileHash = Files.asByteSource(targetFile).hash(Hashing.sha512()).toString(); + if (!this.versionHash.equalsIgnoreCase(fileHash)) { + this.plugin.getLogger().warning("Downloaded file " + file + " does not match the remote file's SHA-1 hash"); + this.result = UpdateResult.FAIL_HASH; + return; + } + } //Just a quick check to make sure we didn't leave any files from last time... - File[] files = new File(this.plugin.getDataFolder().getParent(), this.updateFolder).listFiles(); + File[] files = this.plugin.getServer().getUpdateFolderFile().listFiles(); if (files != null) { for (final File xFile : files) { if (xFile.getName().endsWith(".zip")) { @@ -336,7 +366,7 @@ public final class Updater { } } // Check to see if it's a zip file, if it is, unzip it. - final File dFile = new File(folder.getAbsolutePath() + File.separator + file); + final File dFile = new File(folder.getAbsolutePath(), file); if (dFile.getName().endsWith(".zip")) { // Unzip this.unzip(dFile.getCanonicalPath()); @@ -379,7 +409,7 @@ public final class Updater { 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)); + destinationFilePath.renameTo(new File(this.plugin.getServer().getUpdateFolderFile(),name)); } } } @@ -409,7 +439,7 @@ public final class Updater { } if (!found) { // Move the new file into the current dir - cFile.renameTo(new File(oFile.getCanonicalFile() + File.separator + cFile.getName())); + cFile.renameTo(new File(oFile.getCanonicalFile(), cFile.getName())); } else { // This file already exists, so we don't need it anymore. cFile.delete(); @@ -456,11 +486,14 @@ public final class Updater { */ private boolean versionCheck(String title) { if (this.type != UpdateType.NO_VERSION_CHECK) { - final String localVersion = this.plugin.getDescription().getVersion(); - if (title.split(delimiter).length == 2) { - final String remoteVersion = title.split(delimiter)[1].split(" ")[0]; // Get the newest file's version number + final String rawLocalVersion = this.plugin.getDescription().getVersion(); + final Matcher localMatcher = Updater.VERSION_PATTERN.matcher(rawLocalVersion); + final Matcher titleMatcher = Updater.VERSION_PATTERN.matcher(title); + if (titleMatcher.find() && localMatcher.find()) { + final String localVersion = localMatcher.group(1); // Get the plugins version number + final String remoteVersion = titleMatcher.group(1); // Get the newest file's version number - if (this.hasTag(localVersion) || !this.shouldUpdate(localVersion, remoteVersion)) { + if (this.hasTag(rawLocalVersion) || !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; @@ -502,11 +535,49 @@ public final class Updater { * @return true if Updater should consider the remote version an update, false if not. */ public boolean shouldUpdate(String localVersion, String remoteVersion) { - if (this.type != Updater.UpdateType.NO_DOWNLOAD && localVersion.contains("DEV") || getLatestType() != ReleaseType.RELEASE) { + if (this.type != Updater.UpdateType.NO_DOWNLOAD && localVersion.contains("DEV") || getLatestTypeInternal() != ReleaseType.RELEASE) { return false; //Do not download alphas or betas } - return !localVersion.equalsIgnoreCase(remoteVersion); + if (localVersion.equalsIgnoreCase(remoteVersion)) { + return true; //Already the same version + } + + try { + int[] localSemanticVersion = parseVersion(localVersion); + int[] remoteSemanticVersion = parseVersion(remoteVersion); + + for (int i = 0; i < localSemanticVersion.length; i++) { + if (remoteSemanticVersion.length < i + 1) { + return false; + } + if (localSemanticVersion[i] < remoteSemanticVersion[i]) { + return true; + } else if (localSemanticVersion[i] > remoteSemanticVersion[i]) { + return false; + } + } + return false; + } catch (NumberFormatException e) { + this.plugin.getLogger().warning("Invalid version number found: " + localVersion + " or " + remoteVersion); + return true; + } + } + + /** + * Parse the version number from a string. This expects the version number to be consisting of numbers separated by dots. + * + * @param version the version string to parse + * @return the parsed version number + * @throws NumberFormatException if the version number is not in the expected format + */ + private int[] parseVersion(String version) { + final String[] split = version.split("\\."); + final int[] semanticVersion = new int[split.length]; + for (int i = 0; i < split.length; i++) { + semanticVersion[i] = Integer.parseInt(split[i]); + } + return semanticVersion; } /** @@ -535,7 +606,7 @@ public final class Updater { conn.setConnectTimeout(5000); if (this.apiKey != null) { - conn.addRequestProperty("X-API-Key", this.apiKey); + conn.addRequestProperty("Authorization", this.apiKey); } conn.addRequestProperty("User-Agent", Updater.USER_AGENT); @@ -553,18 +624,26 @@ public final class Updater { } this.versionName = (String) ((JSONObject) array.get(array.size() - 1)).get(Updater.TITLE_VALUE); - this.versionLink = (String) ((JSONObject) array.get(array.size() - 1)).get(Updater.LINK_VALUE); + JSONArray versionFiles = (JSONArray) ((JSONObject) array.get(array.size() - 1)).get(Updater.FILES_VALUE); + for (Object versionFile : versionFiles) { + JSONObject file = (JSONObject) versionFile; + if (file.get("primary").equals(true)) { + versionLink = (String) file.get(Updater.LINK_VALUE); + versionHash = ((JSONObject) file.get("hashes")).get("sha512").toString(); + break; + } + } this.versionType = (String) ((JSONObject) array.get(array.size() - 1)).get(Updater.TYPE_VALUE); - this.versionGameVersion = (String) ((JSONObject) array.get(array.size() - 1)).get(Updater.VERSION_VALUE); - + JSONArray gameVersions = (JSONArray) ((JSONObject) array.get(array.size() - 1)).get(Updater.VERSION_VALUE); + this.versionGameVersion = gameVersions.get(gameVersions.size() - 1).toString(); 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("The Modrinth API server 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("The updater could not contact the api.modrinth.com server 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; } @@ -588,7 +667,7 @@ public final class Updater { final String[] split = Updater.this.versionLink.split("/"); name = split[split.length - 1]; } - Updater.this.saveFile(new File(Updater.this.plugin.getDataFolder().getParent(), Updater.this.updateFolder), name, Updater.this.versionLink); + Updater.this.saveFile(Updater.this.plugin.getServer().getUpdateFolderFile(), name, Updater.this.versionLink); } else { Updater.this.result = UpdateResult.UPDATE_AVAILABLE; }