Initial RemotePackSync plugin

This commit is contained in:
root
2026-08-02 06:53:03 +02:00
commit 34db9d28b3
10 changed files with 444 additions and 0 deletions
+7
View File
@@ -0,0 +1,7 @@
target/
.tooling/
.idea/
*.iml
.classpath
.project
.settings/
+21
View File
@@ -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.
+73
View File
@@ -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
+48
View File
@@ -0,0 +1,48 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<groupId>io.github.lf1</groupId>
<artifactId>remotepacksync</artifactId>
<version>1.0.0</version>
<name>RemotePackSync</name>
<description>Synchronizes a server resource-pack SHA-1 from a remote URL.</description>
<properties>
<!-- Minecraft/Spigot 26.2 requires Java 25 at runtime. -->
<maven.compiler.release>25</maven.compiler.release>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
</properties>
<repositories>
<repository>
<id>spigotmc-repo</id>
<url>https://hub.spigotmc.org/nexus/content/repositories/snapshots/</url>
</repository>
</repositories>
<dependencies>
<dependency>
<groupId>org.spigotmc</groupId>
<artifactId>spigot-api</artifactId>
<version>26.2-R0.1-SNAPSHOT</version>
<scope>provided</scope>
</dependency>
</dependencies>
<build>
<finalName>RemotePackSync</finalName>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-compiler-plugin</artifactId>
<version>3.14.1</version>
<configuration>
<release>${maven.compiler.release}</release>
</configuration>
</plugin>
</plugins>
</build>
</project>
@@ -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<PackSettings> 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);
}
}
}
@@ -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<String> 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<PackSettings> settings = PackSettings.from(getConfig());
resourcePackManager.configure(settings);
} catch (IllegalArgumentException exception) {
resourcePackManager.configure(Optional.empty());
getLogger().severe("Invalid config.yml: " + exception.getMessage());
}
}
}
@@ -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)(?<![0-9a-f])[0-9a-f]{40}(?![0-9a-f])");
private static final Duration REQUEST_TIMEOUT = Duration.ofSeconds(15);
private static final int MAX_RESPONSE_BYTES = 64 * 1024;
private final HttpClient client = HttpClient.newBuilder()
.connectTimeout(Duration.ofSeconds(10))
.followRedirects(HttpClient.Redirect.NORMAL)
.build();
public byte[] fetch(URI sha1Url) throws IOException, InterruptedException {
HttpRequest request = HttpRequest.newBuilder(sha1Url)
.GET()
.timeout(REQUEST_TIMEOUT)
.header("Accept", "text/plain, text/*;q=0.9, */*;q=0.1")
.header("User-Agent", "RemotePackSync/1.0")
.build();
HttpResponse<byte[]> 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());
}
}
@@ -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<byte[]> 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<PackSettings> 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());
}
}
}
+10
View File
@@ -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 '<hash> filename.zip' checksum files are supported.
sha1Url: ""
# Seconds between remote SHA-1 checks. Minimum: 30 seconds.
refreshInterval: 300
+15
View File
@@ -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