diff --git a/README.md b/README.md index 6e18b5f..bf1590a 100644 --- a/README.md +++ b/README.md @@ -73,7 +73,7 @@ The SHA-1 response must contain a 40-character hexadecimal SHA-1. Both of these ### Bedrock Edition through GeyserMC - Preferred remote mode: set `bedrockResourcePackUrl` to a direct `.mcpack` or `.zip` download URL. RemotePackSync creates a Geyser `UrlPackCodec` and registers it through the public `SessionLoadResourcePacksEvent` API, so Geyser supplies the URL to connecting Bedrock clients. -- The remote server must return `Content-Type: application/zip` and an exact `Content-Length`. Redirects are allowed only when they ultimately resolve to a valid direct download. The URL should be stable and should expose a changed ETag or URL when the pack changes. +- The remote server must return `Content-Type: application/zip` and an exact `Content-Length`. RemotePackSync verifies those headers (following redirects) before registering the URL; a provider that returns `application/octet-stream` is not compatible with Bedrock URL packs. Redirects are allowed only when they ultimately resolve to a valid direct download. The URL should be stable and should expose a changed ETag or URL when the pack changes. - Geyser validates/downloads remote URL packs during its lifecycle. Use `/remotepacksync reload` after changing the URL or after replacing a pack at the same URL; players already connected must reconnect. - Local alternative: set `bedrockResourcePack` to a `.mcpack` or `.zip` file. The source is checked asynchronously at `refreshInterval`; changes receive a deterministic manifest UUID and are managed under `plugins/RemotePackSync/bedrock-managed/`. - The original configured local pack is never modified. diff --git a/src/main/java/io/github/lf1/remotepacksync/BedrockPackManager.java b/src/main/java/io/github/lf1/remotepacksync/BedrockPackManager.java index 9d49f6d..9a8b4ab 100644 --- a/src/main/java/io/github/lf1/remotepacksync/BedrockPackManager.java +++ b/src/main/java/io/github/lf1/remotepacksync/BedrockPackManager.java @@ -12,6 +12,10 @@ import org.geysermc.geyser.api.pack.option.ResourcePackOption; import java.io.IOException; import java.io.InputStream; import java.io.OutputStream; +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; import java.nio.charset.StandardCharsets; import java.nio.file.AtomicMoveNotSupportedException; import java.nio.file.Files; @@ -33,6 +37,9 @@ import java.util.zip.ZipOutputStream; /** Maintains a managed Bedrock pack and registers its current version for new Geyser sessions. */ public final class BedrockPackManager { private static final int MAX_MANIFEST_BYTES = 1024 * 1024; + private static final HttpClient HTTP_CLIENT = HttpClient.newBuilder() + .followRedirects(HttpClient.Redirect.NORMAL) + .build(); private static final Pattern HEADER_PROPERTY = Pattern.compile("\\\"header\\\"\\s*:\\s*\\{"); private static final Pattern UUID_PROPERTY = Pattern.compile("\\\"uuid\\\"\\s*:\\s*\\\"[^\\\"]*\\\""); @@ -41,6 +48,7 @@ public final class BedrockPackManager { private volatile BedrockPackSettings settings; private BukkitTask refreshTask; + private GeyserEventRegistrar geyserEventRegistrar; private boolean subscribed; public BedrockPackManager(JavaPlugin plugin) { @@ -76,6 +84,16 @@ public final class BedrockPackManager { refreshTask.cancel(); refreshTask = null; } + if (geyserEventRegistrar != null) { + try { + GeyserApi.api().eventBus().unregisterAll(geyserEventRegistrar); + } catch (RuntimeException | LinkageError exception) { + plugin.getLogger().log(Level.FINE, "Could not unregister the Geyser Bedrock resource-pack handler.", exception); + } finally { + geyserEventRegistrar = null; + subscribed = false; + } + } } private void subscribeToGeyser() { @@ -89,11 +107,13 @@ public final class BedrockPackManager { } try { - GeyserEventRegistrar registrar = new GeyserEventRegistrar(); - GeyserApi.api().eventBus().subscribe(registrar, SessionLoadResourcePacksEvent.class, this::registerForSession); + geyserEventRegistrar = new GeyserEventRegistrar(); + GeyserApi.api().eventBus().subscribe( + geyserEventRegistrar, SessionLoadResourcePacksEvent.class, this::registerForSession); subscribed = true; plugin.getLogger().info("Registered Geyser Bedrock resource-pack session handler."); } catch (RuntimeException | LinkageError exception) { + geyserEventRegistrar = null; plugin.getLogger().log(Level.WARNING, "Could not initialize Geyser Bedrock resource-pack support: " + exception.getMessage(), exception); } @@ -120,6 +140,7 @@ public final class BedrockPackManager { try { if (activeSettings.isRemote()) { + validateRemotePackUrl(activeSettings.remoteUrl()); ResourcePack resourcePack = ResourcePack.create(PackCodec.url(activeSettings.remoteUrl())); if (activeSettings != settings) { return; @@ -156,6 +177,39 @@ public final class BedrockPackManager { } } + /** + * Geyser gives URL packs to Bedrock clients as URLs. Unlike Geyser's server-side + * download, the Bedrock client requires a direct response with application/zip + * and a real Content-Length, so catch bad hosting before registering the pack. + */ + private static void validateRemotePackUrl(String url) throws IOException, InterruptedException { + HttpRequest request = HttpRequest.newBuilder(URI.create(url)) + .method("HEAD", HttpRequest.BodyPublishers.noBody()) + .header("Accept", "application/zip") + .build(); + HttpResponse response = HTTP_CLIENT.send(request, HttpResponse.BodyHandlers.discarding()); + if (response.statusCode() < 200 || response.statusCode() >= 300) { + throw new IOException("Remote Bedrock pack returned HTTP " + response.statusCode() + "."); + } + validateRemotePackHeaders( + response.headers().firstValue("Content-Type").orElse(null), + response.headers().firstValue("Content-Length").orElse(null)); + } + + static void validateRemotePackHeaders(String contentType, String contentLength) throws IOException { + if (contentType == null || !contentType.toLowerCase(java.util.Locale.ROOT).startsWith("application/zip")) { + throw new IOException("Remote Bedrock pack must return Content-Type: application/zip, but returned " + + (contentType == null ? "no Content-Type header" : contentType) + "."); + } + try { + if (contentLength == null || Long.parseLong(contentLength) <= 0) { + throw new NumberFormatException(); + } + } catch (NumberFormatException exception) { + throw new IOException("Remote Bedrock pack must return a positive, exact Content-Length header."); + } + } + private Path writeManagedPack(Path source, byte[] sourceSha256) throws IOException { String sourceName = source.getFileName().toString().toLowerCase(java.util.Locale.ROOT); String extension = sourceName.endsWith(".mcpack") ? ".mcpack" : ".zip"; diff --git a/src/main/java/io/github/lf1/remotepacksync/RemotePackSyncPlugin.java b/src/main/java/io/github/lf1/remotepacksync/RemotePackSyncPlugin.java index 4bcf9fd..0c8aaaf 100644 --- a/src/main/java/io/github/lf1/remotepacksync/RemotePackSyncPlugin.java +++ b/src/main/java/io/github/lf1/remotepacksync/RemotePackSyncPlugin.java @@ -108,6 +108,9 @@ public final class RemotePackSyncPlugin extends JavaPlugin implements Listener, return; } if (getServer().getPluginManager().getPlugin("Geyser-Spigot") == null) { + if (bedrockPackManager != null) { + bedrockPackManager.configure(null); + } getLogger().warning("bedrockResourcePack is configured, but Geyser-Spigot is not installed; Bedrock delivery is disabled."); return; } diff --git a/src/test/java/io/github/lf1/remotepacksync/BedrockPackManagerTest.java b/src/test/java/io/github/lf1/remotepacksync/BedrockPackManagerTest.java index 5d2cd65..7597e2d 100644 --- a/src/test/java/io/github/lf1/remotepacksync/BedrockPackManagerTest.java +++ b/src/test/java/io/github/lf1/remotepacksync/BedrockPackManagerTest.java @@ -5,6 +5,7 @@ import org.junit.jupiter.api.Test; import java.io.IOException; import java.util.UUID; +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertThrows; @@ -34,6 +35,22 @@ final class BedrockPackManagerTest { assertEquals(1, occurrences(updated, "module-uuid-must-not-change")); } + @Test + void acceptsBedrockClientCompatibleRemoteHeaders() { + assertDoesNotThrow(() -> BedrockPackManager.validateRemotePackHeaders("application/zip", "10406764")); + assertDoesNotThrow(() -> BedrockPackManager.validateRemotePackHeaders("application/zip; charset=binary", "1")); + } + + @Test + void rejectsRemoteHeadersThatBedrockClientsCannotUse() { + IOException contentType = assertThrows(IOException.class, + () -> BedrockPackManager.validateRemotePackHeaders("application/octet-stream", "10406764")); + assertEquals("Remote Bedrock pack must return Content-Type: application/zip, but returned application/octet-stream.", + contentType.getMessage()); + assertThrows(IOException.class, () -> BedrockPackManager.validateRemotePackHeaders("application/zip", null)); + assertThrows(IOException.class, () -> BedrockPackManager.validateRemotePackHeaders("application/zip", "0")); + } + @Test void rejectsManifestWithoutHeaderUuid() { assertThrows(IOException.class, () -> BedrockPackManager.replaceHeaderUuid(