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
This commit is contained in:
Phoenix616 2024-03-04 19:28:02 +01:00
parent df17fe7483
commit b386cacb5e
No known key found for this signature in database
GPG Key ID: 40E2321E71738EB0
2 changed files with 121 additions and 41 deletions

View File

@ -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/";

View File

@ -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;
}