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
@@ -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