diff --git a/server/build.gradle.kts b/server/build.gradle.kts index 1086ccb..f370ae9 100644 --- a/server/build.gradle.kts +++ b/server/build.gradle.kts @@ -2,6 +2,8 @@ import net.minecrell.pluginyml.paper.PaperPluginDescription import java.text.SimpleDateFormat import java.util.* +val paperApiVersion = "26.1.2" + plugins { java `maven-publish` @@ -29,7 +31,7 @@ dependencies { library("com.zaxxer:HikariCP:7.0.2") library("com.j256.ormlite:ormlite-jdbc:6.1") library("org.jetbrains:annotations:26.1.0") - compileOnly("io.papermc.paper:paper-api:26.1.2.build.+") + compileOnly("io.papermc.paper:paper-api:${paperApiVersion}.build.+") compileOnly("com.github.MilkBowl:VaultAPI:1.7.1") { exclude("org.bukkit", "bukkit") } @@ -53,7 +55,7 @@ paper { loader = "dev.plex.PlexLibraryManager" website = "https://plex.us.org" authors = listOf("Telesphoreo", "taahanis", "supernt") - apiVersion = "26.1.2" + apiVersion = paperApiVersion foliaSupported = true generateLibrariesJson = true // Load BukkitTelnet and LibsDisguises before Plex so the modules register properly @@ -131,6 +133,7 @@ tasks { property("buildNumber", if (System.getenv("BUILD_NUMBER") != null) System.getenv("BUILD_NUMBER") else getBuildNumber()) property("date", SimpleDateFormat("MM/dd/yyyy 'at' hh:mm:ss a z").format(Date())) property("gitCommit", indraGit.commit().get().name.take(7)) + property("minecraftVersion", paperApiVersion) } } } @@ -171,4 +174,4 @@ publishing { artifacts.artifact(tasks.shadowJar) } } -} \ No newline at end of file +} diff --git a/server/src/main/java/dev/plex/command/impl/PlexCMD.java b/server/src/main/java/dev/plex/command/impl/PlexCMD.java index 4c0d057..aff2c86 100644 --- a/server/src/main/java/dev/plex/command/impl/PlexCMD.java +++ b/server/src/main/java/dev/plex/command/impl/PlexCMD.java @@ -27,7 +27,7 @@ import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; @CommandPermissions(source = RequiredCommandSource.ANY) -@CommandParameters(name = "plex", usage = "/ [reload | redis | modules [reload]]", description = "Show information about Plex or reload it") +@CommandParameters(name = "plex", usage = "/ [reload | redis | update | modules [reload | update]]", description = "Show information about Plex or reload it") public class PlexCMD extends ServerCommand { // Don't modify this command @@ -132,11 +132,11 @@ public class PlexCMD extends ServerCommand { if (args.length == 1) { - return Arrays.asList("reload", "redis", "modules"); + return Arrays.asList("reload", "redis", "modules", "update"); } else if (args[0].equalsIgnoreCase("modules")) { - return List.of("reload"); + return Arrays.asList("reload", "update"); } return Collections.emptyList(); } @@ -161,4 +161,4 @@ public class PlexCMD extends ServerCommand assert player != null; return PlexUtils.DEVELOPERS.contains(player.getUniqueId().toString()); } -} \ No newline at end of file +} diff --git a/server/src/main/java/dev/plex/updater/ArtifactMetadata.java b/server/src/main/java/dev/plex/updater/ArtifactMetadata.java new file mode 100644 index 0000000..da07b0a --- /dev/null +++ b/server/src/main/java/dev/plex/updater/ArtifactMetadata.java @@ -0,0 +1,197 @@ +package dev.plex.updater; + +import java.net.URI; +import java.net.URLDecoder; +import java.nio.charset.StandardCharsets; +import java.util.List; +import java.util.Locale; +import java.util.Objects; +import java.util.Optional; +import java.util.regex.Pattern; + +public final class ArtifactMetadata +{ + private static final int CURRENT_SCHEMA_VERSION = 1; + private static final Pattern SHA256_PATTERN = Pattern.compile("^[a-fA-F0-9]{64}$"); + + private int schemaVersion; + private String name; + private String version; + private String buildNumber; + private String commit; + private String channel; + private List minecraftVersions; + private Integer apiCompatibility; + private Integer requiredApiCompatibility; + private String downloadUrl; + private String sha256; + private Long size; + private String publishedAt; + + public String name() + { + return name; + } + + public String version() + { + return version; + } + + public String buildNumber() + { + return buildNumber; + } + + public String commit() + { + return commit; + } + + public String channel() + { + return channel; + } + + public String downloadUrl() + { + return downloadUrl; + } + + public String sha256() + { + return sha256; + } + + public Long size() + { + return size; + } + + public String publishedAt() + { + return publishedAt; + } + + public Optional validatePlex(UpdateChannel requestedChannel, String minecraftVersion) + { + Optional commonError = validateCommon(requestedChannel); + if (commonError.isPresent()) + { + return commonError; + } + if (!"Plex".equalsIgnoreCase(name)) + { + return Optional.of("Plex metadata has unexpected artifact name " + name); + } + if (minecraftVersions == null || !minecraftVersions.contains(minecraftVersion)) + { + return Optional.of("metadata does not include Minecraft version " + minecraftVersion); + } + if (apiCompatibility == null) + { + return Optional.of("Plex metadata is missing apiCompatibility"); + } + return Optional.empty(); + } + + public Optional validateModule(UpdateChannel requestedChannel, String moduleName, int apiCompatibility) + { + Optional commonError = validateCommon(requestedChannel); + if (commonError.isPresent()) + { + return commonError; + } + if (!moduleName.equalsIgnoreCase(name)) + { + return Optional.of("module metadata has unexpected artifact name " + name); + } + if (requiredApiCompatibility == null) + { + return Optional.of("module metadata is missing requiredApiCompatibility"); + } + if (requiredApiCompatibility != apiCompatibility) + { + return Optional.of("module metadata requires API compatibility " + requiredApiCompatibility + ", but Plex provides " + apiCompatibility); + } + return Optional.empty(); + } + + public boolean matchesCurrentBuild(String currentVersion, String currentBuildNumber, String currentCommit) + { + if (!Objects.equals(version, currentVersion)) + { + return false; + } + if (isKnown(commit) && isKnown(currentCommit)) + { + return commit.equalsIgnoreCase(currentCommit); + } + return Objects.equals(buildNumber, currentBuildNumber); + } + + public String fileName() + { + try + { + String path = URI.create(downloadUrl).getPath(); + int separatorIndex = path.lastIndexOf('/'); + String fileName = separatorIndex >= 0 ? path.substring(separatorIndex + 1) : path; + if (!fileName.isBlank()) + { + return URLDecoder.decode(fileName, StandardCharsets.UTF_8); + } + } + catch (IllegalArgumentException ignored) + { + } + return name + "-" + version + ".jar"; + } + + private Optional validateCommon(UpdateChannel requestedChannel) + { + if (schemaVersion != CURRENT_SCHEMA_VERSION) + { + return Optional.of("metadata schemaVersion " + schemaVersion + " is not supported"); + } + if (isBlank(name)) + { + return Optional.of("metadata is missing name"); + } + if (isBlank(version)) + { + return Optional.of("metadata is missing version"); + } + if (isBlank(channel)) + { + return Optional.of("metadata is missing channel"); + } + if (!requestedChannel.id().equalsIgnoreCase(channel)) + { + return Optional.of("metadata channel " + channel + " does not match requested channel " + requestedChannel.id()); + } + if (requestedChannel == UpdateChannel.STABLE && version.toUpperCase(Locale.ROOT).contains("SNAPSHOT")) + { + return Optional.of("stable metadata must not point to a SNAPSHOT version"); + } + if (isBlank(downloadUrl)) + { + return Optional.of("metadata is missing downloadUrl"); + } + if (isBlank(sha256) || !SHA256_PATTERN.matcher(sha256).matches()) + { + return Optional.of("metadata is missing a valid sha256"); + } + return Optional.empty(); + } + + private static boolean isBlank(String value) + { + return value == null || value.isBlank(); + } + + private static boolean isKnown(String value) + { + return !isBlank(value) && !"unknown".equalsIgnoreCase(value); + } +} diff --git a/server/src/main/java/dev/plex/updater/UpdateChannel.java b/server/src/main/java/dev/plex/updater/UpdateChannel.java new file mode 100644 index 0000000..01c2b67 --- /dev/null +++ b/server/src/main/java/dev/plex/updater/UpdateChannel.java @@ -0,0 +1,36 @@ +package dev.plex.updater; + +import java.util.Locale; + +public enum UpdateChannel +{ + STABLE("stable"), + DEV("dev"); + + private final String id; + + UpdateChannel(String id) + { + this.id = id; + } + + public String id() + { + return id; + } + + public static UpdateChannel fromConfig(String value) + { + if (value == null) + { + return STABLE; + } + + return switch (value.trim().toLowerCase(Locale.ROOT)) + { + case "dev" -> DEV; + case "stable" -> STABLE; + default -> STABLE; + }; + } +} diff --git a/server/src/main/java/dev/plex/updater/UpdateMetadataClient.java b/server/src/main/java/dev/plex/updater/UpdateMetadataClient.java new file mode 100644 index 0000000..b2cbf83 --- /dev/null +++ b/server/src/main/java/dev/plex/updater/UpdateMetadataClient.java @@ -0,0 +1,157 @@ +package dev.plex.updater; + +import com.google.gson.Gson; +import com.google.gson.JsonSyntaxException; + +import java.io.BufferedReader; +import java.io.IOException; +import java.io.InputStreamReader; +import java.net.HttpURLConnection; +import java.net.URI; +import java.net.URLEncoder; +import java.nio.charset.StandardCharsets; +import java.util.List; +import java.util.Optional; + +public final class UpdateMetadataClient +{ + private static final List BASE_URLS = List.of("https://updater.plex.us.org", "https://plex-updater.com"); + + private final Gson gson = new Gson(); + private final UpdateChannel channel; + + public UpdateMetadataClient(UpdateChannel channel) + { + this.channel = channel; + } + + public ArtifactMetadata fetchPlexLatest(String minecraftVersion) throws MetadataException + { + String path = "/api/v1/projects/Plex/channels/" + channel.id() + "/latest/minecraft/" + encodePathSegment(minecraftVersion) + ".json"; + ArtifactMetadata metadata = fetch(path); + Optional validationError = metadata.validatePlex(channel, minecraftVersion); + if (validationError.isPresent()) + { + throw new MetadataException(validationError.get(), false); + } + return metadata; + } + + public ArtifactMetadata fetchModuleLatest(String moduleName, int apiCompatibility) throws MetadataException + { + String path = "/api/v1/projects/" + encodePathSegment(moduleName) + "/channels/" + channel.id() + "/latest/api/" + apiCompatibility + ".json"; + ArtifactMetadata metadata = fetch(path); + Optional validationError = metadata.validateModule(channel, moduleName, apiCompatibility); + if (validationError.isPresent()) + { + throw new MetadataException(validationError.get(), false); + } + return metadata; + } + + private ArtifactMetadata fetch(String path) throws MetadataException + { + MetadataException notFound = null; + MetadataException failure = null; + for (String baseUrl : BASE_URLS) + { + try + { + return fetch(baseUrl, path); + } + catch (MetadataException e) + { + if (e.notFound()) + { + if (notFound == null) + { + notFound = e; + } + continue; + } + failure = e; + } + } + + if (failure != null) + { + throw new MetadataException("all metadata endpoints failed for " + path + "; last error: " + failure.getMessage(), false, failure); + } + if (notFound != null) + { + throw notFound; + } + throw new MetadataException("no updater metadata endpoints are available", false); + } + + private ArtifactMetadata fetch(String baseUrl, String path) throws MetadataException + { + String url = baseUrl + path; + try + { + HttpURLConnection connection = (HttpURLConnection) URI.create(url).toURL().openConnection(); + connection.setConnectTimeout(10000); + connection.setReadTimeout(10000); + connection.setRequestProperty("Accept", "application/json"); + + int statusCode = connection.getResponseCode(); + if (statusCode == HttpURLConnection.HTTP_NOT_FOUND) + { + throw new MetadataException("no compatible update metadata exists at " + path + " on " + baseUrl, true); + } + if (statusCode != HttpURLConnection.HTTP_OK) + { + throw new MetadataException("metadata request returned HTTP " + statusCode + " for " + path + " on " + baseUrl, false); + } + + try (BufferedReader reader = new BufferedReader(new InputStreamReader(connection.getInputStream(), StandardCharsets.UTF_8))) + { + ArtifactMetadata metadata = gson.fromJson(reader, ArtifactMetadata.class); + if (metadata == null) + { + throw new MetadataException("metadata response was empty for " + path + " on " + baseUrl, false); + } + return metadata; + } + catch (JsonSyntaxException e) + { + throw new MetadataException("metadata response was not valid JSON for " + path + " on " + baseUrl, false, e); + } + } + catch (IllegalArgumentException e) + { + throw new MetadataException("metadata URL is invalid: " + url, false, e); + } + catch (IOException e) + { + throw new MetadataException("metadata request failed for " + path + " on " + baseUrl, false, e); + } + } + + private static String encodePathSegment(String value) + { + return URLEncoder.encode(value, StandardCharsets.UTF_8).replace("+", "%20"); + } + + public static final class MetadataException extends Exception + { + private final boolean notFound; + + private MetadataException(String message, boolean notFound) + { + super(message); + this.notFound = notFound; + } + + private MetadataException(String message, boolean notFound, Throwable cause) + { + super(message, cause); + this.notFound = notFound; + } + + public boolean notFound() + { + return notFound; + } + } +} diff --git a/server/src/main/java/dev/plex/util/BuildInfo.java b/server/src/main/java/dev/plex/util/BuildInfo.java index 7e7fc02..61603ff 100644 --- a/server/src/main/java/dev/plex/util/BuildInfo.java +++ b/server/src/main/java/dev/plex/util/BuildInfo.java @@ -17,6 +17,8 @@ public class BuildInfo public static String date; @Getter public static String number; + @Getter + public static String minecraftVersion; public void load(Plex plugin) { @@ -33,6 +35,7 @@ public class BuildInfo commit = props.getProperty("gitCommit", "unknown"); date = props.getProperty("date", "unknown"); number = props.getProperty("buildNumber", "unknown"); + minecraftVersion = props.getProperty("minecraftVersion", "unknown"); } catch (Exception ignored) { diff --git a/server/src/main/java/dev/plex/util/UpdateChecker.java b/server/src/main/java/dev/plex/util/UpdateChecker.java index 5b9a5b3..8f0c061 100644 --- a/server/src/main/java/dev/plex/util/UpdateChecker.java +++ b/server/src/main/java/dev/plex/util/UpdateChecker.java @@ -1,88 +1,41 @@ package dev.plex.util; -import com.google.gson.Gson; -import com.google.gson.JsonObject; -import com.google.gson.JsonSyntaxException; import dev.plex.Plex; +import dev.plex.updater.ArtifactMetadata; +import dev.plex.updater.UpdateChannel; +import dev.plex.updater.UpdateMetadataClient; -import java.io.BufferedReader; import java.io.File; import java.io.IOException; -import java.io.InputStreamReader; +import java.io.InputStream; +import java.io.OutputStream; import java.net.HttpURLConnection; import java.net.URI; -import java.nio.charset.StandardCharsets; -import java.util.concurrent.atomic.AtomicReference; +import java.nio.file.Files; +import java.nio.file.StandardCopyOption; +import java.security.DigestInputStream; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.util.HexFormat; -import lombok.NonNull; import net.kyori.adventure.text.Component; import net.kyori.adventure.text.format.NamedTextColor; -import org.apache.commons.io.FileUtils; import org.bukkit.Bukkit; import org.bukkit.command.CommandSender; import org.bukkit.entity.Player; -import org.json.JSONException; public class UpdateChecker { - /* - * -4 = Never checked for updates - * -3 = Likely rate limited - * -2 = Unknown commit - * -1 = Error occurred - * 0 = Up to date - * > 0 = Number of commits behind - */ private final Plex plugin; - private final String DOWNLOAD_PAGE = "https://ci.plex.us.org/job/"; - private final String REPO; - private String BRANCH; - private int distance = -4; + private final UpdateChannel channel; + private final UpdateMetadataClient metadataClient; + private ArtifactMetadata latestPlexMetadata; public UpdateChecker(Plex plugin) { this.plugin = plugin; - this.REPO = plugin.config.getString("update_repo"); - this.BRANCH = plugin.config.getString("update_branch"); - } - - // Adapted from Paper - private int fetchDistanceFromGitHub(@NonNull String repo, @NonNull String branch, @NonNull String hash) - { - try - { - HttpURLConnection connection = (HttpURLConnection) URI.create("https://api.github.com/repos/" + repo + "/compare/" + branch + "..." + hash).toURL().openConnection(); - connection.connect(); - if (connection.getResponseCode() == HttpURLConnection.HTTP_NOT_FOUND) - { - return -2; // Unknown commit - } - if (connection.getResponseCode() == HttpURLConnection.HTTP_FORBIDDEN) - { - return -3; // Rate limited likely - } - try (BufferedReader reader = new BufferedReader(new InputStreamReader(connection.getInputStream(), StandardCharsets.UTF_8))) - { - JsonObject obj = new Gson().fromJson(reader, JsonObject.class); - String status = obj.get("status").getAsString(); - return switch (status) - { - case "identical" -> 0; - case "behind" -> obj.get("behind_by").getAsInt(); - default -> -1; - }; - } - catch (JsonSyntaxException | NumberFormatException e) - { - e.printStackTrace(); - return -1; - } - } - catch (IOException e) - { - e.printStackTrace(); - return -1; - } + this.channel = UpdateChannel.fromConfig(plugin.config.getString("updater.channel")); + this.metadataClient = new UpdateMetadataClient(channel); } // If verbose is 0, it will display nothing @@ -90,143 +43,183 @@ public class UpdateChecker // If verbose is 2, it will display all messages public boolean getUpdateStatusMessage(CommandSender sender, boolean cached, int verbosity) { - if (BRANCH == null) + try { - PlexLog.error("You did not specify a branch to use for update checking. Defaulting to master."); - BRANCH = "master"; - } - // If it's -4, it hasn't checked for updates yet - if (distance == -4) - { - distance = fetchDistanceFromGitHub(REPO, BRANCH, BuildInfo.getCommit()); - PlexLog.debug("Never checked for updates, checking now..."); - } - else - { - // If the request isn't asked to be cached, fetch it - if (!cached) + ArtifactMetadata metadata = fetchLatestPlexMetadata(cached); + if (metadata.matchesCurrentBuild(plugin.getPluginMeta().getVersion(), BuildInfo.getNumber(), BuildInfo.getCommit())) { - distance = fetchDistanceFromGitHub(REPO, BRANCH, BuildInfo.getCommit()); - PlexLog.debug("We have checked for updates before, but this request was not asked to be cached."); + if (verbosity == 2) + { + sendMessage(sender, Component.text("Plex is up to date on the " + channel.id() + " channel.").color(NamedTextColor.GREEN)); + } + return false; } - else - { - PlexLog.debug("We have checked for updates before, using cache."); - } - } - switch (distance) + if (verbosity >= 1) + { + sendMessage(sender, Component.text("Plex " + metadata.version() + " is available on the " + channel.id() + " channel.", NamedTextColor.RED)); + sendMessage(sender, Component.text("Run: /plex update").color(NamedTextColor.RED)); + } + return true; + } + catch (UpdateMetadataClient.MetadataException e) { - case -1 -> + if (verbosity == 2 || (verbosity >= 1 && !e.notFound())) { - if (verbosity == 2) - { - sendMessage(sender, Component.text("There was an error checking for updates.").color(NamedTextColor.RED)); - } - return false; + sendMessage(sender, Component.text(updateMetadataErrorMessage(e)).color(NamedTextColor.RED)); } - case 0 -> + if (!e.notFound()) { - if (verbosity == 2) + PlexLog.error("Unable to check for updates: {0}", e.getMessage()); + if (e.getCause() != null) { - sendMessage(sender, Component.text("Plex is up to date!").color(NamedTextColor.GREEN)); + e.getCause().printStackTrace(); } - return false; - } - case -2 -> - { - if (verbosity == 2) - { - sendMessage(sender, Component.text("Unknown version, unable to check for updates.").color(NamedTextColor.RED)); - } - return false; - } - default -> - { - if (verbosity >= 1) - { - sendMessage(sender, Component.text("Plex is not up to date!", NamedTextColor.RED)); - sendMessage(sender, Component.text("Download a new version at: " + DOWNLOAD_PAGE + "Plex").color(NamedTextColor.RED)); - sendMessage(sender, Component.text("Or run: /plex update").color(NamedTextColor.RED)); - } - return true; } + return false; } } public void updateJar(CommandSender sender, String name, boolean module) { - AtomicReference url = new AtomicReference<>(DOWNLOAD_PAGE + name); - if (!module) - { - url.set(url.get() + "/job/" + BRANCH); - } - PlexLog.debug(url.toString()); try { - HttpURLConnection connection = (HttpURLConnection) URI.create(url + "/lastSuccessfulBuild/api/json").toURL().openConnection(); - int statusCode = connection.getResponseCode(); + ArtifactMetadata metadata = module + ? metadataClient.fetchModuleLatest(name, plugin.getApi().compatibility().version()) + : fetchLatestPlexMetadata(false); - if (statusCode == HttpURLConnection.HTTP_OK) + if (!module && metadata.matchesCurrentBuild(plugin.getPluginMeta().getVersion(), BuildInfo.getNumber(), BuildInfo.getCommit())) { - try (BufferedReader reader = new BufferedReader(new InputStreamReader(connection.getInputStream(), StandardCharsets.UTF_8))) + sendMessage(sender, PlexUtils.mmDeserialize("Plex is already up to date on the " + channel.id() + " channel.")); + return; + } + + File copyTo = module + ? new File(plugin.getModulesFolder(), metadata.fileName()) + : new File(Bukkit.getUpdateFolderFile(), metadata.fileName()); + + sendMessage(sender, PlexUtils.mmDeserialize("Downloading latest JAR file: " + metadata.fileName())); + plugin.getApi().scheduler().runAsync(() -> downloadAndInstall(sender, metadata, copyTo)); + } + catch (UpdateMetadataClient.MetadataException e) + { + sendMessage(sender, PlexUtils.mmDeserialize("" + updateMetadataErrorMessage(e))); + if (!e.notFound()) + { + PlexLog.error("Unable to update {0}: {1}", name, e.getMessage()); + if (e.getCause() != null) { - JsonObject object = new Gson().fromJson(reader, JsonObject.class); - JsonObject artifact = object.getAsJsonArray("artifacts").get(module ? 0 : 1).getAsJsonObject(); - String jarFile = artifact.get("fileName").getAsString(); - sendMessage(sender, PlexUtils.mmDeserialize("Downloading latest JAR file: " + jarFile)); - File copyTo; - if (!module) - { - copyTo = new File(Bukkit.getUpdateFolderFile(), jarFile); - } - else - { - copyTo = new File(plugin.getModulesFolder().getPath(), jarFile); - } - plugin.getApi().scheduler().runAsync(() -> - { - try - { - FileUtils.copyURLToFile( - URI.create(url + "/lastSuccessfulBuild/artifact/build/libs/" + jarFile).toURL(), - copyTo - ); - sendMessage(sender, PlexUtils.mmDeserialize("New JAR file downloaded successfully.")); - } - catch (IOException e) - { - e.printStackTrace(); - } - }); - } - catch (JsonSyntaxException | NumberFormatException e) - { - e.printStackTrace(); + e.getCause().printStackTrace(); } } - else if (statusCode == HttpURLConnection.HTTP_NOT_FOUND) - { - sendMessage(sender, PlexUtils.mmDeserialize("Could not update " + name + " as it can't be found on Jenkins.")); - } - else - { - sendMessage(sender, PlexUtils.mmDeserialize("Something went wrong while trying to update " + name + ". Please check the log for more information.")); - PlexLog.error("Unable to update module {0} due to unexpected status code returned from Jenkins - Status Code: {1}", name, statusCode); - } + } + } + + private ArtifactMetadata fetchLatestPlexMetadata(boolean cached) throws UpdateMetadataClient.MetadataException + { + if (!cached || latestPlexMetadata == null) + { + latestPlexMetadata = metadataClient.fetchPlexLatest(BuildInfo.getMinecraftVersion()); + } + return latestPlexMetadata; + } + + private void downloadAndInstall(CommandSender sender, ArtifactMetadata metadata, File copyTo) + { + File parent = copyTo.getParentFile(); + if (parent != null && !parent.exists() && !parent.mkdirs()) + { + sendMessage(sender, PlexUtils.mmDeserialize("Unable to create update directory: " + parent.getAbsolutePath())); + return; + } + + File temporaryFile = new File(parent, copyTo.getName() + ".download"); + try + { + download(metadata.downloadUrl(), temporaryFile); + validateDownloadedFile(metadata, temporaryFile); + Files.move(temporaryFile.toPath(), copyTo.toPath(), StandardCopyOption.REPLACE_EXISTING); + sendMessage(sender, PlexUtils.mmDeserialize("New JAR file downloaded and verified successfully.")); } catch (IOException e) { + sendMessage(sender, PlexUtils.mmDeserialize("Something went wrong while downloading " + metadata.name() + ". Please check the log for more information.")); + PlexLog.error("Unable to download update {0}: {1}", metadata.name(), e.getMessage()); e.printStackTrace(); } - catch (JSONException e) + finally { - sendMessage(sender, PlexUtils.mmDeserialize("Something went wrong while trying to gather information from Jenkins for " + name + ". Please check the log for more information")); - PlexLog.error("Unable to parse JSON information received from Jenkins - see below for more information..."); - e.printStackTrace(); + try + { + Files.deleteIfExists(temporaryFile.toPath()); + } + catch (IOException ignored) + { + } } } + private void download(String downloadUrl, File destination) throws IOException + { + HttpURLConnection connection = (HttpURLConnection) URI.create(downloadUrl).toURL().openConnection(); + connection.setConnectTimeout(15000); + connection.setReadTimeout(30000); + + int statusCode = connection.getResponseCode(); + if (statusCode != HttpURLConnection.HTTP_OK) + { + throw new IOException("download request returned HTTP " + statusCode); + } + + try (InputStream inputStream = connection.getInputStream()) + { + Files.copy(inputStream, destination.toPath(), StandardCopyOption.REPLACE_EXISTING); + } + } + + private void validateDownloadedFile(ArtifactMetadata metadata, File file) throws IOException + { + if (metadata.size() != null && metadata.size() >= 0 && Files.size(file.toPath()) != metadata.size()) + { + throw new IOException("downloaded file size did not match metadata size"); + } + + String actualSha256 = sha256(file); + if (!metadata.sha256().equalsIgnoreCase(actualSha256)) + { + throw new IOException("downloaded file SHA-256 did not match metadata SHA-256"); + } + } + + private String sha256(File file) throws IOException + { + MessageDigest digest; + try + { + digest = MessageDigest.getInstance("SHA-256"); + } + catch (NoSuchAlgorithmException e) + { + throw new IllegalStateException("SHA-256 is not available", e); + } + + try (InputStream inputStream = Files.newInputStream(file.toPath()); + DigestInputStream digestInputStream = new DigestInputStream(inputStream, digest)) + { + digestInputStream.transferTo(OutputStream.nullOutputStream()); + } + return HexFormat.of().formatHex(digest.digest()); + } + + private String updateMetadataErrorMessage(UpdateMetadataClient.MetadataException e) + { + if (e.notFound()) + { + return "No compatible update is available on the " + channel.id() + " channel."; + } + return "There was an error checking update metadata: " + e.getMessage(); + } + private void sendMessage(CommandSender sender, Component message) { if (sender instanceof Player player) diff --git a/server/src/main/resource-templates/build-vars.properties b/server/src/main/resource-templates/build-vars.properties index 61842b9..e8d95b9 100644 --- a/server/src/main/resource-templates/build-vars.properties +++ b/server/src/main/resource-templates/build-vars.properties @@ -4,4 +4,6 @@ buildNumber={{ buildNumber | default("unknown") }} date={{ date }} -gitCommit={{ gitCommit | default("unknown") }} \ No newline at end of file +gitCommit={{ gitCommit | default("unknown") }} + +minecraftVersion={{ minecraftVersion | default("unknown") }} diff --git a/server/src/main/resources/config.yml b/server/src/main/resources/config.yml index 1cedc16..bd1e28f 100644 --- a/server/src/main/resources/config.yml +++ b/server/src/main/resources/config.yml @@ -258,11 +258,10 @@ worlds: stone: 16 bedrock: 1 -# If you are running a custom fork of Plex, you may wish to check for updates from a different repository. -update_repo: "plexusorg/Plex" - -# What branch should Plex fetch updates from? -update_branch: "master" +# Static updater metadata. The metadata can be hosted as plain JSON on Cloudflare Pages. +updater: + # Update channel to use. stable only serves non-SNAPSHOT releases; dev serves development builds. + channel: "stable" # Additional logging for debugging debug: false