diff --git a/api/src/main/java/org/geysermc/geyser/api/event/bedrock/SessionLoadResourcePacksEvent.java b/api/src/main/java/org/geysermc/geyser/api/event/bedrock/SessionLoadResourcePacksEvent.java index c2f1cd42770..568626b4cb6 100644 --- a/api/src/main/java/org/geysermc/geyser/api/event/bedrock/SessionLoadResourcePacksEvent.java +++ b/api/src/main/java/org/geysermc/geyser/api/event/bedrock/SessionLoadResourcePacksEvent.java @@ -30,6 +30,7 @@ import org.geysermc.geyser.api.event.connection.ConnectionEvent; import org.geysermc.geyser.api.pack.ResourcePack; +import java.util.Collection; import java.util.List; import java.util.UUID; @@ -42,7 +43,7 @@ public SessionLoadResourcePacksEvent(@NonNull GeyserConnection connection) { } /** - * Gets an unmodifiable list of {@link ResourcePack}s that will be sent to the client. + * Gets an unmodifiable list of {@link ResourcePack}'s that will be sent to the client. * * @return an unmodifiable list of resource packs that will be sent to the client. */ @@ -58,10 +59,17 @@ public SessionLoadResourcePacksEvent(@NonNull GeyserConnection connection) { public abstract boolean register(@NonNull ResourcePack resourcePack); /** - * Unregisters a resource pack from being sent to the client. + * Registers a collection of {@link ResourcePack}'s to be sent to clients. * - * @param uuid the UUID of the resource pack - * @return true whether the resource pack was removed from the list of resource packs. + * @param resourcePacks collection of resource pack's that will be sent to clients + */ + public abstract void registerAll(@NonNull Collection resourcePacks); + + /** + * Unregisters a {@link ResourcePack} from being sent to the client. + * + * @param uuid the UUID of the resource pack to remove. + * @return true whether the resource pack was removed successfully. */ public abstract boolean unregister(@NonNull UUID uuid); } diff --git a/api/src/main/java/org/geysermc/geyser/api/event/lifecycle/GeyserDefineResourcePacksEvent.java b/api/src/main/java/org/geysermc/geyser/api/event/lifecycle/GeyserDefineResourcePacksEvent.java new file mode 100644 index 00000000000..2613f8c5b06 --- /dev/null +++ b/api/src/main/java/org/geysermc/geyser/api/event/lifecycle/GeyserDefineResourcePacksEvent.java @@ -0,0 +1,72 @@ +/* + * Copyright (c) 2019-2023 GeyserMC. http://geysermc.org + * + * 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. + * + * @author GeyserMC + * @link https://github.com/GeyserMC/Geyser + */ + +package org.geysermc.geyser.api.event.lifecycle; + +import org.checkerframework.checker.nullness.qual.NonNull; +import org.geysermc.event.Event; +import org.geysermc.geyser.api.pack.ResourcePack; + +import java.util.Collection; +import java.util.List; +import java.util.UUID; + +/** + * Called when {@link ResourcePack}'s are loaded within Geyser. + */ +public abstract class GeyserDefineResourcePacksEvent implements Event { + + /** + * Gets an unmodifiable list of {@link ResourcePack}'s that will be sent to clients. + * + * @return an unmodifiable list of resource packs that will be sent to clients. + */ + public abstract @NonNull List resourcePacks(); + + /** + * Registers a {@link ResourcePack} to be sent to clients. + * + * @param resourcePack a resource pack that will be sent to clients. + * @return true if the resource pack was added successfully, + * or false if already present + */ + public abstract boolean register(@NonNull ResourcePack resourcePack); + + /** + * Registers a collection of {@link ResourcePack}'s to be sent to clients. + * + * @param resourcePacks a collection of resource pack's that will be sent to clients. + */ + public abstract void registerAll(@NonNull Collection resourcePacks); + + + /** + * Unregisters a {@link ResourcePack} from being sent to clients. + * + * @param uuid the UUID of the resource pack to remove. + * @return true whether the resource pack was removed successfully. + */ + public abstract boolean unregister(@NonNull UUID uuid); +} diff --git a/api/src/main/java/org/geysermc/geyser/api/event/lifecycle/GeyserLoadResourcePacksEvent.java b/api/src/main/java/org/geysermc/geyser/api/event/lifecycle/GeyserLoadResourcePacksEvent.java index e9b283ecbbd..98b34126e2b 100644 --- a/api/src/main/java/org/geysermc/geyser/api/event/lifecycle/GeyserLoadResourcePacksEvent.java +++ b/api/src/main/java/org/geysermc/geyser/api/event/lifecycle/GeyserLoadResourcePacksEvent.java @@ -33,8 +33,11 @@ /** * Called when resource packs are loaded within Geyser. + * @deprecated Use {@link GeyserDefineResourcePacksEvent} instead. * * @param resourcePacks a mutable list of the currently listed resource packs */ + +@Deprecated public record GeyserLoadResourcePacksEvent(@NonNull List resourcePacks) implements Event { } 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 884129fa3f5..6d298f41087 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 @@ -26,6 +26,7 @@ package org.geysermc.geyser.api.pack; import org.checkerframework.checker.nullness.qual.NonNull; +import org.checkerframework.checker.nullness.qual.Nullable; import org.geysermc.geyser.api.GeyserApi; import java.io.IOException; @@ -79,4 +80,27 @@ public abstract class PackCodec { public static PackCodec path(@NonNull Path path) { return GeyserApi.api().provider(PathPackCodec.class, path); } + + /** + * Creates a new pack provider from the given url with no content key. + * + * @param url the url to create the pack provider from + * @return the new pack provider + */ + @NonNull + public static PackCodec url(@NonNull String url) { + return url(url, null); + } + + /** + * Creates a new pack provider from the given url and content key. + * + * @param url the url to create the pack provider from + * @param contentKey the content key, leave empty or null if pack is not encrypted + * @return the new pack provider + */ + @NonNull + public static PackCodec url(@NonNull String url, @Nullable String contentKey) { + return GeyserApi.api().provider(UrlPackCodec.class, url, contentKey); + } } 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 new file mode 100644 index 00000000000..8f279ae0da4 --- /dev/null +++ b/api/src/main/java/org/geysermc/geyser/api/pack/UrlPackCodec.java @@ -0,0 +1,58 @@ +/* + * Copyright (c) 2019-2023 GeyserMC. http://geysermc.org + * + * 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. + * + * @author GeyserMC + * @link https://github.com/GeyserMC/Geyser + */ + +package org.geysermc.geyser.api.pack; + +import org.checkerframework.checker.nullness.qual.NonNull; + +/** + * Represents a pack codec that creates a resource + * pack from a URL. + *

+ * Due to Bedrock limitations, the URL must: + *

    + *
  • be a direct download link to a .zip or .mcpack resource pack
  • + *
  • use the application type `application/zip` and set a correct content length
  • + *
+ */ +public abstract class UrlPackCodec extends PackCodec { + + /** + * Gets the URL to the resource pack location. + * + * @return the URL of the resource pack + */ + @NonNull + public abstract String url(); + + /** + * If the remote pack has an encryption key, it must be specified here. + * This will return empty if none is specified. + * + * @return the encryption key of the resource pack + */ + @NonNull + public abstract String contentKey(); +} diff --git a/core/src/main/java/org/geysermc/geyser/GeyserImpl.java b/core/src/main/java/org/geysermc/geyser/GeyserImpl.java index 8febf4d2179..1a84c27887f 100644 --- a/core/src/main/java/org/geysermc/geyser/GeyserImpl.java +++ b/core/src/main/java/org/geysermc/geyser/GeyserImpl.java @@ -81,6 +81,7 @@ import org.geysermc.geyser.network.netty.GeyserServer; import org.geysermc.geyser.registry.BlockRegistries; import org.geysermc.geyser.registry.Registries; +import org.geysermc.geyser.registry.loader.ResourcePackLoader; import org.geysermc.geyser.registry.provider.ProviderSupplier; import org.geysermc.geyser.scoreboard.ScoreboardUpdater; import org.geysermc.geyser.session.GeyserSession; @@ -719,7 +720,7 @@ public void disable() { runIfNonNull(newsHandler, NewsHandler::shutdown); runIfNonNull(erosionUnixListener, UnixSocketClientListener::close); - Registries.RESOURCE_PACKS.get().clear(); + ResourcePackLoader.clear(); this.setEnabled(false); } diff --git a/core/src/main/java/org/geysermc/geyser/configuration/GeyserConfiguration.java b/core/src/main/java/org/geysermc/geyser/configuration/GeyserConfiguration.java index 88bb98171e6..fb66a002ac5 100644 --- a/core/src/main/java/org/geysermc/geyser/configuration/GeyserConfiguration.java +++ b/core/src/main/java/org/geysermc/geyser/configuration/GeyserConfiguration.java @@ -96,6 +96,8 @@ public interface GeyserConfiguration { boolean isForceResourcePacks(); + List getResourcePackUrls(); + @SuppressWarnings("BooleanMethodIsAlwaysInverted") boolean isXboxAchievementsEnabled(); diff --git a/core/src/main/java/org/geysermc/geyser/configuration/GeyserJacksonConfiguration.java b/core/src/main/java/org/geysermc/geyser/configuration/GeyserJacksonConfiguration.java index 81ac824e432..e4e82165fa8 100644 --- a/core/src/main/java/org/geysermc/geyser/configuration/GeyserJacksonConfiguration.java +++ b/core/src/main/java/org/geysermc/geyser/configuration/GeyserJacksonConfiguration.java @@ -136,6 +136,9 @@ public abstract class GeyserJacksonConfiguration implements GeyserConfiguration @JsonProperty("force-resource-packs") private boolean forceResourcePacks = true; + @JsonProperty("resource-pack-urls") + private List resourcePackUrls = new ArrayList<>(); + @JsonProperty("xbox-achievements-enabled") private boolean xboxAchievementsEnabled = false; diff --git a/core/src/main/java/org/geysermc/geyser/event/type/GeyserDefineResourcePacksEventImpl.java b/core/src/main/java/org/geysermc/geyser/event/type/GeyserDefineResourcePacksEventImpl.java new file mode 100644 index 00000000000..b97712ca961 --- /dev/null +++ b/core/src/main/java/org/geysermc/geyser/event/type/GeyserDefineResourcePacksEventImpl.java @@ -0,0 +1,71 @@ +/* + * Copyright (c) 2019-2023 GeyserMC. http://geysermc.org + * + * 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. + * + * @author GeyserMC + * @link https://github.com/GeyserMC/Geyser + */ + +package org.geysermc.geyser.event.type; + +import lombok.Getter; +import org.checkerframework.checker.nullness.qual.NonNull; +import org.geysermc.geyser.api.event.lifecycle.GeyserDefineResourcePacksEvent; +import org.geysermc.geyser.api.pack.ResourcePack; + +import java.util.Collection; +import java.util.List; +import java.util.Map; +import java.util.UUID; + +@Getter +public class GeyserDefineResourcePacksEventImpl extends GeyserDefineResourcePacksEvent { + + private final Map packs; + + public GeyserDefineResourcePacksEventImpl(Map packMap) { + this.packs = packMap; + } + + @Override + public @NonNull List resourcePacks() { + return List.copyOf(packs.values()); + } + + @Override + public boolean register(@NonNull ResourcePack resourcePack) { + String packID = resourcePack.manifest().header().uuid().toString(); + if (packs.containsValue(resourcePack) || packs.containsKey(packID)) { + return false; + } + packs.put(resourcePack.manifest().header().uuid().toString(), resourcePack); + return true; + } + + @Override + public void registerAll(@NonNull Collection resourcePacks) { + resourcePacks.forEach(this::register); + } + + @Override + public boolean unregister(@NonNull UUID uuid) { + return packs.remove(uuid.toString()) != null; + } +} diff --git a/core/src/main/java/org/geysermc/geyser/event/type/SessionLoadResourcePacksEventImpl.java b/core/src/main/java/org/geysermc/geyser/event/type/SessionLoadResourcePacksEventImpl.java index 5ed0f8d225a..505c551e512 100644 --- a/core/src/main/java/org/geysermc/geyser/event/type/SessionLoadResourcePacksEventImpl.java +++ b/core/src/main/java/org/geysermc/geyser/event/type/SessionLoadResourcePacksEventImpl.java @@ -25,28 +25,26 @@ package org.geysermc.geyser.event.type; +import lombok.Getter; import org.checkerframework.checker.nullness.qual.NonNull; import org.geysermc.geyser.api.event.bedrock.SessionLoadResourcePacksEvent; import org.geysermc.geyser.api.pack.ResourcePack; import org.geysermc.geyser.session.GeyserSession; +import java.util.Collection; import java.util.List; import java.util.Map; import java.util.UUID; public class SessionLoadResourcePacksEventImpl extends SessionLoadResourcePacksEvent { + @Getter private final Map packs; public SessionLoadResourcePacksEventImpl(GeyserSession session, Map packMap) { super(session); this.packs = packMap; } - - public @NonNull Map getPacks() { - return packs; - } - @Override public @NonNull List resourcePacks() { return List.copyOf(packs.values()); @@ -62,6 +60,11 @@ public boolean register(@NonNull ResourcePack resourcePack) { return true; } + @Override + public void registerAll(@NonNull Collection resourcePacks) { + resourcePacks.forEach(this::register); + } + @Override public boolean unregister(@NonNull UUID uuid) { return packs.remove(uuid.toString()) != null; 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 f56a8a43fc4..843b12bb0da 100644 --- a/core/src/main/java/org/geysermc/geyser/network/UpstreamPacketHandler.java +++ b/core/src/main/java/org/geysermc/geyser/network/UpstreamPacketHandler.java @@ -36,20 +36,7 @@ import org.cloudburstmc.protocol.bedrock.netty.codec.compression.CompressionStrategy; import org.cloudburstmc.protocol.bedrock.netty.codec.compression.SimpleCompressionStrategy; import org.cloudburstmc.protocol.bedrock.netty.codec.compression.ZlibCompression; -import org.cloudburstmc.protocol.bedrock.packet.BedrockPacket; -import org.cloudburstmc.protocol.bedrock.packet.LoginPacket; -import org.cloudburstmc.protocol.bedrock.packet.ModalFormResponsePacket; -import org.cloudburstmc.protocol.bedrock.packet.MovePlayerPacket; -import org.cloudburstmc.protocol.bedrock.packet.NetworkSettingsPacket; -import org.cloudburstmc.protocol.bedrock.packet.PlayStatusPacket; -import org.cloudburstmc.protocol.bedrock.packet.RequestNetworkSettingsPacket; -import org.cloudburstmc.protocol.bedrock.packet.ResourcePackChunkDataPacket; -import org.cloudburstmc.protocol.bedrock.packet.ResourcePackChunkRequestPacket; -import org.cloudburstmc.protocol.bedrock.packet.ResourcePackClientResponsePacket; -import org.cloudburstmc.protocol.bedrock.packet.ResourcePackDataInfoPacket; -import org.cloudburstmc.protocol.bedrock.packet.ResourcePackStackPacket; -import org.cloudburstmc.protocol.bedrock.packet.ResourcePacksInfoPacket; -import org.cloudburstmc.protocol.bedrock.packet.SetTitlePacket; +import org.cloudburstmc.protocol.bedrock.packet.*; import org.cloudburstmc.protocol.common.PacketSignal; import org.cloudburstmc.protocol.common.util.Zlib; import org.geysermc.geyser.Constants; @@ -59,10 +46,12 @@ import org.geysermc.geyser.api.pack.PackCodec; import org.geysermc.geyser.api.pack.ResourcePack; import org.geysermc.geyser.api.pack.ResourcePackManifest; +import org.geysermc.geyser.api.pack.UrlPackCodec; import org.geysermc.geyser.event.type.SessionLoadResourcePacksEventImpl; import org.geysermc.geyser.pack.GeyserResourcePack; import org.geysermc.geyser.registry.BlockRegistries; import org.geysermc.geyser.registry.Registries; +import org.geysermc.geyser.registry.loader.ResourcePackLoader; import org.geysermc.geyser.session.GeyserSession; import org.geysermc.geyser.session.PendingMicrosoftAuthentication; import org.geysermc.geyser.text.GeyserLocale; @@ -81,7 +70,7 @@ public class UpstreamPacketHandler extends LoggingPacketHandler { private boolean networkSettingsRequested = false; - private final Deque packsToSent = new ArrayDeque<>(); + private final Deque packsToSend = new ArrayDeque<>(); private final CompressionStrategy compressionStrategy; private SessionLoadResourcePacksEventImpl resourcePackLoadEvent; @@ -207,10 +196,17 @@ public PacketSignal handle(LoginPacket loginPacket) { for (ResourcePack pack : this.resourcePackLoadEvent.resourcePacks()) { PackCodec codec = pack.codec(); ResourcePackManifest.Header header = pack.manifest().header(); + + if (pack.codec() instanceof UrlPackCodec urlPackCodec) { + resourcePacksInfo.getCDNEntries().add(new ResourcePacksInfoPacket.CDNEntry( + header.uuid() + "_" + header.version(), urlPackCodec.url())); + } + resourcePacksInfo.getResourcePackInfos().add(new ResourcePacksInfoPacket.Entry( header.uuid().toString(), header.version().toString(), codec.size(), pack.contentKey(), "", header.uuid().toString(), false, false)); } + resourcePacksInfo.setForcedToAccept(GeyserImpl.getInstance().getConfig().isForceResourcePacks()); session.sendUpstreamPacket(resourcePacksInfo); @@ -221,7 +217,7 @@ public PacketSignal handle(LoginPacket loginPacket) { @Override public PacketSignal handle(ResourcePackClientResponsePacket packet) { switch (packet.getStatus()) { - case COMPLETED: + case COMPLETED -> { if (geyser.getConfig().getRemote().authType() != AuthType.ONLINE) { session.authenticate(session.getAuthData().name()); } else if (!couldLoginUserByName(session.getAuthData().name())) { @@ -229,14 +225,12 @@ public PacketSignal handle(ResourcePackClientResponsePacket packet) { session.connect(); } geyser.getLogger().info(GeyserLocale.getLocaleStringLog("geyser.network.connect", session.getAuthData().name())); - break; - - case SEND_PACKS: - packsToSent.addAll(packet.getPackIds()); - sendPackDataInfo(packsToSent.pop()); - break; - - case HAVE_ALL_PACKS: + } + case SEND_PACKS -> { + packsToSend.addAll(packet.getPackIds()); + sendPackDataInfo(packsToSend.pop()); + } + case HAVE_ALL_PACKS -> { ResourcePackStackPacket stackPacket = new ResourcePackStackPacket(); stackPacket.setExperimentsPreviouslyToggled(false); stackPacket.setForcedToAccept(false); // Leaving this as false allows the player to choose to download or not @@ -256,11 +250,8 @@ public PacketSignal handle(ResourcePackClientResponsePacket packet) { stackPacket.getExperiments().add(new ExperimentData("updateAnnouncedLive2023", true)); session.sendUpstreamPacket(stackPacket); - break; - - default: - session.disconnect("disconnectionScreen.resourcePack"); - break; + } + default -> session.disconnect("disconnectionScreen.resourcePack"); } return PacketSignal.HANDLED; @@ -312,6 +303,11 @@ public PacketSignal handle(ResourcePackChunkRequestPacket packet) { ResourcePack pack = this.resourcePackLoadEvent.getPacks().get(packet.getPackId().toString()); PackCodec codec = pack.codec(); + // If a remote pack ends up here, that usually implies that a client was not able to download the pack + if (codec instanceof UrlPackCodec urlPackCodec) { + ResourcePackLoader.testRemotePack(session, urlPackCodec, packet.getPackId().toString(), packet.getPackVersion()); + } + data.setChunkIndex(packet.getChunkIndex()); data.setProgress((long) packet.getChunkIndex() * GeyserResourcePack.CHUNK_SIZE); data.setPackVersion(packet.getPackVersion()); @@ -333,8 +329,8 @@ public PacketSignal handle(ResourcePackChunkRequestPacket packet) { session.sendUpstreamPacket(data); // Check if it is the last chunk and send next pack in queue when available. - if (remainingSize <= GeyserResourcePack.CHUNK_SIZE && !packsToSent.isEmpty()) { - sendPackDataInfo(packsToSent.pop()); + if (remainingSize <= GeyserResourcePack.CHUNK_SIZE && !packsToSend.isEmpty()) { + sendPackDataInfo(packsToSend.pop()); } return PacketSignal.HANDLED; diff --git a/core/src/main/java/org/geysermc/geyser/pack/GeyserResourcePack.java b/core/src/main/java/org/geysermc/geyser/pack/GeyserResourcePack.java index 82408b6e731..b50696a55de 100644 --- a/core/src/main/java/org/geysermc/geyser/pack/GeyserResourcePack.java +++ b/core/src/main/java/org/geysermc/geyser/pack/GeyserResourcePack.java @@ -25,11 +25,12 @@ package org.geysermc.geyser.pack; +import org.checkerframework.checker.nullness.qual.NonNull; import org.geysermc.geyser.api.pack.PackCodec; import org.geysermc.geyser.api.pack.ResourcePack; import org.geysermc.geyser.api.pack.ResourcePackManifest; -public record GeyserResourcePack(PackCodec codec, ResourcePackManifest manifest, String contentKey) implements ResourcePack { +public record GeyserResourcePack(@NonNull PackCodec codec, @NonNull ResourcePackManifest manifest, @NonNull String contentKey) implements ResourcePack { /** * The size of each chunk to use when sending the resource packs to clients in bytes 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 new file mode 100644 index 00000000000..f8b5ba7fb67 --- /dev/null +++ b/core/src/main/java/org/geysermc/geyser/pack/url/GeyserUrlPackCodec.java @@ -0,0 +1,103 @@ +/* + * Copyright (c) 2019-2023 GeyserMC. http://geysermc.org + * + * 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. + * + * @author GeyserMC + * @link https://github.com/GeyserMC/Geyser + */ + +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.api.pack.PathPackCodec; +import org.geysermc.geyser.api.pack.ResourcePack; +import org.geysermc.geyser.api.pack.UrlPackCodec; +import org.geysermc.geyser.registry.loader.ResourcePackLoader; + +import java.io.IOException; +import java.nio.channels.SeekableByteChannel; +import java.util.Objects; + +public class GeyserUrlPackCodec extends UrlPackCodec { + private final @NonNull String url; + private final @Nullable String contentKey; + @Getter + private PathPackCodec fallback; + + public GeyserUrlPackCodec(String url) throws IllegalArgumentException { + this(url, null); + } + + public GeyserUrlPackCodec(@NonNull String url, @Nullable String contentKey) throws IllegalArgumentException { + Objects.requireNonNull(url, "url cannot be null"); + this.url = url; + this.contentKey = contentKey; + } + + @Override + public byte @NonNull [] sha256() { + Objects.requireNonNull(fallback, "must call #create() before attempting to get the sha256!"); + return fallback.sha256(); + } + + @Override + public long size() { + 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 { + Objects.requireNonNull(fallback, "must call #create() before attempting to serialize!!"); + return fallback.serialize(resourcePack); + } + + @Override + @NonNull + public ResourcePack create() { + if (this.fallback == null) { + try { + ResourcePackLoader.downloadPack(url, false).whenComplete((pack, throwable) -> { + if (throwable != null) { + throw new IllegalArgumentException(throwable); + } else if (pack != null) { + this.fallback = pack; + } + }).join(); // Needed to ensure that we don't attempt to read a pack before downloading/checking it + } catch (Exception e) { + throw new IllegalArgumentException("Failed to download pack from the url %s (reason: %s)!".formatted(url, e.getMessage())); + } + } + + return ResourcePackLoader.readPack(this); + } + + @Override + public @NonNull String url() { + return this.url; + } + + @Override + public @NonNull String contentKey() { + return this.contentKey != null ? contentKey : ""; + } +} diff --git a/core/src/main/java/org/geysermc/geyser/registry/loader/ProviderRegistryLoader.java b/core/src/main/java/org/geysermc/geyser/registry/loader/ProviderRegistryLoader.java index 94de0c29858..4db9497d6b0 100644 --- a/core/src/main/java/org/geysermc/geyser/registry/loader/ProviderRegistryLoader.java +++ b/core/src/main/java/org/geysermc/geyser/registry/loader/ProviderRegistryLoader.java @@ -40,6 +40,7 @@ import org.geysermc.geyser.api.item.custom.CustomItemOptions; import org.geysermc.geyser.api.item.custom.NonVanillaCustomItemData; import org.geysermc.geyser.api.pack.PathPackCodec; +import org.geysermc.geyser.api.pack.UrlPackCodec; import org.geysermc.geyser.impl.camera.GeyserCameraFade; import org.geysermc.geyser.impl.camera.GeyserCameraPosition; import org.geysermc.geyser.event.GeyserEventRegistrar; @@ -54,6 +55,7 @@ import org.geysermc.geyser.level.block.GeyserMaterialInstance; import org.geysermc.geyser.level.block.GeyserNonVanillaCustomBlockData; import org.geysermc.geyser.pack.path.GeyserPathPackCodec; +import org.geysermc.geyser.pack.url.GeyserUrlPackCodec; import org.geysermc.geyser.registry.provider.ProviderSupplier; import java.nio.file.Path; @@ -78,6 +80,7 @@ public Map, ProviderSupplier> load(Map, ProviderSupplier> prov providers.put(EventRegistrar.class, args -> new GeyserEventRegistrar(args[0])); providers.put(PathPackCodec.class, args -> new GeyserPathPackCodec((Path) args[0])); + providers.put(UrlPackCodec.class, args -> new GeyserUrlPackCodec((String) args[0], (String) args[1])); // items providers.put(CustomItemData.Builder.class, args -> new GeyserCustomItemData.Builder()); 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 800a3d22c6e..9662f6f0bc7 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,26 +25,39 @@ 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; +import org.geysermc.geyser.event.type.GeyserDefineResourcePacksEventImpl; import org.geysermc.geyser.pack.GeyserResourcePack; import org.geysermc.geyser.pack.GeyserResourcePackManifest; import org.geysermc.geyser.pack.SkullResourcePackManager; import org.geysermc.geyser.pack.path.GeyserPathPackCodec; +import org.geysermc.geyser.pack.url.GeyserUrlPackCodec; +import org.geysermc.geyser.registry.Registries; +import org.geysermc.geyser.session.GeyserSession; import org.geysermc.geyser.text.GeyserLocale; import org.geysermc.geyser.util.FileUtils; +import org.geysermc.geyser.util.WebUtils; +import java.io.File; import java.io.IOException; import java.nio.charset.StandardCharsets; 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.HashMap; -import java.util.List; -import java.util.Map; +import java.util.*; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.RejectedExecutionException; +import java.util.concurrent.TimeUnit; import java.util.concurrent.atomic.AtomicReference; import java.util.stream.Collectors; import java.util.stream.Stream; @@ -52,10 +65,18 @@ import java.util.zip.ZipFile; /** - * Loads {@link ResourcePack}s within a {@link Path} directory, firing the {@link GeyserLoadResourcePacksEvent}. + * Loads {@link ResourcePack}s within a {@link Path} directory, firing the {@link GeyserDefineResourcePacksEventImpl}. */ public class ResourcePackLoader implements RegistryLoader> { + /** + * Used to keep track of remote resource packs that the client rejected. + * If a client rejects such a pack, it falls back to the old method, and Geyser serves a cached variant. + */ + 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}"); private static final boolean SHOW_RESOURCE_PACK_LENGTH_WARNING = Boolean.parseBoolean(System.getProperty("Geyser.ShowResourcePackLengthWarning", "true")); @@ -65,7 +86,7 @@ public class ResourcePackLoader implements RegistryLoader load(Path directory) { - Map packMap = new HashMap<>(); + Map packMap = new Object2ObjectOpenHashMap<>(); if (!Files.exists(directory)) { try { @@ -94,6 +115,7 @@ public Map load(Path directory) { resourcePacks.add(skullResourcePack); } + @SuppressWarnings("deprecation") GeyserLoadResourcePacksEvent event = new GeyserLoadResourcePacksEvent(resourcePacks); GeyserImpl.getInstance().eventBus().fire(event); @@ -105,7 +127,11 @@ public Map load(Path directory) { e.printStackTrace(); } } - return packMap; + + packMap.putAll(loadRemotePacks()); + GeyserDefineResourcePacksEventImpl defineEvent = new GeyserDefineResourcePacksEventImpl(packMap); + GeyserImpl.getInstance().eventBus().fire(defineEvent); + return defineEvent.getPacks(); } /** @@ -117,10 +143,41 @@ public Map load(Path directory) { * @throws IllegalArgumentException if the pack manifest was invalid or there was any processing exception */ public static GeyserResourcePack readPack(Path path) throws IllegalArgumentException { - if (!path.getFileName().toString().endsWith(".mcpack") && !path.getFileName().toString().endsWith(".zip")) { + if (!PACK_MATCHER.matches(path)) { throw new IllegalArgumentException("Resource pack " + path.getFileName() + " must be a .zip or .mcpack file!"); } + ResourcePackManifest manifest = readManifest(path, path.getFileName().toString()); + String contentKey; + + try { + // Check if a file exists with the same name as the resource pack suffixed by .key, + // and set this as content key. (e.g. test.zip, key file would be test.zip.key) + Path keyFile = path.resolveSibling(path.getFileName().toString() + ".key"); + contentKey = Files.exists(keyFile) ? Files.readString(keyFile, StandardCharsets.UTF_8) : ""; + } catch (IOException e) { + GeyserImpl.getInstance().getLogger().error("Failed to read content key for resource pack " + path.getFileName(), e); + contentKey = ""; + } + + return new GeyserResourcePack(new GeyserPathPackCodec(path), manifest, contentKey); + } + + /** + * Reads a Resource pack from a URL codec, and returns a resource pack. Unlike {@link ResourcePackLoader#readPack(Path)} + * this method reads content keys differently. + * + * @param codec the URL pack codec with the url to download the pack from + * @return a {@link GeyserResourcePack} representation + * @throws IllegalArgumentException if there was an error reading the pack. + */ + public static GeyserResourcePack readPack(GeyserUrlPackCodec codec) throws IllegalArgumentException { + Path path = codec.getFallback().path(); + ResourcePackManifest manifest = readManifest(path, codec.url()); + return new GeyserResourcePack(codec, manifest, codec.contentKey()); + } + + private static ResourcePackManifest readManifest(Path path, String packLocation) throws IllegalArgumentException { AtomicReference manifestReference = new AtomicReference<>(); try (ZipFile zip = new ZipFile(path.toFile()); @@ -128,7 +185,7 @@ public static GeyserResourcePack readPack(Path path) throws IllegalArgumentExcep stream.forEach(x -> { String name = x.getName(); if (SHOW_RESOURCE_PACK_LENGTH_WARNING && name.length() >= 80) { - GeyserImpl.getInstance().getLogger().warning("The resource pack " + path.getFileName() + GeyserImpl.getInstance().getLogger().warning("The resource pack " + packLocation + " has a file in it that meets or exceeds 80 characters in its path (" + name + ", " + name.length() + " characters long). This will cause problems on some Bedrock platforms." + " Please rename it to be shorter, or reduce the amount of folders needed to get to the file."); @@ -147,17 +204,192 @@ public static GeyserResourcePack readPack(Path path) throws IllegalArgumentExcep GeyserResourcePackManifest manifest = manifestReference.get(); if (manifest == null) { - throw new IllegalArgumentException(path.getFileName() + " does not contain a valid pack_manifest.json or manifest.json"); + throw new IllegalArgumentException(packLocation + " does not contain a valid pack_manifest.json or manifest.json"); } - // Check if a file exists with the same name as the resource pack suffixed by .key, - // and set this as content key. (e.g. test.zip, key file would be test.zip.key) - Path keyFile = path.resolveSibling(path.getFileName().toString() + ".key"); - String contentKey = Files.exists(keyFile) ? Files.readString(keyFile, StandardCharsets.UTF_8) : ""; - - return new GeyserResourcePack(new GeyserPathPackCodec(path), manifest, contentKey); + return manifest; } catch (Exception e) { - throw new IllegalArgumentException(GeyserLocale.getLocaleStringLog("geyser.resource_pack.broken", path.getFileName()), e); + throw new IllegalArgumentException(GeyserLocale.getLocaleStringLog("geyser.resource_pack.broken", packLocation), e); + } + } + + private Map loadRemotePacks() { + GeyserImpl instance = GeyserImpl.getInstance(); + // Unable to make this a static variable, as the test would fail + final Path cachedCdnPacksDirectory = instance.getBootstrap().getConfigFolder().resolve("cache").resolve("remote_packs"); + + if (!Files.exists(cachedCdnPacksDirectory)) { + try { + Files.createDirectories(cachedCdnPacksDirectory); + } catch (IOException e) { + instance.getLogger().error("Could not create remote pack cache directory", e); + return new Object2ObjectOpenHashMap<>(); + } + } + + List remotePackUrls = instance.getConfig().getResourcePackUrls(); + Map packMap = new Object2ObjectOpenHashMap<>(); + + for (String url : remotePackUrls) { + try { + GeyserUrlPackCodec codec = new GeyserUrlPackCodec(url); + ResourcePack pack = codec.create(); + packMap.put(pack.manifest().header().uuid().toString(), pack); + } catch (Throwable e) { + instance.getLogger().error(GeyserLocale.getLocaleStringLog("geyser.resource_pack.broken", url)); + instance.getLogger().error(e.getMessage()); + if (instance.getLogger().isDebug()) { + e.printStackTrace(); + } + } + } + + // After loading the new resource packs: let's clean up the old + cleanupRemotePacks(); + + return packMap; + } + + /** + * Used when a Bedrock client requests a Bedrock resource pack from the server when it should be downloading it + * from a remote provider. Since this would be called each time a Bedrock client requests a piece of the Bedrock pack, + * this uses a cache to ensure we aren't re-checking a dozen times. + * + * @param codec the codec of the resource pack that wasn't successfully downloaded by a Bedrock client. + */ + public static void testRemotePack(GeyserSession session, UrlPackCodec codec, String packId, String packVersion) { + if (CACHED_FAILED_PACKS.getIfPresent(codec.url()) == null) { + String url = codec.url(); + CACHED_FAILED_PACKS.put(url, codec); + GeyserImpl.getInstance().getLogger().warning( + "A Bedrock client (%s, playing on %s / %s) was not able to download the resource pack at %s. Checking for changes now:" + .formatted(session.bedrockUsername(), session.getClientData().getDeviceOs().name(), session.getClientData().getDeviceId(), codec.url()) + ); + + downloadPack(codec.url(), true).whenComplete((pathPackCodec, e) -> { + if (e != null) { + GeyserImpl.getInstance().getLogger().error(GeyserLocale.getLocaleStringLog("geyser.resource_pack.broken", url), e); + if (GeyserImpl.getInstance().getLogger().isDebug()) { + e.printStackTrace(); + if (pathPackCodec != null) { + deleteFile(pathPackCodec.path()); + } + return; + } + } + + if (pathPackCodec == null) { + return; // Already warned about + } + + ResourcePack newPack = ResourcePackLoader.readPack(pathPackCodec.path()); + UUID newUUID = newPack.manifest().header().uuid(); + if (newUUID.toString().equals(packId)) { + if (packVersion.equals(newPack.manifest().header().version().toString())) { + GeyserImpl.getInstance().getLogger().info("No version or pack change detected: Was the resource pack server down?"); + } else { + GeyserImpl.getInstance().getLogger().info("Detected a new resource pack version (%s, old version %s) for pack at %s!" + .formatted(packVersion, newPack.manifest().header().version().toString(), url)); + } + } else { + GeyserImpl.getInstance().getLogger().info("Detected a new resource pack at the url %s!".formatted(url)); + } + + // This should be safe to do as we're not directly using registries to read packs. + // Instead, they're cached per-session in the SessionLoadResourcePacks event + Registries.RESOURCE_PACKS.get().remove(packId); + Registries.RESOURCE_PACKS.get().put(newUUID.toString(), newPack); + + if (codec instanceof GeyserUrlPackCodec geyserUrlPackCodec && geyserUrlPackCodec.getFallback() != null) { + // Other implementations could, in theory, not have a fallback + Path path = geyserUrlPackCodec.getFallback().path(); + try { + GeyserImpl.getInstance().getScheduledThread().schedule(() -> { + CACHED_FAILED_PACKS.invalidate(packId); + deleteFile(path); + }, 5, TimeUnit.MINUTES); + } catch (RejectedExecutionException exception) { + // No scheduling here, probably because we're shutting down? + deleteFile(path); + } + } + }); + } + } + + private static void deleteFile(Path path) { + if (path.toFile().exists()) { + try { + Files.delete(path); + } catch (IOException e) { + GeyserImpl.getInstance().getLogger().error("Unable to delete old pack! " + e.getMessage()); + e.printStackTrace(); + } + } + } + + public static CompletableFuture<@Nullable PathPackCodec> downloadPack(String url, boolean testing) throws IllegalArgumentException { + return CompletableFuture.supplyAsync(() -> { + Path path = WebUtils.downloadRemotePack(url, testing); + + // Already warned about these above + if (path == null) { + return null; + } + + // Check if the pack is a .zip or .mcpack file + if (!PACK_MATCHER.matches(path)) { + throw new IllegalArgumentException("Invalid pack format from url %s! Not a .zip or .mcpack file.".formatted(url)); + } + + try { + try (ZipFile zip = new ZipFile(path.toFile())) { + if (zip.stream().noneMatch(x -> x.getName().contains("manifest.json"))) { + 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) { + if (GeyserImpl.getInstance().getLogger().isDebug()) { + 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); + } + + return new GeyserPathPackCodec(path); + }); + } + + public static void clear() { + Registries.RESOURCE_PACKS.get().clear(); + CACHED_FAILED_PACKS.invalidateAll(); + + } + + public static void cleanupRemotePacks() { + File cacheFolder = GeyserImpl.getInstance().getBootstrap().getConfigFolder().resolve("cache").resolve("remote_packs").toFile(); + if (!cacheFolder.exists()) { + return; + } + + int count = 0; + final long expireTime = (((long) 1000 * 60 * 60)); // one hour + for (File imageFile : Objects.requireNonNull(cacheFolder.listFiles())) { + if (imageFile.lastModified() < System.currentTimeMillis() - expireTime) { + //noinspection ResultOfMethodCallIgnored + imageFile.delete(); + count++; + } + } + + if (count > 0) { + GeyserImpl.getInstance().getLogger().debug(String.format("Removed %d cached resource pack files as they are no longer in use!", count)); } } } diff --git a/core/src/main/java/org/geysermc/geyser/scoreboard/Scoreboard.java b/core/src/main/java/org/geysermc/geyser/scoreboard/Scoreboard.java index acce86f4d95..48a45fd27c8 100644 --- a/core/src/main/java/org/geysermc/geyser/scoreboard/Scoreboard.java +++ b/core/src/main/java/org/geysermc/geyser/scoreboard/Scoreboard.java @@ -51,7 +51,7 @@ import static org.geysermc.geyser.scoreboard.UpdateType.*; public final class Scoreboard { - private static final boolean SHOW_SCOREBOARD_LOGS = Boolean.parseBoolean(System.getProperty("Geyser.ShowScoreboardLogs", "true")); + private static final boolean SHOW_SCOREBOARD_LOGS = Boolean.parseBoolean(System.getProperty("Geyser.ShowScoreboardLogs", "false")); private static final boolean ADD_TEAM_SUGGESTIONS = Boolean.parseBoolean(System.getProperty("Geyser.AddTeamSuggestions", "true")); private final GeyserSession session; diff --git a/core/src/main/java/org/geysermc/geyser/scoreboard/ScoreboardUpdater.java b/core/src/main/java/org/geysermc/geyser/scoreboard/ScoreboardUpdater.java index 395eb957660..c2898578493 100644 --- a/core/src/main/java/org/geysermc/geyser/scoreboard/ScoreboardUpdater.java +++ b/core/src/main/java/org/geysermc/geyser/scoreboard/ScoreboardUpdater.java @@ -48,7 +48,7 @@ public final class ScoreboardUpdater extends Thread { static { GeyserConfiguration config = GeyserImpl.getInstance().getConfig(); FIRST_SCORE_PACKETS_PER_SECOND_THRESHOLD = Math.min(config.getScoreboardPacketThreshold(), SECOND_SCORE_PACKETS_PER_SECOND_THRESHOLD); - DEBUG_ENABLED = config.isDebugMode(); + DEBUG_ENABLED = Boolean.parseBoolean(System.getProperty("Geyser.ShowScoreboardLogs", "false")) && config.isDebugMode(); } private final GeyserImpl geyser = GeyserImpl.getInstance(); diff --git a/core/src/main/java/org/geysermc/geyser/skin/SkinProvider.java b/core/src/main/java/org/geysermc/geyser/skin/SkinProvider.java index aec1fa4de4c..f3ad0be2ffa 100644 --- a/core/src/main/java/org/geysermc/geyser/skin/SkinProvider.java +++ b/core/src/main/java/org/geysermc/geyser/skin/SkinProvider.java @@ -167,7 +167,7 @@ public static void registerCacheImageTask(GeyserImpl geyser) { if (count > 0) { GeyserImpl.getInstance().getLogger().debug(String.format("Removed %d cached image files as they have expired", count)); } - }, 10, 1440, TimeUnit.MINUTES); + }, 10, 1, TimeUnit.DAYS); } } diff --git a/core/src/main/java/org/geysermc/geyser/util/WebUtils.java b/core/src/main/java/org/geysermc/geyser/util/WebUtils.java index 1b7f2d9d966..50ff0ede2a1 100644 --- a/core/src/main/java/org/geysermc/geyser/util/WebUtils.java +++ b/core/src/main/java/org/geysermc/geyser/util/WebUtils.java @@ -28,22 +28,26 @@ import com.fasterxml.jackson.databind.JsonNode; import org.checkerframework.checker.nullness.qual.Nullable; import org.geysermc.geyser.GeyserImpl; +import org.geysermc.geyser.GeyserLogger; import javax.naming.directory.Attribute; import javax.naming.directory.InitialDirContext; import java.io.*; -import java.net.HttpURLConnection; -import java.net.URL; -import java.net.URLEncoder; +import java.net.*; import java.nio.charset.StandardCharsets; import java.nio.file.Files; +import java.nio.file.Path; import java.nio.file.Paths; import java.nio.file.StandardCopyOption; +import java.util.Arrays; +import java.util.List; import java.util.Map; import java.util.stream.Stream; public class WebUtils { + private static final Path REMOTE_PACK_CACHE = GeyserImpl.getInstance().getBootstrap().getConfigFolder().resolve("cache").resolve("remote_packs"); + /** * Makes a web request to the given URL and returns the body as a string * @@ -96,6 +100,114 @@ public static void downloadFile(String reqURL, String fileLocation) { } } + /** + * Checks a remote pack URL to see if it is valid + * If it is, it will download the pack file and return a path to it + * + * @param url The URL to check + * @param force If true, the pack will be downloaded even if it is cached to a separate location. + * @return Path to the downloaded pack file, or null if it was unable to be loaded + */ + @SuppressWarnings("ResultOfMethodCallIgnored") + public static @Nullable Path downloadRemotePack(String url, boolean force) { + GeyserLogger logger = GeyserImpl.getInstance().getLogger(); + 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); + + int responseCode = con.getResponseCode(); + if (responseCode >= 400) { + throw new IllegalStateException(String.format("Invalid response code from remote pack at URL: %s (code: %d)", url, responseCode)); + } + + int size = con.getContentLength(); + String type = con.getContentType(); + + if (size <= 0) { + throw new IllegalArgumentException(String.format("Invalid content length received from remote pack at URL: %s (size: %d)", url, size)); + } + + // This doesn't seem to be a requirement (anymore?). Logging to debug as it might be interesting though. + if (type == null || !type.equals("application/zip")) { + logger.warning(String.format("Application type received from remote pack at URL %s uses the content type: %s! This may result in packs not loading " + + "for Bedrock players.", url, type)); + } + + Path packMetadata = REMOTE_PACK_CACHE.resolve(url.hashCode() + ".metadata"); + Path downloadLocation; + + // If we downloaded this pack before, reuse it if the ETag matches. + if (Files.exists(packMetadata) && !force) { + try { + List metadata = Files.readAllLines(packMetadata, StandardCharsets.UTF_8); + int cachedSize = Integer.parseInt(metadata.get(0)); + String cachedEtag = metadata.get(1); + long cachedLastModified = Long.parseLong(metadata.get(2)); + downloadLocation = REMOTE_PACK_CACHE.resolve(metadata.get(3)); + + if (cachedSize == size && cachedEtag.equals(con.getHeaderField("ETag")) && + cachedLastModified == con.getLastModified() && downloadLocation.toFile().exists()) { + logger.debug("Using cached pack (%s) for %s.".formatted(downloadLocation.getFileName(), url)); + downloadLocation.toFile().setLastModified(System.currentTimeMillis()); + packMetadata.toFile().setLastModified(System.currentTimeMillis()); + return downloadLocation; + } + } catch (IOException e) { + GeyserImpl.getInstance().getLogger().error("Failed to read cached pack metadata! " + e); + try { + Files.delete(packMetadata); + } catch (Exception exception) { + GeyserImpl.getInstance().getLogger().error("Failed to delete pack metadata!", exception); + } + } + } + + downloadLocation = REMOTE_PACK_CACHE.resolve(url.hashCode() + "_" + System.currentTimeMillis() + ".zip"); + 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)); + } + + try { + Files.write( + packMetadata, + Arrays.asList( + String.valueOf(size), + con.getHeaderField("ETag"), + String.valueOf(con.getLastModified()), + downloadLocation.getFileName().toString() + )); + packMetadata.toFile().setLastModified(System.currentTimeMillis()); + } catch (IOException e) { + GeyserImpl.getInstance().getLogger().error("Failed to write cached pack metadata: " + e.getMessage()); + Files.delete(packMetadata); + Files.delete(downloadLocation); + return null; + } + + downloadLocation.toFile().setLastModified(System.currentTimeMillis()); + 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; + } + + /** * Post a string to the given URL * diff --git a/core/src/main/resources/config.yml b/core/src/main/resources/config.yml index a5fe2072b0f..e1adc2ca1d8 100644 --- a/core/src/main/resources/config.yml +++ b/core/src/main/resources/config.yml @@ -167,6 +167,14 @@ above-bedrock-nether-building: false # want to download the resource packs. force-resource-packs: true +# A list of links to send to the client to download resource packs from. +# These must be direct links to the resource pack, not a link to a page containing the resource pack. +# If you enter a link here, Geyser will download the resource pack once to check if it's in a valid format. +# See https://wiki.geysermc.org/geyser/packs for more info. +resource-pack-urls: + # GeyserOptionalPack + - "https://download.geysermc.org/v2/projects/geyseroptionalpack/versions/latest/builds/latest/downloads/geyseroptionalpack" + # Allows Xbox achievements to be unlocked. xbox-achievements-enabled: false