diff --git a/api/src/main/java/org/geysermc/geyser/api/pack/PackCodec.java b/api/src/main/java/org/geysermc/geyser/api/pack/PackCodec.java index 7c123b624..ba062535d 100644 --- a/api/src/main/java/org/geysermc/geyser/api/pack/PackCodec.java +++ b/api/src/main/java/org/geysermc/geyser/api/pack/PackCodec.java @@ -68,7 +68,7 @@ public abstract class PackCodec { * @return the new resource pack */ @NonNull - protected abstract ResourcePack create(); + public abstract ResourcePack create(); /** * Creates a new pack provider from the given path. diff --git a/api/src/main/java/org/geysermc/geyser/api/pack/UrlPackCodec.java b/api/src/main/java/org/geysermc/geyser/api/pack/UrlPackCodec.java index ee449aa7f..8f279ae0d 100644 --- a/api/src/main/java/org/geysermc/geyser/api/pack/UrlPackCodec.java +++ b/api/src/main/java/org/geysermc/geyser/api/pack/UrlPackCodec.java @@ -36,8 +36,6 @@ import org.checkerframework.checker.nullness.qual.NonNull; *
  • be a direct download link to a .zip or .mcpack resource pack
  • *
  • use the application type `application/zip` and set a correct content length
  • * - * - * Additionally, the resource pack must be zipped in a folder enclosing the resource pack, instead of the resource pack being at the root of the zip. */ public abstract class UrlPackCodec extends PackCodec { diff --git a/core/src/main/java/org/geysermc/geyser/GeyserImpl.java b/core/src/main/java/org/geysermc/geyser/GeyserImpl.java index cc31de9c5..0d47560b8 100644 --- a/core/src/main/java/org/geysermc/geyser/GeyserImpl.java +++ b/core/src/main/java/org/geysermc/geyser/GeyserImpl.java @@ -650,8 +650,6 @@ public class GeyserImpl implements GeyserApi { this.erosionUnixListener.close(); } - // todo check - //Registries.RESOURCE_PACKS.get().clear(); ResourcePackLoader.clear(); this.setEnabled(false); diff --git a/core/src/main/java/org/geysermc/geyser/network/UpstreamPacketHandler.java b/core/src/main/java/org/geysermc/geyser/network/UpstreamPacketHandler.java index 5061a7ef2..1f1d69136 100644 --- a/core/src/main/java/org/geysermc/geyser/network/UpstreamPacketHandler.java +++ b/core/src/main/java/org/geysermc/geyser/network/UpstreamPacketHandler.java @@ -319,7 +319,7 @@ public class UpstreamPacketHandler extends LoggingPacketHandler { // If a remote pack ends up here, that usually implies that a platform was not able to download the pack if (codec instanceof UrlPackCodec urlPackCodec) { - ResourcePackLoader.checkPack(urlPackCodec); + ResourcePackLoader.testUrlPack(urlPackCodec); } data.setChunkIndex(packet.getChunkIndex()); diff --git a/core/src/main/java/org/geysermc/geyser/pack/url/GeyserUrlPackCodec.java b/core/src/main/java/org/geysermc/geyser/pack/url/GeyserUrlPackCodec.java index 0b0ce32e3..2c561fe1a 100644 --- a/core/src/main/java/org/geysermc/geyser/pack/url/GeyserUrlPackCodec.java +++ b/core/src/main/java/org/geysermc/geyser/pack/url/GeyserUrlPackCodec.java @@ -28,56 +28,46 @@ package org.geysermc.geyser.pack.url; import lombok.Getter; import org.checkerframework.checker.nullness.qual.NonNull; import org.checkerframework.checker.nullness.qual.Nullable; -import org.geysermc.geyser.GeyserImpl; +import org.geysermc.geyser.api.pack.PathPackCodec; import org.geysermc.geyser.api.pack.ResourcePack; import org.geysermc.geyser.api.pack.UrlPackCodec; -import org.geysermc.geyser.pack.path.GeyserPathPackCodec; import org.geysermc.geyser.registry.loader.ResourcePackLoader; import java.io.IOException; import java.nio.channels.SeekableByteChannel; -import java.nio.file.Path; +import java.util.Objects; public class GeyserUrlPackCodec extends UrlPackCodec { - private final String url; - private final String contentKey; + private final @NonNull String url; + private final @Nullable String contentKey; @Getter - private GeyserPathPackCodec fallback; + private PathPackCodec fallback; public GeyserUrlPackCodec(String url) throws IllegalArgumentException { - this(url, ""); + this(url, null); } public GeyserUrlPackCodec(@NonNull String url, @Nullable String contentKey) throws IllegalArgumentException { - //noinspection ConstantValue - need to enforce - if (url == null) { - throw new IllegalArgumentException("Url cannot be nulL!"); - } + Objects.requireNonNull(url, "url cannot be null"); this.url = url; - this.contentKey = contentKey == null ? "" : contentKey; + this.contentKey = contentKey; } @Override public byte @NonNull [] sha256() { - if (this.fallback == null) { - throw new IllegalStateException("Fallback pack not initialized! Needs to be created first."); - } + Objects.requireNonNull(fallback, "must call #create() before attempting to get the sha256!"); return fallback.sha256(); } @Override public long size() { - if (this.fallback == null) { - throw new IllegalStateException("Fallback pack not initialized! Needs to be created first."); - } + Objects.requireNonNull(fallback, "must call #create() before attempting to get the size!"); return fallback.size(); } @Override public @NonNull SeekableByteChannel serialize(@NonNull ResourcePack resourcePack) throws IOException { - if (this.fallback == null) { - throw new IllegalStateException("Fallback pack not initialized! Needs to be created first."); - } + Objects.requireNonNull(fallback, "must call #create() before attempting to serialize!!"); return fallback.serialize(resourcePack); } @@ -86,17 +76,15 @@ public class GeyserUrlPackCodec extends UrlPackCodec { public ResourcePack create() { if (this.fallback == null) { try { - final Path downloadedPack = ResourcePackLoader.downloadPack(url, false).whenComplete((pack, throwable) -> { + ResourcePackLoader.downloadPack(url, false).whenComplete((pack, throwable) -> { if (throwable != null) { - GeyserImpl.getInstance().getLogger().error("Failed to download pack from " + url + " due to " + throwable.getMessage()); - if (GeyserImpl.getInstance().getConfig().isDebugMode()) { - GeyserImpl.getInstance().getLogger().error("full error: " + throwable); - } + throw new IllegalArgumentException(throwable); + } else if (pack != null) { + this.fallback = pack; } - }).join(); - this.fallback = new GeyserPathPackCodec(downloadedPack); + }); } catch (Exception e) { - throw new IllegalArgumentException("Failed to download pack from " + url, e); + throw new IllegalArgumentException("Failed to download pack from the url %s (reason: %s)!".formatted(url, e.getMessage())); } } return ResourcePackLoader.readPack(this); @@ -109,6 +97,6 @@ public class GeyserUrlPackCodec extends UrlPackCodec { @Override public @NonNull String contentKey() { - return this.contentKey; + return this.contentKey != null ? contentKey : ""; } } diff --git a/core/src/main/java/org/geysermc/geyser/registry/loader/ResourcePackLoader.java b/core/src/main/java/org/geysermc/geyser/registry/loader/ResourcePackLoader.java index 97ed256f9..2e8fe49cf 100644 --- a/core/src/main/java/org/geysermc/geyser/registry/loader/ResourcePackLoader.java +++ b/core/src/main/java/org/geysermc/geyser/registry/loader/ResourcePackLoader.java @@ -25,10 +25,13 @@ package org.geysermc.geyser.registry.loader; +import com.google.common.cache.Cache; +import com.google.common.cache.CacheBuilder; import it.unimi.dsi.fastutil.objects.Object2ObjectOpenHashMap; import org.checkerframework.checker.nullness.qual.Nullable; import org.geysermc.geyser.GeyserImpl; import org.geysermc.geyser.api.event.lifecycle.GeyserLoadResourcePacksEvent; +import org.geysermc.geyser.api.pack.PathPackCodec; import org.geysermc.geyser.api.pack.ResourcePack; import org.geysermc.geyser.api.pack.ResourcePackManifest; import org.geysermc.geyser.api.pack.UrlPackCodec; @@ -49,12 +52,9 @@ import java.nio.file.FileSystems; import java.nio.file.Files; import java.nio.file.Path; import java.nio.file.PathMatcher; -import java.util.ArrayList; -import java.util.HashSet; -import java.util.List; -import java.util.Map; -import java.util.Set; +import java.util.*; import java.util.concurrent.CompletableFuture; +import java.util.concurrent.TimeUnit; import java.util.concurrent.atomic.AtomicReference; import java.util.stream.Collectors; import java.util.stream.Stream; @@ -70,7 +70,9 @@ public class ResourcePackLoader implements RegistryLoader brokenPacks = new HashSet<>(); + private static final Cache CACHED_FAILED_PACKS = CacheBuilder.newBuilder() + .expireAfterWrite(1, TimeUnit.HOURS) + .build(); static final PathMatcher PACK_MATCHER = FileSystems.getDefault().getPathMatcher("glob:**.{zip,mcpack}"); @@ -169,9 +171,6 @@ public class ResourcePackLoader implements RegistryLoader loadRemotePacks() { + // Unable to make this a static variable, as the test would fail final Path cachedCdnPacksDirectory = GeyserImpl.getInstance().getBootstrap().getConfigFolder().resolve("cache").resolve("remote_packs"); - // Download CDN packs to get the pack uuid's if (!Files.exists(cachedCdnPacksDirectory)) { try { Files.createDirectories(cachedCdnPacksDirectory); @@ -231,7 +230,7 @@ public class ResourcePackLoader implements RegistryLoader remotePackUrls = GeyserImpl.getInstance().getConfig().getResourcePackUrls(); Map packMap = new Object2ObjectOpenHashMap<>(); - for (String url: remotePackUrls) { + for (String url : remotePackUrls) { try { GeyserUrlPackCodec codec = new GeyserUrlPackCodec(url); ResourcePack pack = codec.create(); @@ -246,57 +245,65 @@ public class ResourcePackLoader implements RegistryLoader downloadPack(String url, boolean checking) throws IllegalArgumentException { - return WebUtils.checkUrlAndDownloadRemotePack(url, checking).whenCompleteAsync((cachedPath, throwable) -> { - if (cachedPath == null) { - // already warned about in WebUtils - return; - } + public static CompletableFuture<@Nullable PathPackCodec> downloadPack(String url, boolean testing) throws IllegalArgumentException { + return CompletableFuture.supplyAsync(() -> { + Path path = WebUtils.checkUrlAndDownloadRemotePack(url, testing); - if (throwable != null) { - GeyserImpl.getInstance().getLogger().error("Failed to download resource pack! ", throwable); - return; + // Already warned about these above + if (path == null) { + return null; } // Check if the pack is a .zip or .mcpack file - if (!PACK_MATCHER.matches(cachedPath)) { + if (!PACK_MATCHER.matches(path)) { throw new IllegalArgumentException("Invalid pack format! Not a .zip or .mcpack file."); } - if (checking) { - try { - Files.delete(cachedPath); - } catch (IOException e) { - throw new IllegalArgumentException("Could not delete debug pack! " + e.getMessage(), e); - } - } - try { - try (ZipFile zip = new ZipFile(cachedPath.toFile())) { + try (ZipFile zip = new ZipFile(path.toFile())) { if (zip.stream().noneMatch(x -> x.getName().contains("manifest.json"))) { - throw new IllegalArgumentException(url + " does not contain a manifest file."); + throw new IllegalArgumentException("The pack at the url " + url + " does not contain a manifest file!"); } -// // Check if a "manifest.json" or "pack_manifest.json" file is located directly in the zip... does not work otherwise. -// // (something like MyZip.zip/manifest.json) will not, but will if it's a subfolder (MyPack.zip/MyPack/manifest.json) -// if (zip.getEntry("manifest.json") != null || zip.getEntry("pack_manifest.json") != null) { -// GeyserImpl.getInstance().getLogger().debug("The remote resource pack from " + url + " contains a manifest.json file at the root of the zip file. " + -// "This is not supported for remote packs, and will cause Bedrock clients to fall back to request the pack from the server. " + -// "Please put the pack file in a subfolder, and provide that zip in the URL."); -// } + // Check if a "manifest.json" or "pack_manifest.json" file is located directly in the zip... does not work otherwise. + // (something like MyZip.zip/manifest.json) will not, but will if it's a subfolder (MyPack.zip/MyPack/manifest.json) + if (zip.getEntry("manifest.json") != null || zip.getEntry("pack_manifest.json") != null) { + if (testing) { + GeyserImpl.getInstance().getLogger().info("The remote resource pack from " + url + " contains a manifest.json file at the root of the zip file. " + + "This may not work for remote packs, and could cause Bedrock clients to fall back to request the pack from the server. " + + "Please put the pack file in a subfolder, and provide that zip in the URL."); + } + } } } catch (IOException e) { throw new IllegalArgumentException(GeyserLocale.getLocaleStringLog("geyser.resource_pack.broken", url), e); } + + if (testing) { + try { + Files.delete(path); + return null; + } catch (IOException e) { + throw new IllegalStateException("Could not delete debug pack! " + e.getMessage(), e); + } + } + + return new GeyserPathPackCodec(path); }); } @@ -305,7 +312,7 @@ public class ResourcePackLoader implements RegistryLoader { + for (UrlPackCodec codec : CACHED_FAILED_PACKS.asMap().values()) { int hash = codec.url().hashCode(); Path packLocation = location.resolve(hash + ".zip"); Path packMetadata = packLocation.resolveSibling(hash + ".metadata"); @@ -320,6 +327,6 @@ public class ResourcePackLoader implements RegistryLoader checkUrlAndDownloadRemotePack(String url, boolean force) { + public static @Nullable Path checkUrlAndDownloadRemotePack(String url, boolean force) { GeyserLogger logger = GeyserImpl.getInstance().getLogger(); - return CompletableFuture.supplyAsync(() -> { - try { - HttpURLConnection con = (HttpURLConnection) new URL(url).openConnection(); + try { + HttpURLConnection con = (HttpURLConnection) new URL(url).openConnection(); - con.setConnectTimeout(10000); - con.setReadTimeout(10000); - con.setRequestProperty("User-Agent", "Geyser-" + GeyserImpl.getInstance().getPlatformType().platformName() + "/" + GeyserImpl.VERSION); - con.setInstanceFollowRedirects(true); + con.setConnectTimeout(10000); + con.setReadTimeout(10000); + con.setRequestProperty("User-Agent", "Geyser-" + GeyserImpl.getInstance().getPlatformType().platformName() + "/" + GeyserImpl.VERSION); + con.setInstanceFollowRedirects(true); - int responseCode = con.getResponseCode(); - if (responseCode >= 400) { - logger.error(String.format("Invalid response code from remote pack URL: %s (code: %d)", url, responseCode)); - return null; - } - - int size = con.getContentLength(); - String type = con.getContentType(); - - if (size <= 0) { - logger.error(String.format("Invalid size from remote pack URL: %s (size: %d)", url, size)); - return null; - } - - // This doesn't seem to be a requirement (anymore?). Logging to debug might be interesting though. - if (type == null || !type.equals("application/zip")) { - logger.debug(String.format("Application type from remote pack URL: %s (type: %s)", url, type)); - } - - Path packLocation = REMOTE_PACK_CACHE.resolve(url.hashCode() + ".zip"); - Path packMetadata = packLocation.resolveSibling(url.hashCode() + ".metadata"); - - if (Files.exists(packLocation) && Files.exists(packMetadata) && !force) { - try { - List metadataLines = Files.readAllLines(packMetadata, StandardCharsets.UTF_8); - int cachedSize = Integer.parseInt(metadataLines.get(0)); - String cachedEtag = metadataLines.get(1); - long cachedLastModified = Long.parseLong(metadataLines.get(2)); - - if (cachedSize == size && cachedEtag.equals(con.getHeaderField("ETag")) && cachedLastModified == con.getLastModified()) { - logger.debug("Using cached pack for " + url); - return packLocation; - } - } catch (IOException e) { - GeyserImpl.getInstance().getLogger().error("Failed to read cached pack metadata: " + e.getMessage()); - } - } - - Path downloadLocation = force ? REMOTE_PACK_CACHE.resolve(url.hashCode() + "_debug") : packLocation; - Files.copy(con.getInputStream(), downloadLocation, StandardCopyOption.REPLACE_EXISTING); - - // This needs to match as the client fails to download the pack otherwise - if (Files.size(downloadLocation) != size) { - GeyserImpl.getInstance().getLogger().error(String.format("Size mismatch with resource pack at url: %s. Downloaded pack has %s bytes, expected %s bytes!", url, Files.size(packLocation), size)); - Files.delete(downloadLocation); - return null; - } - - // "Force" runs when the client rejected a pack. This is done for diagnosis of the issue. - if (force) { - if (Files.size(packLocation) != Files.size(downloadLocation)) { - logger.error("The pack size seems to have changed. If you wish to change the pack at the remote URL, restart/reload Geyser. " + - "Changing the pack mid-game can result in clients rejecting the pack, connected clients having different pack, or similar. "); - } - } else { - try { - Files.write(packMetadata, Arrays.asList(String.valueOf(size), con.getHeaderField("ETag"), String.valueOf(con.getLastModified()))); - } catch (IOException e) { - GeyserImpl.getInstance().getLogger().error("Failed to write cached pack metadata: " + e.getMessage()); - } - } - - return downloadLocation; - } catch (MalformedURLException e) { - throw new IllegalArgumentException("Malformed URL: " + url); - } catch (SocketTimeoutException | ConnectException e) { - GeyserImpl.getInstance().getLogger().error("Unable to reach URL: " + url + " (" + e.getMessage() + ")"); - return null; - } catch (IOException e) { - throw new RuntimeException("Unable to download and save remote resource pack from: " + url + " (" + e.getMessage() + ")"); + int responseCode = con.getResponseCode(); + if (responseCode >= 400) { + throw new IllegalStateException(String.format("Invalid response code from remote pack URL: %s (code: %d)", url, responseCode)); } - }); + + int size = con.getContentLength(); + String type = con.getContentType(); + + if (size <= 0) { + throw new IllegalArgumentException(String.format("Invalid size from remote pack URL: %s (size: %d)", url, size)); + } + + // This doesn't seem to be a requirement (anymore?). Logging to debug might be interesting though. + if (type == null || !type.equals("application/zip")) { + logger.debug(String.format("Application type from remote pack URL: %s (type: %s)", url, type)); + } + + Path packLocation = REMOTE_PACK_CACHE.resolve(url.hashCode() + ".zip"); + Path packMetadata = packLocation.resolveSibling(url.hashCode() + ".metadata"); + + // If we downloaded this pack before, reuse it if the ETag matches. + if (Files.exists(packLocation) && Files.exists(packMetadata) && !force) { + try { + List metadataLines = Files.readAllLines(packMetadata, StandardCharsets.UTF_8); + int cachedSize = Integer.parseInt(metadataLines.get(0)); + String cachedEtag = metadataLines.get(1); + long cachedLastModified = Long.parseLong(metadataLines.get(2)); + + if (cachedSize == size && cachedEtag.equals(con.getHeaderField("ETag")) && cachedLastModified == con.getLastModified()) { + logger.debug("Using cached pack for " + url); + return packLocation; + } + } catch (IOException e) { + GeyserImpl.getInstance().getLogger().error("Failed to read cached pack metadata: " + e.getMessage()); + } + } + + Path downloadLocation = force ? REMOTE_PACK_CACHE.resolve(url.hashCode() + "_debug") : packLocation; + Files.copy(con.getInputStream(), downloadLocation, StandardCopyOption.REPLACE_EXISTING); + + // This needs to match as the client fails to download the pack otherwise + long downloadSize = Files.size(downloadLocation); + if (downloadSize != size) { + Files.delete(downloadLocation); + throw new IllegalStateException("Size mismatch with resource pack at url: %s. Downloaded pack has %s bytes, expected %s bytes" + .formatted(url, downloadSize, size)); + } + + // "Force" runs when the client rejected a pack. This is done for diagnosis of the issue. + if (force) { + // Check whether existing pack's size matches the newly downloaded packs' size + if (Files.size(packLocation) != Files.size(downloadLocation)) { + logger.error(""" + The pack size seems to have changed (%s, expected %s). If you wish to change the pack at the remote URL, restart/reload Geyser. + Changing the pack while Geyser is running can result in unexpected issues. + """.formatted(Files.size(packLocation), Files.size(downloadLocation))); + } + } else { + try { + Files.write(packMetadata, Arrays.asList(String.valueOf(size), con.getHeaderField("ETag"), String.valueOf(con.getLastModified()))); + } catch (IOException e) { + GeyserImpl.getInstance().getLogger().error("Failed to write cached pack metadata: " + e.getMessage()); + } + } + + return downloadLocation; + } catch (MalformedURLException e) { + throw new IllegalArgumentException("Unable to download resource pack from malformed URL %s! ".formatted(url)); + } catch (SocketTimeoutException | ConnectException e) { + logger.error("Unable to download pack from url %s due to network error! ( %s )".formatted(url, e.getMessage())); + logger.debug(e); + } catch (IOException e) { + throw new IllegalStateException("Unable to download and save remote resource pack from: %s ( %s )!".formatted(url, e.getMessage())); + } + return null; }