From 0000000000000000000000000000000000000000 Mon Sep 17 00:00:00 2001 From: violetc <58360096+s-yh-china@users.noreply.github.com> Date: Mon, 31 Jul 2023 17:42:25 +0800 Subject: [PATCH] Leaves Updater --------- Co-authored-by: MC_XiaoHei diff --git a/src/main/java/org/leavesmc/leaves/command/LeavesCommand.java b/src/main/java/org/leavesmc/leaves/command/LeavesCommand.java index 393163c80b9e2ce04b089e90d20eefd7b7ad8366..41f4245f558f1c41ea84891b920f1d03dcb07066 100644 --- a/src/main/java/org/leavesmc/leaves/command/LeavesCommand.java +++ b/src/main/java/org/leavesmc/leaves/command/LeavesCommand.java @@ -32,6 +32,7 @@ public final class LeavesCommand extends Command { private static final Map SUBCOMMANDS = Util.make(() -> { final Map, LeavesSubcommand> commands = new HashMap<>(); commands.put(Set.of("config"), new ConfigCommand()); + commands.put(Set.of("update"), new UpdateCommand()); return commands.entrySet().stream() .flatMap(entry -> entry.getKey().stream().map(s -> Map.entry(s, entry.getValue()))) diff --git a/src/main/java/org/leavesmc/leaves/command/subcommands/UpdateCommand.java b/src/main/java/org/leavesmc/leaves/command/subcommands/UpdateCommand.java new file mode 100644 index 0000000000000000000000000000000000000000..7f94df607e8ffd48ab2cb7c90d520c2b4a906cdc --- /dev/null +++ b/src/main/java/org/leavesmc/leaves/command/subcommands/UpdateCommand.java @@ -0,0 +1,21 @@ +package org.leavesmc.leaves.command.subcommands; + +import org.bukkit.ChatColor; +import org.bukkit.command.CommandSender; +import org.leavesmc.leaves.command.LeavesSubcommand; +import org.leavesmc.leaves.util.LeavesUpdateHelper; + +public class UpdateCommand implements LeavesSubcommand { + + @Override + public boolean execute(CommandSender sender, String subCommand, String[] args) { + sender.sendMessage(ChatColor.GRAY + "Trying to update Leaves, see the console for more info."); + LeavesUpdateHelper.tryUpdateLeaves(); + return true; + } + + @Override + public boolean tabCompletes() { + return false; + } +} diff --git a/src/main/java/org/leavesmc/leaves/util/LeavesUpdateHelper.java b/src/main/java/org/leavesmc/leaves/util/LeavesUpdateHelper.java new file mode 100644 index 0000000000000000000000000000000000000000..f72e8bca8c2978abaf1acab30ca0853d484c1e6f --- /dev/null +++ b/src/main/java/org/leavesmc/leaves/util/LeavesUpdateHelper.java @@ -0,0 +1,249 @@ +package org.leavesmc.leaves.util; + +import com.google.common.base.Charsets; +import com.google.gson.Gson; +import com.google.gson.JsonArray; +import com.google.gson.JsonElement; +import com.google.gson.JsonObject; +import com.google.gson.JsonSyntaxException; +import io.papermc.paper.ServerBuildInfo; +import net.minecraft.Util; +import org.jetbrains.annotations.NotNull; +import org.leavesmc.leaves.LeavesConfig; +import org.leavesmc.leaves.LeavesLogger; + +import java.io.BufferedReader; +import java.io.BufferedWriter; +import java.io.File; +import java.io.FileInputStream; +import java.io.FileWriter; +import java.io.IOException; +import java.io.InputStreamReader; +import java.net.HttpURLConnection; +import java.net.URI; +import java.net.URISyntaxException; +import java.net.URL; +import java.nio.channels.Channels; +import java.nio.channels.FileChannel; +import java.nio.channels.ReadableByteChannel; +import java.nio.file.Files; +import java.nio.file.Path; +import java.security.MessageDigest; +import java.time.Duration; +import java.time.LocalTime; +import java.util.Map; +import java.util.concurrent.Executors; +import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.locks.ReentrantLock; + +import static java.nio.file.StandardOpenOption.CREATE; +import static java.nio.file.StandardOpenOption.TRUNCATE_EXISTING; +import static java.nio.file.StandardOpenOption.WRITE; + +public class LeavesUpdateHelper { + + private final static String autoUpdateDir = "auto_update"; + private final static String corePathFileName = autoUpdateDir + File.separator + "core.path"; + + private final static ReentrantLock updateLock = new ReentrantLock(); + private static boolean updateTaskStarted = false; + + private static final ScheduledExecutorService autoUpdateExecutor = Executors.newScheduledThreadPool(1); + + public static void init() { + File workingDirFile = new File(autoUpdateDir); + if (!workingDirFile.exists()) { + if (!workingDirFile.mkdir()) { + LeavesLogger.LOGGER.warning("Failed to create working directory: " + autoUpdateDir); + } + } + + File corePathFile = new File(corePathFileName); + if (!corePathFile.exists()) { + try { + if (!corePathFile.createNewFile()) { + throw new IOException(); + } + } catch (IOException e) { + LeavesLogger.LOGGER.severe("Failed to create core path file: " + corePathFileName, e); + } + } + + File leavesUpdateDir = new File(autoUpdateDir + File.separator + "leaves"); + if (!leavesUpdateDir.exists()) { + if (!leavesUpdateDir.mkdir()) { + LeavesLogger.LOGGER.warning("Failed to create leaves update directory: " + leavesUpdateDir); + } + } + + if (LeavesConfig.autoUpdate) { + LocalTime currentTime = LocalTime.now(); + long dailyTaskPeriod = 24 * 60 * 60 * 1000; + + for (String time : LeavesConfig.autoUpdateTime) { + try { + LocalTime taskTime = LocalTime.of(Integer.parseInt(time.split(":")[0]), Integer.parseInt(time.split(":")[1])); + Duration task = Duration.between(currentTime, taskTime); + if (task.isNegative()) { + task = task.plusDays(1); + } + autoUpdateExecutor.scheduleAtFixedRate(LeavesUpdateHelper::tryUpdateLeaves, task.toMillis(), dailyTaskPeriod, TimeUnit.MILLISECONDS); + } catch (Exception ignored) { + LeavesLogger.LOGGER.warning("Illegal auto-update time ignored: " + time); + } + } + } + } + + public static void tryUpdateLeaves() { + updateLock.lock(); + try { + if (!updateTaskStarted) { + updateTaskStarted = true; + new Thread(LeavesUpdateHelper::downloadLeaves).start(); + } + } finally { + updateLock.unlock(); + } + } + + private static void downloadLeaves() { + ServerBuildInfo version = ServerBuildInfo.buildInfo(); + if (version.gitCommit().isEmpty() || version.buildNumber().isEmpty()) { + LeavesLogger.LOGGER.info("IDE, custom build? Can not update!"); + updateTaskStarted = false; + return; + } + + LeavesLogger.LOGGER.info("Now gitHash: " + version.gitCommit().get()); + LeavesLogger.LOGGER.info("Trying to get latest build info."); + LeavesBuildInfo buildInfo = getLatestBuildInfo(version.minecraftVersionId(), version.gitCommit().get()); + + if (buildInfo != LeavesBuildInfo.ERROR) { + if (!buildInfo.needUpdate) { + LeavesLogger.LOGGER.warning("You are running the latest version, stopping update."); + updateTaskStarted = false; + return; + } + + LeavesLogger.LOGGER.info("Got build info, trying to download " + buildInfo.fileName); + try { + Path outFile = Path.of(autoUpdateDir, "leaves", buildInfo.fileName + ".cache"); + Files.deleteIfExists(outFile); + + try ( + final ReadableByteChannel source = Channels.newChannel(new URI( + buildInfo.url + LeavesConfig.autoUpdateSource).toURL().openStream() + ); + final FileChannel fileChannel = FileChannel.open(outFile, CREATE, WRITE, TRUNCATE_EXISTING) + ) { + fileChannel.transferFrom(source, 0, Long.MAX_VALUE); + LeavesLogger.LOGGER.info("Download " + buildInfo.fileName + " completed."); + } catch (final IOException e) { + LeavesLogger.LOGGER.warning("Download " + buildInfo.fileName + " failed.", e); + Files.deleteIfExists(outFile); + updateTaskStarted = false; + return; + } + + if (!isFileValid(outFile, buildInfo.sha256)) { + LeavesLogger.LOGGER.warning("Hash check failed for downloaded file " + buildInfo.fileName); + Files.deleteIfExists(outFile); + updateTaskStarted = false; + return; + } + + File nowServerCore = new File(autoUpdateDir + File.separator + "leaves" + File.separator + buildInfo.fileName); + File backupServerCore = new File(autoUpdateDir + File.separator + "leaves" + File.separator + buildInfo.fileName + ".old"); + Util.safeReplaceFile(nowServerCore.toPath(), outFile, backupServerCore.toPath()); + + try (BufferedWriter bufferedWriter = new BufferedWriter(new FileWriter(corePathFileName))) { + bufferedWriter.write(autoUpdateDir + File.separator + "leaves" + File.separator + buildInfo.fileName); + } catch (IOException e) { + LeavesLogger.LOGGER.warning("Fail to download leaves core", e); + updateTaskStarted = false; + return; + } + + LeavesLogger.LOGGER.info("Leaves update completed, please restart your server."); + } catch (Exception e) { + LeavesLogger.LOGGER.severe("Leaves update failed", e); + } + } else { + LeavesLogger.LOGGER.warning("Stopping update."); + } + updateTaskStarted = false; + } + + private static boolean isFileValid(Path file, String hash) { + try (FileInputStream inputStream = new FileInputStream(file.toFile())) { + byte[] buffer = new byte[1024]; + MessageDigest md5 = MessageDigest.getInstance("SHA-256"); + + for (int numRead; (numRead = inputStream.read(buffer)) > 0; ) { + md5.update(buffer, 0, numRead); + } + + return toHexString(md5.digest()).equals(hash); + } catch (Exception e) { + LeavesLogger.LOGGER.warning("Fail to validate file " + file, e); + } + return false; + } + + @NotNull + private static String toHexString(byte @NotNull [] bytes) { + StringBuilder builder = new StringBuilder(); + for (byte b : bytes) { + builder.append(String.format("%02x", b)); + } + return builder.toString(); + } + + private static LeavesBuildInfo getLatestBuildInfo(String mcVersion, String gitHash) { + try { + HttpURLConnection connection = (HttpURLConnection) new URI( + "https://api.leavesmc.org/v2/projects/leaves/versions/" + mcVersion + "/builds/latest" + ).toURL().openConnection(); + connection.connect(); + if (connection.getResponseCode() == HttpURLConnection.HTTP_NOT_FOUND) { + return LeavesBuildInfo.ERROR; + } + try (BufferedReader reader = new BufferedReader(new InputStreamReader(connection.getInputStream(), Charsets.UTF_8))) { + JsonObject obj = new Gson().fromJson(reader, JsonObject.class); + String channel = obj.get("channel").getAsString(); + if ("experimental".equals(channel) && !LeavesConfig.autoUpdateAllowExperimental) { + LeavesLogger.LOGGER.warning("Experimental version is not allowed to update for default, if you really want to update, please set misc.auto-update.allow-experimental to true in leaves.yml"); + return LeavesBuildInfo.ERROR; + } + int build = obj.get("build").getAsInt(); + + JsonArray changes = obj.get("changes").getAsJsonArray(); + boolean needUpdate = true; + for (JsonElement change : changes) { + if (change.getAsJsonObject().get("commit").getAsString().startsWith(gitHash)) { + needUpdate = false; + break; + } + } + + JsonObject downloadInfo = obj.get("downloads").getAsJsonObject().get("application").getAsJsonObject(); + String fileName = downloadInfo.get("name").getAsString(); + String sha256 = downloadInfo.get("sha256").getAsString(); + String url = "https://api.leavesmc.org/v2/projects/leaves/versions/" + mcVersion + "/builds/" + build + "/downloads/"; + return new LeavesBuildInfo(build, fileName, sha256, needUpdate, url); + } catch (JsonSyntaxException | NumberFormatException e) { + LeavesLogger.LOGGER.warning("Fail to get latest build info", e); + return LeavesBuildInfo.ERROR; + } + } catch (IOException | URISyntaxException e) { + LeavesLogger.LOGGER.warning("Fail to get latest build info", e); + return LeavesBuildInfo.ERROR; + } + } + + private record LeavesBuildInfo(int build, String fileName, String sha256, boolean needUpdate, String url) { + public static LeavesBuildInfo ERROR = null; + } +}