From 2bda591693c82a57d8b7a93f868957831275304c Mon Sep 17 00:00:00 2001 From: monst Date: Tue, 21 Feb 2023 23:13:44 +0100 Subject: [PATCH] Redesign update system --- .../monst/bankingplugin/BankingPlugin.java | 12 +- .../command/plugin/BPUpdate.java | 22 +- .../monst/bankingplugin/gui/UpdateGUI.java | 95 +++--- .../com/monst/bankingplugin/lang/Message.java | 16 -- .../listener/NotifyPlayerOnJoinListener.java | 4 +- .../monst/bankingplugin/update/Download.java | 192 +++++++++++++ .../monst/bankingplugin/update/Update.java | 106 +++++++ .../bankingplugin/update/UpdaterService.java | 113 ++++++++ .../com/monst/bankingplugin/util/Update.java | 270 ------------------ .../bankingplugin/util/UpdaterService.java | 102 ------- src/main/resources/lang/en_US.lang | 16 -- 11 files changed, 483 insertions(+), 465 deletions(-) create mode 100644 src/main/java/com/monst/bankingplugin/update/Download.java create mode 100644 src/main/java/com/monst/bankingplugin/update/Update.java create mode 100644 src/main/java/com/monst/bankingplugin/update/UpdaterService.java delete mode 100644 src/main/java/com/monst/bankingplugin/util/Update.java delete mode 100644 src/main/java/com/monst/bankingplugin/util/UpdaterService.java diff --git a/src/main/java/com/monst/bankingplugin/BankingPlugin.java b/src/main/java/com/monst/bankingplugin/BankingPlugin.java index cae4592c..6e0492da 100644 --- a/src/main/java/com/monst/bankingplugin/BankingPlugin.java +++ b/src/main/java/com/monst/bankingplugin/BankingPlugin.java @@ -13,6 +13,7 @@ import com.monst.bankingplugin.listener.*; import com.monst.bankingplugin.persistence.Database; import com.monst.bankingplugin.persistence.service.*; +import com.monst.bankingplugin.update.UpdaterService; import com.monst.bankingplugin.util.*; import com.sk89q.worldedit.WorldEdit; import com.sk89q.worldedit.bukkit.WorldEditPlugin; @@ -105,15 +106,8 @@ public void onEnable() { schedulerService = new SchedulerService(this); paymentService = new PaymentService(this); - if (config().enableStartupUpdateCheck.get()) { - updaterService.checkForUpdate().then(update -> { - if (update == null) - return; - log(Level.WARNING, "Version " + update.getVersion() + " of BankingPlugin is available!"); - if (config().downloadUpdatesAutomatically.get()) - update.download(); - }).catchError(error -> getLogger().warning("Failed to check for updates!")); - } + if (config().enableStartupUpdateCheck.get()) + updaterService.checkForUpdate().catchError(error -> getLogger().warning("Failed to check for updates!")); new AccountCommand(this); new BankCommand(this); diff --git a/src/main/java/com/monst/bankingplugin/command/plugin/BPUpdate.java b/src/main/java/com/monst/bankingplugin/command/plugin/BPUpdate.java index b9adfd78..99cc6888 100644 --- a/src/main/java/com/monst/bankingplugin/command/plugin/BPUpdate.java +++ b/src/main/java/com/monst/bankingplugin/command/plugin/BPUpdate.java @@ -6,7 +6,6 @@ import com.monst.bankingplugin.command.SubCommand; import com.monst.bankingplugin.gui.UpdateGUI; import com.monst.bankingplugin.lang.Message; -import com.monst.bankingplugin.lang.Placeholder; import org.bukkit.command.CommandSender; import org.bukkit.entity.Player; @@ -40,24 +39,13 @@ protected void execute(CommandSender sender, String[] args) { sender.sendMessage(Message.NO_UPDATE_AVAILABLE.translate(plugin)); return; } - boolean download = plugin.config().downloadUpdatesAutomatically.get() - || args.length > 0 && args[0].equalsIgnoreCase("download"); - if (sender instanceof Player) { + + if (sender instanceof Player) new UpdateGUI(plugin, (Player) sender, update).open(); - if (download) - update.download(); - } else { - sender.sendMessage(Message.UPDATE_AVAILABLE.with(Placeholder.VERSION).as(update.getVersion()).translate(plugin)); - if (download) { - sender.sendMessage(Message.UPDATE_DOWNLOADING.translate(plugin)); - update.download() - .onValidating(() -> sender.sendMessage(Message.UPDATE_VALIDATING.translate(plugin))) - .onDownloadComplete(() -> sender.sendMessage(Message.UPDATE_DOWNLOAD_COMPLETE.translate(plugin))) - .catchError(error -> sender.sendMessage(Message.UPDATE_DOWNLOAD_FAILED.translate(plugin))); - } - } + + if (args.length > 0 && args[0].equalsIgnoreCase("download")) + update.download(); }).catchError(error -> sender.sendMessage(Message.UPDATE_CHECK_ERROR.translate(plugin))); } - } diff --git a/src/main/java/com/monst/bankingplugin/gui/UpdateGUI.java b/src/main/java/com/monst/bankingplugin/gui/UpdateGUI.java index 4caa12fc..9916b1df 100644 --- a/src/main/java/com/monst/bankingplugin/gui/UpdateGUI.java +++ b/src/main/java/com/monst/bankingplugin/gui/UpdateGUI.java @@ -1,7 +1,8 @@ package com.monst.bankingplugin.gui; import com.monst.bankingplugin.BankingPlugin; -import com.monst.bankingplugin.util.Update; +import com.monst.bankingplugin.util.Observer; +import com.monst.bankingplugin.update.Update; import org.bukkit.Bukkit; import org.bukkit.ChatColor; import org.bukkit.Material; @@ -12,9 +13,12 @@ import org.bukkit.inventory.ItemStack; import org.bukkit.inventory.meta.ItemMeta; +import java.time.Duration; import java.util.*; -public class UpdateGUI extends SinglePageGUI { +import static org.bukkit.ChatColor.*; + +public class UpdateGUI extends SinglePageGUI implements Observer { private final Update update; private final List versionLore; @@ -22,6 +26,7 @@ public class UpdateGUI extends SinglePageGUI { public UpdateGUI(BankingPlugin plugin, Player player, Update update) { super(plugin, player); this.update = update; + update.subscribe(this); this.versionLore = Collections.singletonList(ChatColor.GOLD.toString() + ChatColor.BOLD + update.getVersion()); } @@ -38,28 +43,36 @@ Map createItems(Player player) { return items; } - private List clickToDownload() { - List lore = new ArrayList<>(versionLore); - lore.add(ChatColor.GREEN + "Click to download."); - return lore; - } - @Override public void click(int slot, ClickType type) { if (slot != 4) return; switch (update.getState()) { case DOWNLOADING: - update.pauseDownload(); break; + update.pauseDownload(); + break; + case INITIAL: + case DOWNLOAD_FAILED: + case PAUSED: + update.download(); + } + } + + @Override + public void update() { + switch (update.getState()) { case INITIAL: + setStatus(clickToDownload()); break; + case DOWNLOADING: + setStatus(downloading(update.getDownload().getPercentComplete())); break; case PAUSED: - case ERROR: - update.download() - .onDownloadPercentageChange(percentage -> setStatus(downloading(percentage))) - .onPause(percentage -> setStatus(paused(percentage))) - .onValidating(() -> setStatus(validating())) - .onDownloadComplete(() -> setStatus(updateComplete())) - .catchError(error -> setStatus(error())); + setStatus(paused(update.getDownload().getPercentComplete())); break; + case VALIDATING: + setStatus(validating(update.getDownload().getDuration())); break; + case SUCCESS: + setStatus(updateComplete(update.getDownload().getDuration(), update.getDownload().isValidated())); break; + case DOWNLOAD_FAILED: + setStatus(downloadError()); break; } } @@ -71,37 +84,53 @@ private void setStatus(List lore) { inventory.setItem(4, item); // TODO: Line necessary, or does setItemMeta() already update the inventory? } + private List clickToDownload() { + return lore(GREEN + "Click to download."); + } + private List downloading(int percentage) { - List lore = new ArrayList<>(versionLore); - lore.add(ChatColor.RED + "Downloading... (" + percentage + "%)"); - lore.add(ChatColor.RED + "Click to pause download."); - return lore; + return lore( + RED + "Downloading... (" + percentage + "%)", + RED + "Click to pause download." + ); } private List paused(int percentage) { - List lore = new ArrayList<>(versionLore); - lore.add(ChatColor.GREEN + "Download paused. (" + percentage + "%)"); - lore.add(ChatColor.RED + "Click to resume download."); - return lore; + return lore( + GREEN + "Download paused. (" + percentage + "%)", + RED + "Click to resume download." + ); } - private List validating() { - List lore = new ArrayList<>(versionLore); - lore.add(ChatColor.GREEN + "Download complete."); - lore.add(ChatColor.RED + "Validating... "); - return lore; + private List validating(Duration duration) { + // Display duration formatted as "mm:ss.ss" + return lore( + GREEN + "Download complete. (" + formatDuration(duration) + ")", + RED + "Validating... " + ); } - private List updateComplete() { + private List updateComplete(Duration duration, boolean validated) { List lore = new ArrayList<>(versionLore); - lore.add(ChatColor.GREEN + "Download complete. File successfully validated."); + lore.add(GREEN + "Download complete. (" + formatDuration(duration) + ")"); + if (validated) + lore.add(GREEN + "Update successfully validated."); return lore; } - private List error() { + private List downloadError() { + return lore(DARK_RED + "Download failed. Click to retry."); + } + + private List lore(String... lines) { List lore = new ArrayList<>(versionLore); - lore.add(ChatColor.DARK_RED + "Download failed. Click to retry."); + lore.addAll(Arrays.asList(lines)); return lore; } + private static String formatDuration(Duration duration) { + // format as "mm:ss.ss" + return String.format("%02d:%02d.%02d", duration.toMinutes(), duration.getSeconds() % 60, duration.getNano() / 10_000_000) + "s"; + } + } diff --git a/src/main/java/com/monst/bankingplugin/lang/Message.java b/src/main/java/com/monst/bankingplugin/lang/Message.java index 73944d3b..99e9eb44 100644 --- a/src/main/java/com/monst/bankingplugin/lang/Message.java +++ b/src/main/java/com/monst/bankingplugin/lang/Message.java @@ -958,22 +958,6 @@ public enum Message implements Translatable { "An admin is notified that an error occurred while checking for updates.", red().bold("Error while checking for updates.") ), - UPDATE_DOWNLOADING( - "An admin is notified that an update to BankingPlugin is being downloaded.", - gold().bold("Downloading update...") - ), - UPDATE_VALIDATING( - "An admin is notified that a downloaded update to BankingPlugin is being validated.", - gold().bold("Validating download...") - ), - UPDATE_DOWNLOAD_COMPLETE( - "An admin is notified that an update to BankingPlugin was successfully downloaded.", - gold().bold("Download successful.") - ), - UPDATE_DOWNLOAD_FAILED( - "An admin is notified that an update to BankingPlugin could not be downloaded.", - red().bold("Download failed.") - ), CLICK_TO_DONATE( "A player executes the donate command and they are shown the donation link.", gold().bold("Thank you! Click to donate: ").green(URL), diff --git a/src/main/java/com/monst/bankingplugin/listener/NotifyPlayerOnJoinListener.java b/src/main/java/com/monst/bankingplugin/listener/NotifyPlayerOnJoinListener.java index 390369ac..5216e90e 100644 --- a/src/main/java/com/monst/bankingplugin/listener/NotifyPlayerOnJoinListener.java +++ b/src/main/java/com/monst/bankingplugin/listener/NotifyPlayerOnJoinListener.java @@ -4,7 +4,7 @@ import com.monst.bankingplugin.command.Permissions; import com.monst.bankingplugin.lang.Message; import com.monst.bankingplugin.lang.Placeholder; -import com.monst.bankingplugin.util.Update; +import com.monst.bankingplugin.update.Update; import org.bukkit.entity.Player; import org.bukkit.event.EventHandler; import org.bukkit.event.Listener; @@ -26,7 +26,7 @@ public void onPlayerJoin(PlayerJoinEvent e) { Player player = e.getPlayer(); Update update = plugin.getUpdaterService().getUpdateIfAvailable(); - if (update != null && !update.isCompleted() && Permissions.UPDATE.ownedBy(player)) + if (update != null && !update.isDownloaded() && Permissions.UPDATE.ownedBy(player)) player.sendMessage(Message.UPDATE_AVAILABLE .with(Placeholder.VERSION).as(update.getVersion()) .translate(plugin)); diff --git a/src/main/java/com/monst/bankingplugin/update/Download.java b/src/main/java/com/monst/bankingplugin/update/Download.java new file mode 100644 index 00000000..be73d63c --- /dev/null +++ b/src/main/java/com/monst/bankingplugin/update/Download.java @@ -0,0 +1,192 @@ +package com.monst.bankingplugin.update; + +import com.monst.bankingplugin.BankingPlugin; +import org.bukkit.Bukkit; + +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.net.URL; +import java.net.URLConnection; +import java.nio.file.Files; +import java.nio.file.Path; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.time.Duration; +import java.util.logging.Level; + +import static java.nio.file.StandardCopyOption.REPLACE_EXISTING; +import static java.nio.file.StandardOpenOption.*; + +public class Download { + + private static class DownloadInterruptedException extends Exception {} + + private static final Path DOWNLOAD_PATH = Bukkit.getServer().getUpdateFolderFile().toPath().resolve("bankingplugin.download"); + + private final BankingPlugin plugin; + private final Update update; + + private boolean isRunning; + private Duration duration = Duration.ZERO; + private long bytesDownloaded; + private int percentComplete; + private Boolean validated; // Null if not yet attempted, true if validated, false if validation attempt failed (not necessarily invalid) + private Exception exception; + + Download(BankingPlugin plugin, Update update) { + this.plugin = plugin; + this.update = update; + } + + void start() { + if (!isRunning && !isCompleted() && !failed()) { + plugin.log(Level.INFO, "Downloading update " + update.getVersion() + "..."); + update.setState(Update.State.DOWNLOADING); + Bukkit.getScheduler().runTaskAsynchronously(plugin, this::run); + } + } + + void pause() { + isRunning = false; + } + + private void run() { + long startTime = System.currentTimeMillis(); + isRunning = true; + try { + if (!Files.exists(DOWNLOAD_PATH)) // If the download file doesn't exist, be sure the directory does + Files.createDirectories(DOWNLOAD_PATH.getParent()); + + else if (Files.size(DOWNLOAD_PATH) != bytesDownloaded) { + // If the incomplete file exists and its size is not equal to the number of bytes downloaded, delete it + plugin.debug("Overwriting incomplete download file because its size was unexpected. Starting over..."); + bytesDownloaded = 0; // Don't need to delete the file, just reset the number of bytes downloaded + } + + // Establish a connection with the server + URLConnection con = requestBytesAtURL(update.getURL(), bytesDownloaded); + streamBytesToDownloadFile(con); + validate(); + + // Rename incomplete file to the plugin jar file name, replacing any existing file in the update folder with that name + Files.move(DOWNLOAD_PATH, DOWNLOAD_PATH.resolveSibling(plugin.getFileName()), REPLACE_EXISTING); + + duration = duration.plusMillis((System.currentTimeMillis() - startTime)); + update.setState(Update.State.SUCCESS); + plugin.log(Level.INFO, "Download complete! Restart the server to apply the update."); + } catch (DownloadInterruptedException ignored) { + duration = duration.plusMillis(System.currentTimeMillis() - startTime); + } catch (IOException e) { + exception = e; + update.setState(Update.State.DOWNLOAD_FAILED); + plugin.log(Level.SEVERE, "Download failed. Try again or update the plugin manually (version " + update.getVersion() + ").", e); + } + isRunning = false; + } + + private static URLConnection requestBytesAtURL(URL url, long resumeFrom) throws IOException { + URLConnection con = url.openConnection(); + con.setRequestProperty("Accept", "application/octet-stream"); + if (resumeFrom > 0) // If the download is being resumed, only request the remaining bytes + con.setRequestProperty("Range", "bytes=" + resumeFrom + "-"); + con.setConnectTimeout(5000); + con.connect(); + return con; + } + + private void streamBytesToDownloadFile(URLConnection con) throws IOException, DownloadInterruptedException { + long bytesPerPercentageStep = update.getFileSizeBytes() / 100; + // Open an input stream from the connection and an output stream to the file + try (InputStream urlIn = con.getInputStream(); + // If the download is being resumed, append to what was already downloaded. Otherwise, overwrite. + OutputStream fileOut = Files.newOutputStream(DOWNLOAD_PATH, CREATE, bytesDownloaded > 0 ? APPEND : TRUNCATE_EXISTING)) { + + // Download the data 8KiB at a time + byte[] buffer = new byte[8 * 1024]; + for (int bytesRead = urlIn.read(buffer); bytesRead != -1; bytesRead = urlIn.read(buffer)) { + fileOut.write(buffer, 0, bytesRead); + bytesDownloaded += bytesRead; + if (bytesDownloaded >= bytesPerPercentageStep * (percentComplete + 1)) { + percentComplete = (int) (bytesDownloaded / bytesPerPercentageStep); + update.notifyObservers(); + if (percentComplete % 14 == 0) + plugin.log(Level.INFO, "Downloaded " + percentComplete + "%"); + } + // If the download has been paused or set outdated, stop the download + if (!isRunning && bytesDownloaded < update.getFileSizeBytes()) { + update.setState(Update.State.PAUSED); + plugin.log(Level.INFO, "Download paused."); + throw new DownloadInterruptedException(); + } + } + plugin.log(Level.INFO, "Download complete."); + } + } + + private void validate() throws IOException { + String remoteChecksum = update.getRemoteChecksum(); + if (remoteChecksum == null) { + plugin.debug("No checksum provided by server. Skipping validation."); + validated = false; + return; + } + + update.setState(Update.State.VALIDATING); + plugin.log(Level.INFO, "Validating download..."); + try { + Thread.sleep(1000); // Wait one second for better UX :) + } catch (InterruptedException ignored) {} + + String checksum; + try { + // Get an MD5 digest instance for calculating the checksum + MessageDigest md5 = MessageDigest.getInstance("MD5"); + md5.update(Files.readAllBytes(DOWNLOAD_PATH)); + checksum = toHexString(md5.digest()); + } catch (NoSuchAlgorithmException | IOException e) { + // MD5 is guaranteed to be supported by all Java implementations, so NoSuchAlgorithmException should never be thrown + // If we were still not able to validate the file for some reason, just log the error and return + plugin.log(Level.WARNING, "Attempted to validate the download, but an error occurred. Skipping validation.", e); + validated = false; + return; + } + + // Compare the downloaded file's checksum against the one on the server + // If they don't match, throw an exception and force the user to download the file again + if (!checksum.equals(remoteChecksum)) + throw new IOException("Downloaded file's checksum does not match the one on the server."); + + validated = true; + plugin.log(Level.INFO, "Download validated."); + } + + private String toHexString(byte[] bytes) { + StringBuilder sb = new StringBuilder(32); + for (byte b : bytes) + sb.append(String.format("%02x", b)); + return sb.toString(); + } + + public boolean isCompleted() { + // Return true if file is fully downloaded and was at least attempted to be validated + return bytesDownloaded == update.getFileSizeBytes() && validated != null; + } + + public boolean failed() { + return exception != null; + } + + public int getPercentComplete() { + return percentComplete; + } + + public Duration getDuration() { + return duration; + } + + public boolean isValidated() { + return validated != null && validated; + } + +} diff --git a/src/main/java/com/monst/bankingplugin/update/Update.java b/src/main/java/com/monst/bankingplugin/update/Update.java new file mode 100644 index 00000000..0c1270b6 --- /dev/null +++ b/src/main/java/com/monst/bankingplugin/update/Update.java @@ -0,0 +1,106 @@ +package com.monst.bankingplugin.update; + +import com.monst.bankingplugin.BankingPlugin; +import com.monst.bankingplugin.util.Observable; +import com.monst.bankingplugin.util.Observer; + +import java.net.URL; +import java.util.HashSet; +import java.util.Set; + +public class Update implements Observable { + + public enum State { + /** The update has not begun downloading yet.*/ + INITIAL, + + /** The update is currently being downloaded.*/ + DOWNLOADING, + + /** The download process was paused.*/ + PAUSED, + + /** The download process has completed and the file is being validated.*/ + VALIDATING, + + /** The file has been successfully downloaded and validated.*/ + SUCCESS, + + /** The download failed and may be retried.*/ + DOWNLOAD_FAILED, + } + + private final BankingPlugin plugin; + private final String version; + private final URL url; + private final String remoteChecksum; // Nullable + private final long fileSizeBytes; + + private State state = State.INITIAL; + private Download download; + + public Update(BankingPlugin plugin, String version, URL url, String remoteChecksum, long fileSizeBytes) { + this.plugin = plugin; + this.version = version; + this.url = url; + this.remoteChecksum = remoteChecksum; + this.fileSizeBytes = fileSizeBytes; + } + + /** + * Downloads the update to the server's update folder under the same name as the current jar file. If the download + * is successful, the update is marked as {@link State#SUCCESS completed}. If the download fails, the update is + * placed in an {@link State#DOWNLOAD_FAILED error} state and can be retried. + */ + public void download() { + if (download == null || download.failed()) + download = new Download(plugin, this); + if (!download.isCompleted()) + download.start(); + } + + public Download getDownload() { + return download; + } + + public void pauseDownload() { + if (download != null && state == State.DOWNLOADING) + download.pause(); + } + + public boolean isDownloaded() { + return download != null && download.isCompleted(); + } + + public State getState() { + return state; + } + + void setState(State state) { + this.state = state; + notifyObservers(); + } + + public String getVersion() { + return version; + } + + URL getURL() { + return url; + } + + long getFileSizeBytes() { + return fileSizeBytes; + } + + String getRemoteChecksum() { + return remoteChecksum; + } + + private final Set observers = new HashSet<>(); + @Override + public Set getObservers() { + return observers; + } + +} diff --git a/src/main/java/com/monst/bankingplugin/update/UpdaterService.java b/src/main/java/com/monst/bankingplugin/update/UpdaterService.java new file mode 100644 index 00000000..d5393613 --- /dev/null +++ b/src/main/java/com/monst/bankingplugin/update/UpdaterService.java @@ -0,0 +1,113 @@ +package com.monst.bankingplugin.update; + +import com.google.gson.JsonArray; +import com.google.gson.JsonElement; +import com.google.gson.JsonObject; +import com.google.gson.JsonParser; +import com.monst.bankingplugin.BankingPlugin; +import com.monst.bankingplugin.util.Promise; + +import java.io.InputStreamReader; +import java.net.URL; +import java.net.URLConnection; +import java.nio.charset.StandardCharsets; +import java.util.Optional; +import java.util.logging.Level; + +public class UpdaterService { + + private final BankingPlugin plugin; + private Update update; + + public UpdaterService(BankingPlugin plugin) { + this.plugin = plugin; + } + + /** + * Checks for an update on GitHub. + * @return A promise which will resolve to the latest update if one is available. + */ + public Promise checkForUpdate() { + plugin.log(Level.INFO, "Checking for updates..."); + return Promise.async(plugin, () -> { + URL url = new URL("https://api.github.com/repos/FreshLlamanade/BankingPlugin/releases"); + URLConnection con = url.openConnection(); + con.setConnectTimeout(5000); + con.setRequestProperty("User-Agent", "BankingPlugin"); + con.setDoOutput(true); + + JsonElement response = new JsonParser().parse(new InputStreamReader(con.getInputStream(), StandardCharsets.UTF_8)); + + JsonArray releases = response.getAsJsonArray(); + if (releases.size() == 0) + // No releases available + return update; + + String versionNumber = null; + JsonObject jar = null; + // Releases are sorted newest to oldest + // Iterate backwards through releases to find the latest version + for (int i = releases.size() - 1; i >= 0; i--) { + JsonObject version = releases.get(i).getAsJsonObject(); + versionNumber = version.get("name").getAsString(); + plugin.debug("Checking update version " + versionNumber); + + if (versionNumber.compareTo(plugin.getDescription().getVersion()) <= 0) { + // This version is no newer than current version + // No need to check further + plugin.log(Level.INFO, "No updates found."); + return update; + } + + if (plugin.config().ignoreUpdatesContaining.ignore(versionNumber)) { // This version is ignored + plugin.debug("Skipping update version " + versionNumber + " because it contains an ignored tag."); + continue; + } + + jar = searchForJar(version.get("assets").getAsJsonArray()); + if (jar != null) + break; + } + + if (jar == null) { + plugin.log(Level.INFO, "No updates found."); + return update; + } + + plugin.log(Level.WARNING, "BankingPlugin version " + versionNumber + " is available!"); + + // This update is newer than any currently available update + if (update == null || update.getVersion().compareTo(versionNumber) < 0) { + plugin.debug("Creating update."); + + // Create a new update object + URL fileURL = new URL(jar.get("browser_download_url").getAsString()); + String checksum = Optional.ofNullable(jar.get("md5")).map(JsonElement::getAsString).orElse(null); + int downloadSize = jar.get("size").getAsInt(); + update = new Update(plugin, versionNumber, fileURL, checksum, downloadSize); + } + + if (plugin.config().downloadUpdatesAutomatically.get()) + update.download(); + return update; + }); + } + + private JsonObject searchForJar(JsonArray assets) { + for (JsonElement asset : assets) { + JsonObject assetObject = asset.getAsJsonObject(); + if (assetObject.get("name").getAsString().endsWith(".jar")) + return assetObject; + } + return null; + } + + /** + * Returns the latest update if one has already been found. + * @return The latest update if one is available, or null if no update has been found yet. + */ + public Update getUpdateIfAvailable() { + return update; + } + +} diff --git a/src/main/java/com/monst/bankingplugin/util/Update.java b/src/main/java/com/monst/bankingplugin/util/Update.java deleted file mode 100644 index 5314d2d2..00000000 --- a/src/main/java/com/monst/bankingplugin/util/Update.java +++ /dev/null @@ -1,270 +0,0 @@ -package com.monst.bankingplugin.util; - -import com.monst.bankingplugin.BankingPlugin; -import org.bukkit.Bukkit; - -import java.io.IOException; -import java.io.InputStream; -import java.io.OutputStream; -import java.net.URL; -import java.net.URLConnection; -import java.nio.file.Files; -import java.nio.file.Path; -import java.nio.file.StandardCopyOption; -import java.security.MessageDigest; -import java.security.NoSuchAlgorithmException; -import java.util.function.Consumer; -import java.util.logging.Level; - -import static java.nio.file.StandardOpenOption.*; - -public class Update { - - private static final Path DOWNLOAD_PATH = Bukkit.getServer().getUpdateFolderFile().toPath().resolve("bankingplugin.incomplete"); - - public enum State { - /** The update has not begun downloading yet.*/ - INITIAL, - - /** The update is currently being downloaded.*/ - DOWNLOADING, - - /** The download process has completed and the file is being validated.*/ - VALIDATING, - - /** The file has been successfully downloaded and validated.*/ - COMPLETED, - - /** The download process was paused.*/ - PAUSED, - - /** The download failed and may be retried.*/ - ERROR, - - /** The update is no longer the latest available version.*/ - OUTDATED - } - - private final BankingPlugin plugin; - private final String version; - private final URL fileURL; - private final String remoteChecksum; // Nullable - private final long filesize; - - private Download download; - private State state = State.INITIAL; - private long bytesDownloaded; - - public Update(BankingPlugin plugin, String version, URL fileURL, String remoteChecksum, long filesize) { - this.plugin = plugin; - this.version = version; - this.fileURL = fileURL; - this.remoteChecksum = remoteChecksum; - this.filesize = filesize; - } - - /** - * Downloads the update to the server's update folder under the same name as the current jar file. - * If the download is successful, the update is marked as {@link State#COMPLETED completed}. - * If the download fails, the update is placed in an {@link State#ERROR error} state and can be retried. - */ - public Download download() { - if (download != null && download.isRunning()) { - plugin.debug("Download already in progress, skipping call to download()"); - return download; - } - - if (!(state == State.INITIAL || state == State.PAUSED || state == State.ERROR)) - return download; // Only continue if the update is in one of these states - - setState(State.DOWNLOADING); - return download = new Download(); - } - - /** - * Pauses the download. - */ - public void pauseDownload() { - if (state == State.DOWNLOADING) - setState(State.PAUSED); - } - - public void setOutdated() { - setState(State.OUTDATED); - } - - public State getState() { - return state; - } - - public void setState(State state) { - this.state = state; - } - - public String getVersion() { - return version; - } - - public boolean isCompleted() { - return state == State.COMPLETED; - } - - public class Download { - - private boolean isRunning; - private Exception exception; - private int downloadPercentage; - private Consumer onDownloadPercentageChange = percentage -> {}; - private Runnable onDownloadComplete = () -> {}; - private Consumer onPause = percentage -> {}; - private Runnable onValidate = () -> {}; - private Consumer onRejected = error -> {}; - - public Download() { - Bukkit.getScheduler().runTaskAsynchronously(plugin, this::run); - } - - private void run() { - isRunning = true; - try { - // Create the update folder if it doesn't exist - createDownloadDirectory(); - // Establish a connection with the server - URLConnection con = connect(); - if (!downloadFile(con)) { - return; - } - setState(State.VALIDATING); - validate(); - rename(); - setState(State.COMPLETED); - plugin.log(Level.INFO, "Download complete! Restart the server to apply the update."); - } catch (IOException e) { - setState(State.ERROR); - plugin.log(Level.SEVERE, "Download failed. Try again or update the plugin manually (version " + version + ")."); - plugin.debug(e); - exception = e; - if (onRejected != null) - onRejected.accept(e); - } - isRunning = false; - } - - private void createDownloadDirectory() throws IOException { - Files.createDirectories(DOWNLOAD_PATH.getParent()); - } - - private URLConnection connect() throws IOException { - URLConnection con = fileURL.openConnection(); - con.setRequestProperty("Accept", "application/octet-stream"); - if (bytesDownloaded > 0) // If the download is being resumed, only request the remaining bytes - con.setRequestProperty("Range", "bytes=" + bytesDownloaded + "-"); - con.setConnectTimeout(5000); - con.connect(); - return con; - } - - private boolean downloadFile(URLConnection con) throws IOException { - plugin.log(Level.INFO, "Downloading BankingPlugin v" + version + "..."); - boolean resume = state == State.PAUSED; // If the download was paused, continue from where it left off. Otherwise, start over. - if (!resume) { - bytesDownloaded = 0; // Reset the bytes downloaded counter - downloadPercentage = 0; // Reset the download percentage - } - // Open an input stream from the connection and an output stream to the file - try (InputStream urlIn = con.getInputStream(); - // If the download is being resumed, append to what was already downloaded. Otherwise, overwrite. - OutputStream fileOut = Files.newOutputStream(DOWNLOAD_PATH, CREATE, resume ? APPEND : TRUNCATE_EXISTING)) { - - // Download the data 8KiB at a time - byte[] buffer = new byte[8 * 1024]; - for (int bytesRead = urlIn.read(buffer); bytesRead != -1; bytesRead = urlIn.read(buffer)) { - fileOut.write(buffer, 0, bytesRead); - int newPercentage = (int) (100 * (bytesDownloaded += bytesRead) / filesize); - if (newPercentage != downloadPercentage) { - downloadPercentage = newPercentage; - if (onDownloadPercentageChange != null) - onDownloadPercentageChange.accept(newPercentage); - } - // If the download has been paused or set outdated, stop the download - if (state == State.PAUSED || state == State.OUTDATED) { - if (onPause != null) - onPause.accept(downloadPercentage); - return false; - } - } - plugin.getLogger().info("Download complete."); - if (onDownloadComplete != null) - onDownloadComplete.run(); - return true; - } - } - - private void validate() throws IOException { - if (remoteChecksum == null) - return; - if (onValidate != null) - onValidate.run(); - plugin.getLogger().info("Validating download..."); - try { - Thread.sleep(1000); // Wait one second for better UX :) - } catch (InterruptedException ignored) {} - try { - // Get an MD5 digest instance for calculating the checksum - MessageDigest md5 = MessageDigest.getInstance("MD5"); - md5.update(Files.readAllBytes(DOWNLOAD_PATH)); - String downloadChecksum = toHexString(md5.digest()); - // Compare the downloaded file's checksum against the one on the server - if (!downloadChecksum.equals(remoteChecksum)) - throw new IOException("Checksums do not match! " + downloadChecksum + " != " + remoteChecksum); - } catch (NoSuchAlgorithmException ignored) { - // Not able to validate with MD5, everything is probably fine so just continue - } - } - - private String toHexString(byte[] bytes) { - StringBuilder sb = new StringBuilder(32); - for (byte b : bytes) - sb.append(String.format("%02x", b)); - return sb.toString(); - } - - private void rename() throws IOException { - // Rename incomplete file to the plugin jar file name, replacing any existing file in the update folder with that name - Files.move(DOWNLOAD_PATH, DOWNLOAD_PATH.resolveSibling(plugin.getFileName()), StandardCopyOption.REPLACE_EXISTING); - } - - public Download onDownloadPercentageChange(Consumer onDownloadPercentageChange) { - this.onDownloadPercentageChange = onDownloadPercentageChange; - return this; - } - - public Download onPause(Consumer onPause) { - this.onPause = onPause; - return this; - } - - public Download onValidating(Runnable onValidate) { - this.onValidate = onValidate; - return this; - } - - public Download onDownloadComplete(Runnable onDownloadComplete) { - this.onDownloadComplete = onDownloadComplete; - return this; - } - - public Download catchError(Consumer onRejected) { - this.onRejected = onRejected; - if (exception != null) - onRejected.accept(exception); - return this; - } - - private boolean isRunning() { - return isRunning; - } - - } - -} diff --git a/src/main/java/com/monst/bankingplugin/util/UpdaterService.java b/src/main/java/com/monst/bankingplugin/util/UpdaterService.java deleted file mode 100644 index 52e29eb8..00000000 --- a/src/main/java/com/monst/bankingplugin/util/UpdaterService.java +++ /dev/null @@ -1,102 +0,0 @@ -package com.monst.bankingplugin.util; - -import com.google.gson.JsonArray; -import com.google.gson.JsonElement; -import com.google.gson.JsonObject; -import com.google.gson.JsonParser; -import com.monst.bankingplugin.BankingPlugin; - -import java.io.InputStreamReader; -import java.net.URL; -import java.net.URLConnection; -import java.nio.charset.StandardCharsets; -import java.util.Optional; - -public class UpdaterService { - - private final BankingPlugin plugin; - private Update update; - - public UpdaterService(BankingPlugin plugin) { - this.plugin = plugin; - } - - /** - * Checks if an update is needed. - */ - public Promise checkForUpdate() { - plugin.debug("Checking for updates..."); - return Promise.async(plugin, () -> { - JsonElement response; - URL url = new URL("https://api.github.com/repos/FreshLlamanade/BankingPlugin/releases"); - URLConnection con = url.openConnection(); - con.setConnectTimeout(5000); - con.setRequestProperty("User-Agent", "BankingPlugin"); - con.setDoOutput(true); - - response = new JsonParser().parse(new InputStreamReader(con.getInputStream(), StandardCharsets.UTF_8)); - - // Releases are sorted by date, newest first - JsonArray releases = response.getAsJsonArray(); - - if (releases.size() == 0) - // No versions available - return update; - - String versionNumber = null; - JsonObject jar = null; - for (int i = releases.size() - 1; i >= 0; i--) { - JsonObject version = releases.get(i).getAsJsonObject(); - versionNumber = version.get("name").getAsString(); - plugin.debug("Checking version " + versionNumber); - - if (versionNumber.compareTo(plugin.getDescription().getVersion()) <= 0) { - // This version is no newer than current version - // No need to check further - return update; - } - - if (plugin.config().ignoreUpdatesContaining.ignore(versionNumber)) { // This version is ignored - plugin.debug("Skipping version " + versionNumber + " because it contains an ignored tag."); - continue; - } - - for (JsonElement asset : version.get("assets").getAsJsonArray()) { - if (((JsonObject) asset).get("name").getAsString().endsWith(".jar")) { - // Found the latest non-ignored jar available, and it is newer than the current version - jar = (JsonObject) asset; - break; - } - } - } - - if (jar == null) - // No suitable update found - return update; - - plugin.debug("Found latest version: " + versionNumber); - - // An update already exists newer or equal to this one - if (update != null && update.getVersion().compareTo(versionNumber) > 0) - return update; - - if (update != null) - update.setOutdated(); - - // Create a new update package - plugin.debug("Creating new update package."); - URL fileURL; - fileURL = new URL(jar.get("browser_download_url").getAsString()); - String checksum = Optional.ofNullable(jar.get("md5")).map(JsonElement::getAsString).orElse(null); - int downloadSize = jar.get("size").getAsInt(); - - update = new Update(plugin, versionNumber, fileURL, checksum, downloadSize); - return update; - }); - } - - public Update getUpdateIfAvailable() { - return update; - } - -} diff --git a/src/main/resources/lang/en_US.lang b/src/main/resources/lang/en_US.lang index 24550694..e67094bf 100644 --- a/src/main/resources/lang/en_US.lang +++ b/src/main/resources/lang/en_US.lang @@ -848,22 +848,6 @@ message.no-update-available=&6&lNo new version available. # Available placeholders: message.update-check-error=&c&lError while checking for updates. -# Example Scenario: An admin is notified that an update to BankingPlugin is being downloaded. -# Available placeholders: -message.update-downloading=&6&lDownloading update... - -# Example Scenario: An admin is notified that a downloaded update to BankingPlugin is being validated. -# Available placeholders: -message.update-validating=&6&lValidating download... - -# Example Scenario: An admin is notified that an update to BankingPlugin was successfully downloaded. -# Available placeholders: -message.update-download-complete=&6&lDownload successful. - -# Example Scenario: An admin is notified that an update to BankingPlugin could not be downloaded. -# Available placeholders: -message.update-download-failed=&c&lDownload failed. - # Example Scenario: A player executes the donate command and they are shown the donation link. # Available placeholders: %URL% message.click-to-donate=&6&lThank you! Click to donate: &a%URL%