From 79672bf3f7e463770572e122feaead46115d02a6 Mon Sep 17 00:00:00 2001 From: 1robie <97293924+1robie@users.noreply.github.com> Date: Wed, 8 Apr 2026 22:00:53 +0200 Subject: [PATCH 1/7] feat: add host validation for downloadable websites and enhance download error handling --- .../menu/api/configuration/Configuration.java | 328 +++++++++--------- .../fr/maxlego08/menu/api/utils/Message.java | 1 + .../menu/website/ZWebsiteManager.java | 126 +++++-- 3 files changed, 260 insertions(+), 195 deletions(-) diff --git a/API/src/main/java/fr/maxlego08/menu/api/configuration/Configuration.java b/API/src/main/java/fr/maxlego08/menu/api/configuration/Configuration.java index d097f250..e5b78609 100644 --- a/API/src/main/java/fr/maxlego08/menu/api/configuration/Configuration.java +++ b/API/src/main/java/fr/maxlego08/menu/api/configuration/Configuration.java @@ -22,113 +22,113 @@ public class Configuration { // Enable debug, allows you to display errors in the console that would normally be hidden. @ConfigOption( - key = "enableDebug", - type = DialogInputType.BOOLEAN, - trueText = "Enabled", - falseText = "Disabled", - label = "Enable debug" + key = "enableDebug", + type = DialogInputType.BOOLEAN, + trueText = "Enabled", + falseText = "Disabled", + label = "Enable debug" ) public static boolean enableDebug = false; // Enable debug time, allows you to display the code execution time in nanosecond, perfect for testing the effectiveness of the plugin. @ConfigOption( - key = "enableDebugTime", - type = DialogInputType.BOOLEAN, - trueText = "Enabled", - falseText = "Disabled", - label = "Enable debug time" + key = "enableDebugTime", + type = DialogInputType.BOOLEAN, + trueText = "Enabled", + falseText = "Disabled", + label = "Enable debug time" ) public static boolean enableDebugTime = false; // Enable an information message, allows you to view messages that tell you about an inventory or that an order has been successfully loaded. @ConfigOption( - key = "enableInformationMessage", - type = DialogInputType.BOOLEAN, - trueText = "Enabled", - falseText = "Disabled", - label = "Enable information message" + key = "enableInformationMessage", + type = DialogInputType.BOOLEAN, + trueText = "Enabled", + falseText = "Disabled", + label = "Enable information message" ) public static boolean enableInformationMessage = true; // Enable save or load file log in console @ConfigOption( - key = "enableLogStorageFile", - type = DialogInputType.BOOLEAN, - trueText = "Enabled", - falseText = "Disabled", - label = "Enable log storage file" + key = "enableLogStorageFile", + type = DialogInputType.BOOLEAN, + trueText = "Enabled", + falseText = "Disabled", + label = "Enable log storage file" ) public static boolean enableLogStorageFile = false; // Skip update check @ConfigOption( - key = "skipUpdateCheck", - type = DialogInputType.BOOLEAN, - trueText = "Enabled", - falseText = "Disabled", - label = "Skip update check" + key = "skipUpdateCheck", + type = DialogInputType.BOOLEAN, + trueText = "Enabled", + falseText = "Disabled", + label = "Skip update check" ) public static boolean skipUpdateCheck = false; // Enable open message, default value for the command /zm open @ConfigOption( - key = "enableOpenMessage", - type = DialogInputType.BOOLEAN, - trueText = "Enabled", - falseText = "Disabled", - label = "Enable open message" + key = "enableOpenMessage", + type = DialogInputType.BOOLEAN, + trueText = "Enabled", + falseText = "Disabled", + label = "Enable open message" ) public static boolean enableOpenMessage = true; // Enable mini message format, allows you to activate the mini message format, available from 1.17 onwards, more information here: https://docs.advntr.dev/minimessage/index.html @ConfigOption( - key = "enableMiniMessageFormat", - type = DialogInputType.BOOLEAN, - trueText = "Enabled", - falseText = "Disabled", - label = "Enable mini message format" + key = "enableMiniMessageFormat", + type = DialogInputType.BOOLEAN, + trueText = "Enabled", + falseText = "Disabled", + label = "Enable mini message format" ) public static boolean enableMiniMessageFormat = true; // Enable player command in chat, Allows you to ensure that when a player executes a command, they execute it from the chat and not from the console. If you have "fake" command, which are not saved in spigot you need to enable this option. @ConfigOption( - key = "enablePlayerCommandInChat", - type = DialogInputType.BOOLEAN, - trueText = "Enabled", - falseText = "Disabled", - label = "Enable player command in chat" + key = "enablePlayerCommandInChat", + type = DialogInputType.BOOLEAN, + trueText = "Enabled", + falseText = "Disabled", + label = "Enable player command in chat" ) public static boolean enablePlayerCommandInChat = false; // Allows you to use the FastEvent interface instead of bukkit events. You gain performance. To use FastEvent, please read the documentation. @ConfigOption( - key = "enableFastEvent", - type = DialogInputType.BOOLEAN, - trueText = "Enabled", - falseText = "Disabled", - label = "Enable FastEvent" + key = "enableFastEvent", + type = DialogInputType.BOOLEAN, + trueText = "Enabled", + falseText = "Disabled", + label = "Enable FastEvent" ) public static boolean enableFastEvent = false; // Seconds save player data: The time in seconds for automatic backup of player data. @ConfigOption( - key = "secondsSavePlayerData", - type = DialogInputType.NUMBER_RANGE, - label = "Seconds save player data", - startRange = 60, - endRange = 3600, - stepRange = 5 + key = "secondsSavePlayerData", + type = DialogInputType.NUMBER_RANGE, + label = "Seconds save player data", + startRange = 60, + endRange = 3600, + stepRange = 5 ) public static int secondsSavePlayerData = 600; // Seconds save player data: The time in seconds for automatic backup of inventories data. @ConfigOption( - key = "secondsSavePlayerInventories", - type = DialogInputType.NUMBER_RANGE, - label = "Seconds save player inventories", - startRange = 60, - endRange = 3600, - stepRange = 5 + key = "secondsSavePlayerInventories", + type = DialogInputType.NUMBER_RANGE, + label = "Seconds save player inventories", + startRange = 60, + endRange = 3600, + stepRange = 5 ) public static int secondsSavePlayerInventories = 600; @@ -137,21 +137,21 @@ public class Configuration { // Open main menu when swap item offhand key is press @ConfigOption( - key = "useSwapItemOffHandKeyToOpenMainMenu", - type = DialogInputType.BOOLEAN, - trueText = "Enabled", - falseText = "Disabled", - label = "Use swap item offhand key to open main menu" + key = "useSwapItemOffHandKeyToOpenMainMenu", + type = DialogInputType.BOOLEAN, + trueText = "Enabled", + falseText = "Disabled", + label = "Use swap item offhand key to open main menu" ) public static boolean useSwapItemOffHandKeyToOpenMainMenu = false; // Open main menu when swap item offhand key is press and sneak key @ConfigOption( - key = "useSwapItemOffHandKeyToOpenMainMenuNeedsShift", - type = DialogInputType.BOOLEAN, - trueText = "Enabled", - falseText = "Disabled", - label = "Use swap item offhand key to open main menu needs shift" + key = "useSwapItemOffHandKeyToOpenMainMenuNeedsShift", + type = DialogInputType.BOOLEAN, + trueText = "Enabled", + falseText = "Disabled", + label = "Use swap item offhand key to open main menu needs shift" ) public static boolean useSwapItemOffHandKeyToOpenMainMenuNeedsShift = false; @@ -160,169 +160,171 @@ public class Configuration { // Generate default configuration @ConfigOption( - key = "generateDefaultFile", - type = DialogInputType.BOOLEAN, - trueText = "Enabled", - falseText = "Disabled", - label = "Generate default file" + key = "generateDefaultFile", + type = DialogInputType.BOOLEAN, + trueText = "Enabled", + falseText = "Disabled", + label = "Generate default file" ) public static boolean generateDefaultFile = true; // Does not take double click into account @ConfigOption( - key = "disableDoubleClickEvent", - type = DialogInputType.BOOLEAN, - trueText = "Enabled", - falseText = "Disabled", - label = "Disable double click event" + key = "disableDoubleClickEvent", + type = DialogInputType.BOOLEAN, + trueText = "Enabled", + falseText = "Disabled", + label = "Disable double click event" ) public static boolean disableDoubleClickEvent = true; // Enable anti dupe @ConfigOption( - key = "enableAntiDupe", - type = DialogInputType.BOOLEAN, - trueText = "Enabled", - falseText = "Disabled", - label = "Enable anti dupe" + key = "enableAntiDupe", + type = DialogInputType.BOOLEAN, + trueText = "Enabled", + falseText = "Disabled", + label = "Enable anti dupe" ) public static boolean enableAntiDupe = true; // Enable anti dupe discord notification @ConfigOption( - key = "enableAntiDupeDiscordNotification", - type = DialogInputType.BOOLEAN, - trueText = "Enabled", - falseText = "Disabled", - label = "Enable anti dupe discord notification" + key = "enableAntiDupeDiscordNotification", + type = DialogInputType.BOOLEAN, + trueText = "Enabled", + falseText = "Disabled", + label = "Enable anti dupe discord notification" ) public static boolean enableAntiDupeDiscordNotification = false; @ConfigOption( - key = "antiDupeDiscordWebhookUrl", - type = DialogInputType.TEXT, - label = "Anti dupe discord webhook url", - maxLength = 100 + key = "antiDupeDiscordWebhookUrl", + type = DialogInputType.TEXT, + label = "Anti dupe discord webhook url", + maxLength = 100 ) public static String antiDupeDiscordWebhookUrl = "https://discord.com/api/webhooks/"; @ConfigOption( - key = "antiDupeMessage", - type = DialogInputType.TEXT, - label = "Anti dupe message", - maxLength = 200 + key = "antiDupeMessage", + type = DialogInputType.TEXT, + label = "Anti dupe message", + maxLength = 200 ) public static String antiDupeMessage = "**%player%** use %amount% %itemname% which comes from zMenu. Removing it !"; public static List allClicksType = Arrays.asList(ClickType.MIDDLE, ClickType.RIGHT, ClickType.LEFT, ClickType.SHIFT_RIGHT, ClickType.SHIFT_LEFT); // Enable cache itemstack in memory @ConfigOption( - key = "enableCacheItemStack", - type = DialogInputType.BOOLEAN, - trueText = "Enabled", - falseText = "Disabled", - label = "Enable cache item stack" + key = "enableCacheItemStack", + type = DialogInputType.BOOLEAN, + trueText = "Enabled", + falseText = "Disabled", + label = "Enable cache item stack" ) public static boolean enableCacheItemStack = true; @ConfigOption( - key = "enableCooldownClick", - type = DialogInputType.BOOLEAN, - trueText = "Enabled", - falseText = "Disabled", - label = "Enable cooldown click" + key = "enableCooldownClick", + type = DialogInputType.BOOLEAN, + trueText = "Enabled", + falseText = "Disabled", + label = "Enable cooldown click" ) public static boolean enableCooldownClick = true; @ConfigOption( - key = "cooldownClickMilliseconds", - type = DialogInputType.NUMBER_RANGE, - label = "Cooldown click milliseconds", - endRange = 1000, - stepRange = 10 + key = "cooldownClickMilliseconds", + type = DialogInputType.NUMBER_RANGE, + label = "Cooldown click milliseconds", + endRange = 1000, + stepRange = 10 ) public static long cooldownClickMilliseconds = 100; @ConfigOption( - key = "cachePlaceholderAPI", - type = DialogInputType.NUMBER_RANGE, - label = "Cache PlaceholderAPI", - endRange = 300, - stepRange = 5 + key = "cachePlaceholderAPI", + type = DialogInputType.NUMBER_RANGE, + label = "Cache PlaceholderAPI", + endRange = 300, + stepRange = 5 ) public static long cachePlaceholderAPI = 20; @ConfigOption( - key = "enableCachePlaceholderAPI", - type = DialogInputType.BOOLEAN, - trueText = "Enabled", - falseText = "Disabled", - label = "Enable cache PlaceholderAPI" + key = "enableCachePlaceholderAPI", + type = DialogInputType.BOOLEAN, + trueText = "Enabled", + falseText = "Disabled", + label = "Enable cache PlaceholderAPI" ) public static boolean enableCachePlaceholderAPI = false; @ConfigOption( - key = "enableDownloadCommand", - type = DialogInputType.BOOLEAN, - trueText = "Enabled", - falseText = "Disabled", - label = "Enable download command" + key = "enableDownloadCommand", + type = DialogInputType.BOOLEAN, + trueText = "Enabled", + falseText = "Disabled", + label = "Enable download command" ) public static boolean enableDownloadCommand = false; @ConfigOption( - key = "enablePlayerOpenInventoryLogs", - type = DialogInputType.BOOLEAN, - trueText = "Enabled", - falseText = "Disabled", - label = "Enable player open inventory logs" + key = "enablePlayerOpenInventoryLogs", + type = DialogInputType.BOOLEAN, + trueText = "Enabled", + falseText = "Disabled", + label = "Enable player open inventory logs" ) public static boolean enablePlayerOpenInventoryLogs = true; @ConfigOption( - key = "enablePlayerCommandsAsOPAction", - type = DialogInputType.BOOLEAN, - trueText = "Enabled", - falseText = "Disabled", - label = "Enable player commands as OP action (requires server restart)" + key = "enablePlayerCommandsAsOPAction", + type = DialogInputType.BOOLEAN, + trueText = "Enabled", + falseText = "Disabled", + label = "Enable player commands as OP action (requires server restart)" ) public static boolean enablePlayerCommandsAsOPAction = false; @ConfigOption( - key = "enablePacketEventClickLimiter", - type = DialogInputType.BOOLEAN, - trueText = "Enabled", - falseText = "Disabled", - label = "Enable packet event click limiter" + key = "enablePacketEventClickLimiter", + type = DialogInputType.BOOLEAN, + trueText = "Enabled", + falseText = "Disabled", + label = "Enable packet event click limiter" ) public static boolean enablePacketEventClickLimiter = false; @ConfigOption( - key = "packetEventClickLimiterMilliseconds", - type = DialogInputType.NUMBER_RANGE, - label = "Packet event click limiter milliseconds", - endRange = 1000, - stepRange = 10 + key = "packetEventClickLimiterMilliseconds", + type = DialogInputType.NUMBER_RANGE, + label = "Packet event click limiter milliseconds", + endRange = 1000, + stepRange = 10 ) public static long packetEventClickLimiterMilliseconds = 50L; public static OpGrantMethod opGrantMethod = OpGrantMethod.ATTACHMENT; @ConfigOption( - key = "enableToast", - type = DialogInputType.BOOLEAN, - trueText = "Enabled", - falseText = "Disabled", - label = "Enable toast" + key = "enableToast", + type = DialogInputType.BOOLEAN, + trueText = "Enabled", + falseText = "Disabled", + label = "Enable toast" ) public static boolean enableToast = true; @ConfigOption( - key = "enablePerformanceDebug", - type = DialogInputType.BOOLEAN, - trueText = "Enabled", - falseText = "Disabled", - label = "Enable performance debug" + key = "enablePerformanceDebug", + type = DialogInputType.BOOLEAN, + trueText = "Enabled", + falseText = "Disabled", + label = "Enable performance debug" ) public static boolean enablePerformanceDebug = false; public static List skipCloseActionsOnInventorySwitch = Arrays.asList("inventory", "inv", "back"); - + + public static List allowedDownloadableWebsite = new ArrayList<>(List.of("minecraft-inventory-builder.com")); + public static PerformanceFilterMode performanceFilterMode = PerformanceFilterMode.DISABLED; public static List performanceFilterOperations = new ArrayList<>(); public static long performanceThresholdMs = 10; @@ -426,9 +428,14 @@ public void load(@NotNull FileConfiguration fileConfiguration) { Logger.info("Invalid performance filter mode in config, defaulting to DISABLED."); performanceFilterMode = PerformanceFilterMode.DISABLED; } + + List loadedHosts = fileConfiguration.getStringList(ConfigPath.ALLOWED_DOWNLOADABLE_WEBSITE.getPath()); + if (!loadedHosts.isEmpty()) { + allowedDownloadableWebsite = loadedHosts; + } } - public void save(@NotNull FileConfiguration fileConfiguration,@NotNull File file) { + public void save(@NotNull FileConfiguration fileConfiguration, @NotNull File file) { if (!updated) { return; } @@ -481,6 +488,7 @@ public void save(@NotNull FileConfiguration fileConfiguration,@NotNull File file fileConfiguration.set(ConfigPath.PERFORMANCE_DEBUG_THRESHOLD_MS.getPath(), performanceThresholdMs); fileConfiguration.set(ConfigPath.PERFORMANCE_DEBUG_FILTER_MODE.getPath(), performanceFilterMode.name()); fileConfiguration.set(ConfigPath.PERFORMANCE_DEBUG_FILTER_OPERATIONS.getPath(), performanceFilterOperations); + fileConfiguration.set(ConfigPath.ALLOWED_DOWNLOADABLE_WEBSITE.getPath(), allowedDownloadableWebsite); updated = false; try { fileConfiguration.save(file); @@ -539,7 +547,9 @@ private enum ConfigPath { ENABLE_PERFORMANCE_DEBUG("enable-performance-debug"), PERFORMANCE_DEBUG_THRESHOLD_MS("performance-debug.threshold-ms"), PERFORMANCE_DEBUG_FILTER_MODE("performance-debug.filter.mode"), - PERFORMANCE_DEBUG_FILTER_OPERATIONS("performance-debug.filter.operations"); + PERFORMANCE_DEBUG_FILTER_OPERATIONS("performance-debug.filter.operations"), + + ALLOWED_DOWNLOADABLE_WEBSITE("allowed-downloadable-website"); private final String path; @@ -553,4 +563,4 @@ public String getPath() { return this.path; } } -} +} \ No newline at end of file diff --git a/API/src/main/java/fr/maxlego08/menu/api/utils/Message.java b/API/src/main/java/fr/maxlego08/menu/api/utils/Message.java index 8b594ab9..a21e4323 100644 --- a/API/src/main/java/fr/maxlego08/menu/api/utils/Message.java +++ b/API/src/main/java/fr/maxlego08/menu/api/utils/Message.java @@ -160,6 +160,7 @@ public enum Message implements IMessage { WEBSITE_DOWNLOAD_ERROR_NAME("&cCannot find file name."), WEBSITE_DOWNLOAD_ERROR_CONSOLE("&cAn error has occurred, look at the console."), WEBSITE_DOWNLOAD_START("&7Start downloading inventory, please wait."), + WEBSITE_DOWNLOAD_ERROR_HOST("&cDownload rejected: &7%host% &cis not allowed. &8(&7Allowed: &f%allowed%&8)"), PLACEHOLDER_NEVER("never"), diff --git a/src/main/java/fr/maxlego08/menu/website/ZWebsiteManager.java b/src/main/java/fr/maxlego08/menu/website/ZWebsiteManager.java index 488e6377..1618b8f1 100644 --- a/src/main/java/fr/maxlego08/menu/website/ZWebsiteManager.java +++ b/src/main/java/fr/maxlego08/menu/website/ZWebsiteManager.java @@ -20,10 +20,8 @@ import java.io.File; import java.io.IOException; -import java.net.HttpURLConnection; -import java.net.URI; -import java.net.URISyntaxException; -import java.net.URL; +import java.net.*; +import java.nio.file.Paths; import java.util.ArrayList; import java.util.List; import java.util.Map; @@ -31,8 +29,27 @@ public class ZWebsiteManager extends ZUtils implements WebsiteManager { + /** + * Represents the outcome of a {@link #downloadFromUrl} call. + * Each value maps to a distinct failure reason or success, allowing + * callers to switch on the result and send the appropriate message. + */ + public enum DownloadResult { + /** File was downloaded and saved successfully. */ + SUCCESS, + /** The resolved host is not in {@code Configuration.allowedDownloadableWebsite}. */ + ERROR_HOST_NOT_ALLOWED, + /** The response is not a .yml / YAML file. */ + ERROR_INVALID_FILE_TYPE, + /** The file already exists on disk and {@code force} is false. */ + ERROR_FILE_ALREADY_EXISTS, + /** A generic I/O or network failure occurred. */ + ERROR_IO + } + // private final String API_URL = "http://mib.test/api/v1/"; private final String API_URL = "https://minecraft-inventory-builder.com/api/v1/"; + private final ZMenuPlugin plugin; private final List folders = new ArrayList<>(); private boolean isLogin = false; @@ -378,49 +395,71 @@ private String getFileNameFromUrl(String url) { } } + private String getHostFromUrl(String urlString) { + try { + return new URL(urlString).getHost(); + } catch (MalformedURLException e) { + return urlString; + } + } + @Override public void downloadFromUrl(@NonNull CommandSender sender, @NonNull String baseUrl, boolean force) { message(this.plugin, sender, Message.WEBSITE_DOWNLOAD_START); plugin.getScheduler().runAsync(w -> { + DownloadResult result = performDownload(baseUrl, force, sender); + switch (result) { + case SUCCESS -> {} + case ERROR_HOST_NOT_ALLOWED -> message(this.plugin, sender, Message.WEBSITE_DOWNLOAD_ERROR_HOST, + "%host%", getHostFromUrl(baseUrl), + "%allowed%", String.join(", ", Configuration.allowedDownloadableWebsite)); + case ERROR_IO -> message(this.plugin, sender, Message.WEBSITE_DOWNLOAD_ERROR_CONSOLE); + case ERROR_INVALID_FILE_TYPE -> message(this.plugin, sender, Message.WEBSITE_DOWNLOAD_ERROR_TYPE); + case ERROR_FILE_ALREADY_EXISTS -> message(this.plugin, sender, Message.WEBSITE_INVENTORY_EXIST); + } + }); + } - try { - String finalUrl = followRedirection(baseUrl); + private DownloadResult performDownload(String baseUrl, boolean force, CommandSender sender) { + try { + String finalUrl = followRedirection(baseUrl); - // ToDo, add a configuration for "allowed urls" + if (!isValidHost(finalUrl)) { + return DownloadResult.ERROR_HOST_NOT_ALLOWED; + } - URL url = new URL(finalUrl); - HttpURLConnection httpURLConnection = (HttpURLConnection) url.openConnection(); - String fileName = getFileNameFromContentDisposition(httpURLConnection); + URL url = new URL(finalUrl); + HttpURLConnection httpURLConnection = (HttpURLConnection) url.openConnection(); + String fileName = getFileNameFromContentDisposition(httpURLConnection); - if (fileName == null) { - message(this.plugin, sender, Message.WEBSITE_DOWNLOAD_ERROR_NAME); - return; - } + if (!isYmlFile(httpURLConnection) && !fileName.endsWith(".yml")) { + return DownloadResult.ERROR_INVALID_FILE_TYPE; + } - if (!isYmlFile(httpURLConnection) && !fileName.endsWith(".yml")) { - message(this.plugin, sender, Message.WEBSITE_DOWNLOAD_ERROR_TYPE); - return; - } + File folder = new File(this.plugin.getDataFolder(), "inventories/downloads"); + if (!folder.exists()) folder.mkdirs(); + File file = new File(folder, fileName); - File folder = new File(this.plugin.getDataFolder(), "inventories/downloads"); - if (!folder.exists()) folder.mkdirs(); - File file = new File(folder, fileName); + if (file.exists() && !force) { + return DownloadResult.ERROR_FILE_ALREADY_EXISTS; + } - if (file.exists() && !force) { - message(this.plugin, sender, Message.WEBSITE_INVENTORY_EXIST); - return; - } + HttpRequest request = new HttpRequest(finalUrl, new JsonObject()); + request.setMethod("GET"); + request.submitForFileDownload(this.plugin, file, isSuccess -> message(this.plugin, sender, isSuccess ? Message.WEBSITE_INVENTORY_SUCCESS : Message.WEBSITE_INVENTORY_ERROR, "%name%", fileName)); - HttpRequest request = new HttpRequest(finalUrl, new JsonObject()); - request.setMethod("GET"); + return DownloadResult.SUCCESS; + } catch (IOException exception) { + exception.printStackTrace(); + return DownloadResult.ERROR_IO; + } + } - request.submitForFileDownload(this.plugin, file, isSuccess -> message(this.plugin, sender, isSuccess ? Message.WEBSITE_INVENTORY_SUCCESS : Message.WEBSITE_INVENTORY_ERROR, "%name%", fileName)); - } catch (IOException exception) { - exception.printStackTrace(); - message(this.plugin, sender, Message.WEBSITE_DOWNLOAD_ERROR_CONSOLE); - } - }); + private boolean isValidHost(String urlString) throws IOException { + String host = getHostFromUrl(urlString).toLowerCase(); + return Configuration.allowedDownloadableWebsite.stream() + .anyMatch(host::endsWith); } private String followRedirection(String urlString) throws IOException { @@ -442,12 +481,27 @@ private boolean isYmlFile(HttpURLConnection connection) throws IOException { private String getFileNameFromContentDisposition(HttpURLConnection conn) { String contentDisposition = conn.getHeaderField("Content-Disposition"); + String fileName = null; + if (contentDisposition != null) { int index = contentDisposition.indexOf("filename="); if (index > 0) { - return contentDisposition.substring(index + 9).replaceAll("\"", ""); + fileName = contentDisposition.substring(index + 9) + .replaceAll("\"", "") + .trim(); } } - return generateRandomString(16); + + if (fileName == null || fileName.isBlank()) { + return generateRandomString(16) + ".yml"; + } + + fileName = Paths.get(fileName).getFileName().toString(); + + if (!fileName.matches("[a-zA-Z0-9_\\-]{1,64}\\.yml")) { + return generateRandomString(16) + ".yml"; + } + + return fileName; } -} +} \ No newline at end of file From 53d5baebcc695037610136d1463e67a1f59c95ed Mon Sep 17 00:00:00 2001 From: 1robie <97293924+1robie@users.noreply.github.com> Date: Wed, 8 Apr 2026 22:03:00 +0200 Subject: [PATCH 2/7] refactor: clean up DownloadResult enum by removing unnecessary comments --- .../fr/maxlego08/menu/website/ZWebsiteManager.java | 10 ---------- 1 file changed, 10 deletions(-) diff --git a/src/main/java/fr/maxlego08/menu/website/ZWebsiteManager.java b/src/main/java/fr/maxlego08/menu/website/ZWebsiteManager.java index 1618b8f1..72075d61 100644 --- a/src/main/java/fr/maxlego08/menu/website/ZWebsiteManager.java +++ b/src/main/java/fr/maxlego08/menu/website/ZWebsiteManager.java @@ -29,21 +29,11 @@ public class ZWebsiteManager extends ZUtils implements WebsiteManager { - /** - * Represents the outcome of a {@link #downloadFromUrl} call. - * Each value maps to a distinct failure reason or success, allowing - * callers to switch on the result and send the appropriate message. - */ public enum DownloadResult { - /** File was downloaded and saved successfully. */ SUCCESS, - /** The resolved host is not in {@code Configuration.allowedDownloadableWebsite}. */ ERROR_HOST_NOT_ALLOWED, - /** The response is not a .yml / YAML file. */ ERROR_INVALID_FILE_TYPE, - /** The file already exists on disk and {@code force} is false. */ ERROR_FILE_ALREADY_EXISTS, - /** A generic I/O or network failure occurred. */ ERROR_IO } From e395e547b89a6fbb4f72e1d2682702c6d6281809 Mon Sep 17 00:00:00 2001 From: 1robie <97293924+1robie@users.noreply.github.com> Date: Thu, 9 Apr 2026 18:46:50 +0200 Subject: [PATCH 3/7] feat: improve host validation by handling null cases and enhancing error messaging --- .../maxlego08/menu/website/ZWebsiteManager.java | 17 ++++++++++++----- 1 file changed, 12 insertions(+), 5 deletions(-) diff --git a/src/main/java/fr/maxlego08/menu/website/ZWebsiteManager.java b/src/main/java/fr/maxlego08/menu/website/ZWebsiteManager.java index 72075d61..004e6dcd 100644 --- a/src/main/java/fr/maxlego08/menu/website/ZWebsiteManager.java +++ b/src/main/java/fr/maxlego08/menu/website/ZWebsiteManager.java @@ -389,7 +389,7 @@ private String getHostFromUrl(String urlString) { try { return new URL(urlString).getHost(); } catch (MalformedURLException e) { - return urlString; + return null; } } @@ -401,9 +401,12 @@ public void downloadFromUrl(@NonNull CommandSender sender, @NonNull String baseU DownloadResult result = performDownload(baseUrl, force, sender); switch (result) { case SUCCESS -> {} - case ERROR_HOST_NOT_ALLOWED -> message(this.plugin, sender, Message.WEBSITE_DOWNLOAD_ERROR_HOST, - "%host%", getHostFromUrl(baseUrl), - "%allowed%", String.join(", ", Configuration.allowedDownloadableWebsite)); + case ERROR_HOST_NOT_ALLOWED -> { + String host = getHostFromUrl(baseUrl); + message(this.plugin, sender, Message.WEBSITE_DOWNLOAD_ERROR_HOST, + "%host%", host != null ? host : "", + "%allowed%", String.join(", ", Configuration.allowedDownloadableWebsite)); + } case ERROR_IO -> message(this.plugin, sender, Message.WEBSITE_DOWNLOAD_ERROR_CONSOLE); case ERROR_INVALID_FILE_TYPE -> message(this.plugin, sender, Message.WEBSITE_DOWNLOAD_ERROR_TYPE); case ERROR_FILE_ALREADY_EXISTS -> message(this.plugin, sender, Message.WEBSITE_INVENTORY_EXIST); @@ -447,7 +450,11 @@ private DownloadResult performDownload(String baseUrl, boolean force, CommandSen } private boolean isValidHost(String urlString) throws IOException { - String host = getHostFromUrl(urlString).toLowerCase(); + String host = getHostFromUrl(urlString); + if (host == null || host.isBlank()) { + return false; + } + host = host.toLowerCase(); return Configuration.allowedDownloadableWebsite.stream() .anyMatch(host::endsWith); } From 0ec75a338fe19c36c3785045f524bb0d86a96ab2 Mon Sep 17 00:00:00 2001 From: 1robie <97293924+1robie@users.noreply.github.com> Date: Thu, 9 Apr 2026 18:52:16 +0200 Subject: [PATCH 4/7] feat: support for redirect chain url --- .../menu/website/ZWebsiteManager.java | 39 +++++++++++++++---- 1 file changed, 31 insertions(+), 8 deletions(-) diff --git a/src/main/java/fr/maxlego08/menu/website/ZWebsiteManager.java b/src/main/java/fr/maxlego08/menu/website/ZWebsiteManager.java index 004e6dcd..38f557e8 100644 --- a/src/main/java/fr/maxlego08/menu/website/ZWebsiteManager.java +++ b/src/main/java/fr/maxlego08/menu/website/ZWebsiteManager.java @@ -37,6 +37,12 @@ public enum DownloadResult { ERROR_IO } + private static class DisallowedHostException extends IOException { + public DisallowedHostException(String message) { + super(message); + } + } + // private final String API_URL = "http://mib.test/api/v1/"; private final String API_URL = "https://minecraft-inventory-builder.com/api/v1/"; @@ -418,12 +424,9 @@ private DownloadResult performDownload(String baseUrl, boolean force, CommandSen try { String finalUrl = followRedirection(baseUrl); - if (!isValidHost(finalUrl)) { - return DownloadResult.ERROR_HOST_NOT_ALLOWED; - } - URL url = new URL(finalUrl); HttpURLConnection httpURLConnection = (HttpURLConnection) url.openConnection(); + httpURLConnection.setInstanceFollowRedirects(false); String fileName = getFileNameFromContentDisposition(httpURLConnection); if (!isYmlFile(httpURLConnection) && !fileName.endsWith(".yml")) { @@ -443,6 +446,9 @@ private DownloadResult performDownload(String baseUrl, boolean force, CommandSen request.submitForFileDownload(this.plugin, file, isSuccess -> message(this.plugin, sender, isSuccess ? Message.WEBSITE_INVENTORY_SUCCESS : Message.WEBSITE_INVENTORY_ERROR, "%name%", fileName)); return DownloadResult.SUCCESS; + } catch (DisallowedHostException exception) { + exception.printStackTrace(); + return DownloadResult.ERROR_HOST_NOT_ALLOWED; } catch (IOException exception) { exception.printStackTrace(); return DownloadResult.ERROR_IO; @@ -460,14 +466,31 @@ private boolean isValidHost(String urlString) throws IOException { } private String followRedirection(String urlString) throws IOException { + return resolveRedirectChain(urlString, 0); + } + + private String resolveRedirectChain(String urlString, int hopCount) throws IOException { + if (hopCount > 10) { + throw new IOException("Too many redirects (>10 hops)"); + } + + if (!isValidHost(urlString)) { + throw new DisallowedHostException("Disallowed host in redirect chain"); + } + URL url = new URL(urlString); HttpURLConnection conn = (HttpURLConnection) url.openConnection(); - conn.setInstanceFollowRedirects(true); + conn.setInstanceFollowRedirects(false); int status = conn.getResponseCode(); - if (status == HttpURLConnection.HTTP_MOVED_TEMP || status == HttpURLConnection.HTTP_MOVED_PERM - || status == HttpURLConnection.HTTP_SEE_OTHER) { - return conn.getHeaderField("Location"); + + if (status == HttpURLConnection.HTTP_MOVED_TEMP || status == HttpURLConnection.HTTP_MOVED_PERM || status == HttpURLConnection.HTTP_SEE_OTHER) { + String location = conn.getHeaderField("Location"); + if (location == null || location.isBlank()) { + throw new IOException("Redirect has no Location header"); + } + return resolveRedirectChain(location, hopCount + 1); } + return urlString; } From f30a6f0273875bcc28e51bf437b19899801ee4d3 Mon Sep 17 00:00:00 2001 From: 1robie <97293924+1robie@users.noreply.github.com> Date: Thu, 9 Apr 2026 18:53:54 +0200 Subject: [PATCH 5/7] feat: enhance host validation by ensuring case-insensitive matching for downloadable websites + fix use equal and not endWith validation --- src/main/java/fr/maxlego08/menu/website/ZWebsiteManager.java | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/main/java/fr/maxlego08/menu/website/ZWebsiteManager.java b/src/main/java/fr/maxlego08/menu/website/ZWebsiteManager.java index 38f557e8..2690dd15 100644 --- a/src/main/java/fr/maxlego08/menu/website/ZWebsiteManager.java +++ b/src/main/java/fr/maxlego08/menu/website/ZWebsiteManager.java @@ -460,9 +460,10 @@ private boolean isValidHost(String urlString) throws IOException { if (host == null || host.isBlank()) { return false; } - host = host.toLowerCase(); + final String lowerCaseHost = host.toLowerCase(); return Configuration.allowedDownloadableWebsite.stream() - .anyMatch(host::endsWith); + .map(String::toLowerCase) + .anyMatch(lowerCaseHost::equals); } private String followRedirection(String urlString) throws IOException { From e734c729e27c7942d72dae2d2cec5e360974eeb9 Mon Sep 17 00:00:00 2001 From: 1robie <97293924+1robie@users.noreply.github.com> Date: Thu, 9 Apr 2026 18:56:22 +0200 Subject: [PATCH 6/7] feat: improve file name handling in download process and ensure proper disconnection of HTTP connection --- .../menu/website/ZWebsiteManager.java | 35 +++++++------------ 1 file changed, 13 insertions(+), 22 deletions(-) diff --git a/src/main/java/fr/maxlego08/menu/website/ZWebsiteManager.java b/src/main/java/fr/maxlego08/menu/website/ZWebsiteManager.java index 2690dd15..c2050fc8 100644 --- a/src/main/java/fr/maxlego08/menu/website/ZWebsiteManager.java +++ b/src/main/java/fr/maxlego08/menu/website/ZWebsiteManager.java @@ -20,7 +20,9 @@ import java.io.File; import java.io.IOException; -import java.net.*; +import java.net.HttpURLConnection; +import java.net.MalformedURLException; +import java.net.URL; import java.nio.file.Paths; import java.util.ArrayList; import java.util.List; @@ -374,23 +376,6 @@ public void refreshInventories(Player player) { this.fetchInventories(player); } - private String getFileNameFromUrl(String url) { - URI uri = null; - try { - uri = new URI(url); - } catch (URISyntaxException exception) { - exception.printStackTrace(); - return null; - } - String path = uri.getPath(); - String[] segments = path.split("/"); - if (segments.length > 0) { - return segments[segments.length - 1]; - } else { - return null; - } - } - private String getHostFromUrl(String urlString) { try { return new URL(urlString).getHost(); @@ -424,13 +409,18 @@ private DownloadResult performDownload(String baseUrl, boolean force, CommandSen try { String finalUrl = followRedirection(baseUrl); + String fileName; URL url = new URL(finalUrl); HttpURLConnection httpURLConnection = (HttpURLConnection) url.openConnection(); httpURLConnection.setInstanceFollowRedirects(false); - String fileName = getFileNameFromContentDisposition(httpURLConnection); + try { + fileName = getFileNameFromContentDisposition(httpURLConnection); - if (!isYmlFile(httpURLConnection) && !fileName.endsWith(".yml")) { - return DownloadResult.ERROR_INVALID_FILE_TYPE; + if (!isYmlFile(httpURLConnection) && !fileName.endsWith(".yml")) { + return DownloadResult.ERROR_INVALID_FILE_TYPE; + } + } finally { + httpURLConnection.disconnect(); } File folder = new File(this.plugin.getDataFolder(), "inventories/downloads"); @@ -441,9 +431,10 @@ private DownloadResult performDownload(String baseUrl, boolean force, CommandSen return DownloadResult.ERROR_FILE_ALREADY_EXISTS; } + final String finalFileName = fileName; HttpRequest request = new HttpRequest(finalUrl, new JsonObject()); request.setMethod("GET"); - request.submitForFileDownload(this.plugin, file, isSuccess -> message(this.plugin, sender, isSuccess ? Message.WEBSITE_INVENTORY_SUCCESS : Message.WEBSITE_INVENTORY_ERROR, "%name%", fileName)); + request.submitForFileDownload(this.plugin, file, isSuccess -> message(this.plugin, sender, isSuccess ? Message.WEBSITE_INVENTORY_SUCCESS : Message.WEBSITE_INVENTORY_ERROR, "%name%", finalFileName)); return DownloadResult.SUCCESS; } catch (DisallowedHostException exception) { From de9cc390e81a141f4775bb0e8e58c2ed48b39f3b Mon Sep 17 00:00:00 2001 From: 1robie <97293924+1robie@users.noreply.github.com> Date: Thu, 9 Apr 2026 19:00:09 +0200 Subject: [PATCH 7/7] fix: getName after validate if they was a yml file --- .../menu/website/ZWebsiteManager.java | 21 ++++++++----------- 1 file changed, 9 insertions(+), 12 deletions(-) diff --git a/src/main/java/fr/maxlego08/menu/website/ZWebsiteManager.java b/src/main/java/fr/maxlego08/menu/website/ZWebsiteManager.java index c2050fc8..ef43f1b8 100644 --- a/src/main/java/fr/maxlego08/menu/website/ZWebsiteManager.java +++ b/src/main/java/fr/maxlego08/menu/website/ZWebsiteManager.java @@ -414,11 +414,11 @@ private DownloadResult performDownload(String baseUrl, boolean force, CommandSen HttpURLConnection httpURLConnection = (HttpURLConnection) url.openConnection(); httpURLConnection.setInstanceFollowRedirects(false); try { - fileName = getFileNameFromContentDisposition(httpURLConnection); - - if (!isYmlFile(httpURLConnection) && !fileName.endsWith(".yml")) { + if (!isYmlFile(httpURLConnection)) { return DownloadResult.ERROR_INVALID_FILE_TYPE; } + + fileName = getFileNameFromContentDisposition(httpURLConnection); } finally { httpURLConnection.disconnect(); } @@ -504,16 +504,13 @@ private String getFileNameFromContentDisposition(HttpURLConnection conn) { } } - if (fileName == null || fileName.isBlank()) { - return generateRandomString(16) + ".yml"; - } - - fileName = Paths.get(fileName).getFileName().toString(); - - if (!fileName.matches("[a-zA-Z0-9_\\-]{1,64}\\.yml")) { - return generateRandomString(16) + ".yml"; + if (fileName != null && !fileName.isBlank()) { + fileName = Paths.get(fileName).getFileName().toString(); + if (fileName.matches("[a-zA-Z0-9_\\-]{1,64}\\.yml")) { + return fileName; + } } - return fileName; + return generateRandomString(16) + ".yml"; } } \ No newline at end of file