Initial RemotePackSync plugin
This commit is contained in:
@@ -0,0 +1,7 @@
|
||||
target/
|
||||
.tooling/
|
||||
.idea/
|
||||
*.iml
|
||||
.classpath
|
||||
.project
|
||||
.settings/
|
||||
@@ -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.
|
||||
@@ -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
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user