From 34db9d28b37fa2f12e41d3b7f2fe7061f453b512 Mon Sep 17 00:00:00 2001 From: root Date: Sun, 2 Aug 2026 06:52:49 +0200 Subject: [PATCH] Initial RemotePackSync plugin --- .gitignore | 7 ++ LICENSE | 21 +++++ README.md | 73 +++++++++++++++ pom.xml | 48 ++++++++++ .../lf1/remotepacksync/PackSettings.java | 48 ++++++++++ .../remotepacksync/RemotePackSyncPlugin.java | 83 +++++++++++++++++ .../lf1/remotepacksync/RemoteSha1Fetcher.java | 47 ++++++++++ .../remotepacksync/ResourcePackManager.java | 92 +++++++++++++++++++ src/main/resources/config.yml | 10 ++ src/main/resources/plugin.yml | 15 +++ 10 files changed, 444 insertions(+) create mode 100644 .gitignore create mode 100644 LICENSE create mode 100644 README.md create mode 100644 pom.xml create mode 100644 src/main/java/io/github/lf1/remotepacksync/PackSettings.java create mode 100644 src/main/java/io/github/lf1/remotepacksync/RemotePackSyncPlugin.java create mode 100644 src/main/java/io/github/lf1/remotepacksync/RemoteSha1Fetcher.java create mode 100644 src/main/java/io/github/lf1/remotepacksync/ResourcePackManager.java create mode 100644 src/main/resources/config.yml create mode 100644 src/main/resources/plugin.yml diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..0de08cc --- /dev/null +++ b/.gitignore @@ -0,0 +1,7 @@ +target/ +.tooling/ +.idea/ +*.iml +.classpath +.project +.settings/ diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..ce94539 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 LF1 + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..285dcd1 --- /dev/null +++ b/README.md @@ -0,0 +1,73 @@ +# RemotePackSync + +RemotePackSync is a lightweight **Spigot 26.2** plugin that keeps a server resource pack synchronized with a remotely hosted SHA-1 checksum. It uses only the public Bukkit/Spigot API, so it runs unchanged on **Spigot, Paper, Purpur, and Bukkit-compatible servers**. + +No NMS, packet libraries, or fork-specific APIs are used. + +## Requirements + +- Java 25 (Minecraft/Spigot 26.2 requires Java 25) +- A Minecraft/Spigot 26.2-compatible server + +## Build + +```bash +mvn clean package +``` + +The ready-to-install plugin is written to: + +```text +target/RemotePackSync.jar +``` + +## Installation + +1. Copy `RemotePackSync.jar` into the server's `plugins/` directory. +2. Start the server once. RemotePackSync writes `plugins/RemotePackSync/config.yml` automatically. +3. Configure the resource-pack and SHA-1 URLs. +4. Restart the server, or run `/remotepacksync reload` as an operator. + +## Configuration + +```yaml +# Direct URL to the .zip resource pack. Leave blank to disable pack delivery. +resourcePackUrl: "https://cdn.example.com/server-pack.zip" + +# URL to a text file containing the pack's SHA-1 checksum. +sha1Url: "https://cdn.example.com/server-pack.zip.sha1" + +# Seconds between SHA-1 checks. Minimum: 30. +refreshInterval: 300 +``` + +The SHA-1 response must contain a 40-character hexadecimal SHA-1. Both of these standard formats work: + +```text +0123456789abcdef0123456789abcdef01234567 +``` + +```text +0123456789abcdef0123456789abcdef01234567 server-pack.zip +``` + +## Behaviour + +- On startup and at every refresh interval, RemotePackSync fetches only the SHA-1 file asynchronously. +- When the SHA-1 changes, it sends the configured resource-pack URL and new hash to every online player. +- Players joining after a successful SHA-1 fetch receive the current resource pack. +- A failed request leaves the last known-good SHA-1 active, so a brief CDN outage does not interrupt existing pack delivery. +- If both URLs are blank, delivery is disabled without error. If only one is set, the configuration is rejected and delivery remains disabled. + +## Commands and permissions + +- `/remotepacksync reload` (alias: `/rps reload`) reloads `config.yml` and begins a fresh asynchronous SHA-1 refresh. +- Permission: `remotepacksync.reload` (default: `op`). + +## Compatibility + +RemotePackSync compiles against `org.spigotmc:spigot-api:26.2-R0.1-SNAPSHOT` and targets Java 25, which is required by Minecraft/Spigot 26.2. Resource packs are sent through Bukkit's stable `Player#setResourcePack(String, byte[])` API. + +## License + +MIT diff --git a/pom.xml b/pom.xml new file mode 100644 index 0000000..7200870 --- /dev/null +++ b/pom.xml @@ -0,0 +1,48 @@ + + + 4.0.0 + + io.github.lf1 + remotepacksync + 1.0.0 + RemotePackSync + Synchronizes a server resource-pack SHA-1 from a remote URL. + + + + 25 + UTF-8 + + + + + spigotmc-repo + https://hub.spigotmc.org/nexus/content/repositories/snapshots/ + + + + + + org.spigotmc + spigot-api + 26.2-R0.1-SNAPSHOT + provided + + + + + RemotePackSync + + + org.apache.maven.plugins + maven-compiler-plugin + 3.14.1 + + ${maven.compiler.release} + + + + + diff --git a/src/main/java/io/github/lf1/remotepacksync/PackSettings.java b/src/main/java/io/github/lf1/remotepacksync/PackSettings.java new file mode 100644 index 0000000..6c158db --- /dev/null +++ b/src/main/java/io/github/lf1/remotepacksync/PackSettings.java @@ -0,0 +1,48 @@ +package io.github.lf1.remotepacksync; + +import org.bukkit.configuration.file.FileConfiguration; + +import java.net.URI; +import java.net.URISyntaxException; +import java.util.Optional; + +/** Immutable, validated configuration for resource-pack delivery. */ +public record PackSettings(URI resourcePackUrl, URI sha1Url, long refreshIntervalTicks) { + private static final long MINIMUM_REFRESH_SECONDS = 30L; + + public static Optional from(FileConfiguration config) throws IllegalArgumentException { + String resourcePackUrl = config.getString("resourcePackUrl", "").trim(); + String sha1Url = config.getString("sha1Url", "").trim(); + + if (resourcePackUrl.isEmpty() && sha1Url.isEmpty()) { + return Optional.empty(); + } + if (resourcePackUrl.isEmpty() || sha1Url.isEmpty()) { + throw new IllegalArgumentException("resourcePackUrl and sha1Url must both be configured, or both left blank."); + } + + long refreshSeconds = config.getLong("refreshInterval", 300L); + if (refreshSeconds < MINIMUM_REFRESH_SECONDS) { + throw new IllegalArgumentException("refreshInterval must be at least " + MINIMUM_REFRESH_SECONDS + " seconds."); + } + + return Optional.of(new PackSettings( + parseHttpUrl("resourcePackUrl", resourcePackUrl), + parseHttpUrl("sha1Url", sha1Url), + Math.multiplyExact(refreshSeconds, 20L) + )); + } + + private static URI parseHttpUrl(String key, String value) { + try { + URI uri = new URI(value); + if (!uri.isAbsolute() || uri.getHost() == null + || !("http".equalsIgnoreCase(uri.getScheme()) || "https".equalsIgnoreCase(uri.getScheme()))) { + throw new IllegalArgumentException(key + " must be an absolute HTTP or HTTPS URL."); + } + return uri; + } catch (URISyntaxException exception) { + throw new IllegalArgumentException(key + " is not a valid URL.", exception); + } + } +} diff --git a/src/main/java/io/github/lf1/remotepacksync/RemotePackSyncPlugin.java b/src/main/java/io/github/lf1/remotepacksync/RemotePackSyncPlugin.java new file mode 100644 index 0000000..c4fe68b --- /dev/null +++ b/src/main/java/io/github/lf1/remotepacksync/RemotePackSyncPlugin.java @@ -0,0 +1,83 @@ +package io.github.lf1.remotepacksync; + +import org.bukkit.command.Command; +import org.bukkit.command.CommandExecutor; +import org.bukkit.command.CommandSender; +import org.bukkit.command.PluginCommand; +import org.bukkit.command.TabCompleter; +import org.bukkit.entity.Player; +import org.bukkit.event.EventHandler; +import org.bukkit.event.Listener; +import org.bukkit.event.player.PlayerJoinEvent; +import org.bukkit.plugin.java.JavaPlugin; + +import java.util.Collections; +import java.util.List; +import java.util.Optional; + +/** Entry point for RemotePackSync. Uses only stable Bukkit/Spigot APIs. */ +public final class RemotePackSyncPlugin extends JavaPlugin implements Listener, CommandExecutor, TabCompleter { + private ResourcePackManager resourcePackManager; + + @Override + public void onEnable() { + saveDefaultConfig(); + resourcePackManager = new ResourcePackManager(this); + getServer().getPluginManager().registerEvents(this, this); + + PluginCommand command = getCommand("remotepacksync"); + if (command != null) { + command.setExecutor(this); + command.setTabCompleter(this); + } + reloadPackConfiguration(); + } + + @Override + public void onDisable() { + if (resourcePackManager != null) { + resourcePackManager.stop(); + } + } + + @EventHandler + public void onPlayerJoin(PlayerJoinEvent event) { + // Give the initial asynchronous checksum refresh a chance to complete first. + getServer().getScheduler().runTaskLater(this, () -> resourcePackManager.sendTo(event.getPlayer()), 1L); + } + + @Override + public boolean onCommand(CommandSender sender, Command command, String label, String[] args) { + if (args.length != 1 || !args[0].equalsIgnoreCase("reload")) { + sender.sendMessage("§eUsage: /" + label + " reload"); + return true; + } + if (!sender.hasPermission("remotepacksync.reload")) { + sender.sendMessage("§cYou do not have permission to reload RemotePackSync."); + return true; + } + + reloadPackConfiguration(); + sender.sendMessage("§aRemotePackSync configuration reloaded."); + return true; + } + + @Override + public List onTabComplete(CommandSender sender, Command command, String alias, String[] args) { + if (args.length == 1 && "reload".startsWith(args[0].toLowerCase())) { + return List.of("reload"); + } + return Collections.emptyList(); + } + + private void reloadPackConfiguration() { + reloadConfig(); + try { + Optional settings = PackSettings.from(getConfig()); + resourcePackManager.configure(settings); + } catch (IllegalArgumentException exception) { + resourcePackManager.configure(Optional.empty()); + getLogger().severe("Invalid config.yml: " + exception.getMessage()); + } + } +} diff --git a/src/main/java/io/github/lf1/remotepacksync/RemoteSha1Fetcher.java b/src/main/java/io/github/lf1/remotepacksync/RemoteSha1Fetcher.java new file mode 100644 index 0000000..ac216db --- /dev/null +++ b/src/main/java/io/github/lf1/remotepacksync/RemoteSha1Fetcher.java @@ -0,0 +1,47 @@ +package io.github.lf1.remotepacksync; + +import java.io.IOException; +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.time.Duration; +import java.util.HexFormat; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +/** Downloads and validates a SHA-1 checksum without downloading the resource pack itself. */ +public final class RemoteSha1Fetcher { + private static final Pattern SHA1_PATTERN = Pattern.compile("(?i)(? response = client.send(request, HttpResponse.BodyHandlers.ofByteArray()); + if (response.statusCode() < 200 || response.statusCode() >= 300) { + throw new IOException("SHA-1 endpoint returned HTTP " + response.statusCode()); + } + if (response.body().length > MAX_RESPONSE_BYTES) { + throw new IOException("SHA-1 response exceeds " + MAX_RESPONSE_BYTES + " bytes"); + } + + String responseText = new String(response.body(), java.nio.charset.StandardCharsets.UTF_8); + Matcher matcher = SHA1_PATTERN.matcher(responseText); + if (!matcher.find()) { + throw new IOException("SHA-1 response does not contain a 40-character hexadecimal SHA-1"); + } + return HexFormat.of().parseHex(matcher.group()); + } +} diff --git a/src/main/java/io/github/lf1/remotepacksync/ResourcePackManager.java b/src/main/java/io/github/lf1/remotepacksync/ResourcePackManager.java new file mode 100644 index 0000000..39a78fd --- /dev/null +++ b/src/main/java/io/github/lf1/remotepacksync/ResourcePackManager.java @@ -0,0 +1,92 @@ +package io.github.lf1.remotepacksync; + +import org.bukkit.Bukkit; +import org.bukkit.entity.Player; +import org.bukkit.plugin.java.JavaPlugin; +import org.bukkit.scheduler.BukkitTask; + +import java.util.Arrays; +import java.util.Optional; +import java.util.concurrent.atomic.AtomicReference; +import java.util.logging.Level; + +/** Fetches the remote checksum on an async scheduler and delivers it through the Bukkit API. */ +public final class ResourcePackManager { + private final JavaPlugin plugin; + private final RemoteSha1Fetcher fetcher; + private final AtomicReference currentSha1 = new AtomicReference<>(); + + private volatile PackSettings settings; + private BukkitTask refreshTask; + + public ResourcePackManager(JavaPlugin plugin) { + this.plugin = plugin; + this.fetcher = new RemoteSha1Fetcher(); + } + + public void configure(Optional newSettings) { + stop(); + currentSha1.set(null); + settings = newSettings.orElse(null); + + if (settings == null) { + plugin.getLogger().info("Resource-pack delivery is disabled: resourcePackUrl and sha1Url are blank."); + return; + } + + refreshTask = Bukkit.getScheduler().runTaskTimerAsynchronously( + plugin, this::refreshChecksum, 0L, settings.refreshIntervalTicks()); + plugin.getLogger().info("Remote SHA-1 refresh enabled every " + (settings.refreshIntervalTicks() / 20L) + " seconds."); + } + + public void stop() { + if (refreshTask != null) { + refreshTask.cancel(); + refreshTask = null; + } + } + + public void sendTo(Player player) { + PackSettings activeSettings = settings; + byte[] activeSha1 = currentSha1.get(); + if (activeSettings == null || activeSha1 == null) { + return; + } + player.setResourcePack(activeSettings.resourcePackUrl().toString(), Arrays.copyOf(activeSha1, activeSha1.length)); + } + + private void refreshChecksum() { + PackSettings activeSettings = settings; + if (activeSettings == null) { + return; + } + + try { + byte[] downloadedSha1 = fetcher.fetch(activeSettings.sha1Url()); + // A reload may have replaced the settings while this HTTP request was in flight. + if (activeSettings != settings) { + return; + } + byte[] previousSha1 = currentSha1.getAndSet(downloadedSha1); + if (Arrays.equals(previousSha1, downloadedSha1)) { + return; + } + + Bukkit.getScheduler().runTask(plugin, () -> { + if (activeSettings != settings) { + return; + } + for (Player player : Bukkit.getOnlinePlayers()) { + sendTo(player); + } + plugin.getLogger().info("Resource-pack SHA-1 refreshed; sent the pack to " + + Bukkit.getOnlinePlayers().size() + " online player(s)."); + }); + } catch (InterruptedException exception) { + Thread.currentThread().interrupt(); + } catch (Exception exception) { + plugin.getLogger().log(Level.WARNING, "Could not refresh remote resource-pack SHA-1: " + + exception.getMessage()); + } + } +} diff --git a/src/main/resources/config.yml b/src/main/resources/config.yml new file mode 100644 index 0000000..979920d --- /dev/null +++ b/src/main/resources/config.yml @@ -0,0 +1,10 @@ +# Direct URL to the .zip resource pack. Leave blank to disable pack delivery. +resourcePackUrl: "" + +# Direct URL to a text file containing the SHA-1 of the resource pack. +# RemotePackSync accepts a 40-character hexadecimal SHA-1 anywhere in the response, +# so standard ' filename.zip' checksum files are supported. +sha1Url: "" + +# Seconds between remote SHA-1 checks. Minimum: 30 seconds. +refreshInterval: 300 diff --git a/src/main/resources/plugin.yml b/src/main/resources/plugin.yml new file mode 100644 index 0000000..117d866 --- /dev/null +++ b/src/main/resources/plugin.yml @@ -0,0 +1,15 @@ +name: RemotePackSync +version: '1.0.0' +main: io.github.lf1.remotepacksync.RemotePackSyncPlugin +api-version: '26.2' +description: Synchronizes a server resource-pack SHA-1 from a remote URL. +author: LF1 +commands: + remotepacksync: + description: Reload RemotePackSync configuration and SHA-1 data. + usage: /remotepacksync reload + aliases: [rps] +permissions: + remotepacksync.reload: + description: Allows reloading RemotePackSync. + default: op