diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 5b6d6a6..fc63a97 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -32,4 +32,6 @@ jobs: uses: actions/upload-artifact@v4 with: name: Artifacts - path: build/libs/ + path: | + **/build/libs/ + */build/libs/ \ No newline at end of file diff --git a/.github/workflows/pull_request.yml b/.github/workflows/pull_request.yml index 7f8d07b..1ed8064 100644 --- a/.github/workflows/pull_request.yml +++ b/.github/workflows/pull_request.yml @@ -26,4 +26,6 @@ jobs: uses: actions/upload-artifact@v4 with: name: Artifacts - path: build/libs/ + path: | + **/build/libs/ + */build/libs/ diff --git a/README.md b/README.md index 7821f9f..0ae1203 100644 --- a/README.md +++ b/README.md @@ -35,9 +35,12 @@ ForcePack facilitates forcing users to accept your resource pack, with utilities such as live reloading, SHA-1 generation, and per-server support via velocity. -You can find the Spigot resource at: https://www.spigotmc.org/resources/forcepack.45439/ +Available to download on: +- [Modrinth](https://modrinth.com/plugin/forcepack) +- [Spigot](https://www.spigotmc.org/resources/forcepack.45439) ## Features +- Support for Spigot/Paper/Folia, Velocity, Sponge - Support for 1.20.3+ multiple resource packs - Ability to set resource packs on a per-version basis - Local webserver resource pack hosting diff --git a/api/build.gradle.kts b/api/build.gradle.kts index f94a347..8939ea0 100644 --- a/api/build.gradle.kts +++ b/api/build.gradle.kts @@ -1,8 +1,8 @@ plugins { + id("buildlogic.java-common-conventions") `maven-publish` } - repositories { maven("https://repo.opencollab.dev/main/") } diff --git a/api/src/main/java/com/convallyria/forcepack/api/ForcePackAPI.java b/api/src/main/java/com/convallyria/forcepack/api/ForcePackAPI.java index 1a2b248..129e84e 100644 --- a/api/src/main/java/com/convallyria/forcepack/api/ForcePackAPI.java +++ b/api/src/main/java/com/convallyria/forcepack/api/ForcePackAPI.java @@ -27,4 +27,5 @@ public interface ForcePackAPI { * @return true if the player was successfully exempted, false if they were already on the exemption list. */ boolean exemptNextResourcePackSend(UUID uuid); + } diff --git a/api/src/main/java/com/convallyria/forcepack/api/ForcePackPlatform.java b/api/src/main/java/com/convallyria/forcepack/api/ForcePackPlatform.java new file mode 100644 index 0000000..bf071e8 --- /dev/null +++ b/api/src/main/java/com/convallyria/forcepack/api/ForcePackPlatform.java @@ -0,0 +1,123 @@ +package com.convallyria.forcepack.api; + +import com.convallyria.forcepack.api.resourcepack.PackFormatResolver; +import com.convallyria.forcepack.api.resourcepack.ResourcePack; +import com.convallyria.forcepack.api.resourcepack.ResourcePackVersion; + +import java.util.Arrays; +import java.util.HashSet; +import java.util.List; +import java.util.Optional; +import java.util.Set; + +public interface ForcePackPlatform extends ForcePackAPI { + + /** + * Gets whether the specified URL is a default hosted one. + * @param url the url to check + * @return true if site is a default host + */ + default boolean isDefaultHost(String url) { + List warnForHost = List.of("convallyria.com"); + for (String host : warnForHost) { + if (url.contains(host)) { + return true; + } + } + return false; + } + + /** + * Gets, if there is one, the blacklisted site within a URL + * @param url the url to check + * @return an {@link Optional} possibly containing the URL of the blacklisted site found + */ + default Optional getBlacklistedSite(String url) { + List blacklisted = List.of("mediafire.com"); + for (String blacklistedSite : blacklisted) { + if (url.contains(blacklistedSite)) { + return Optional.of(url); + } + } + return Optional.empty(); + } + + /** + * Gets whether the specified URL has a valid ending + * @param url the url to check + * @return true if URL ends with a valid extension + */ + default boolean isValidEnding(String url) { + List validUrlEndings = Arrays.asList(".zip", "dl=1"); + boolean hasEnding = false; + for (String validUrlEnding : validUrlEndings) { + if (url.endsWith(validUrlEnding)) { + hasEnding = true; + break; + } + } + return hasEnding; + } + + default ResourcePackVersion getVersionFromId(String versionId) { + if (versionId.equals("all")) { + return null; + } + + try { + // One version? + final int fixedVersion = Integer.parseInt(versionId); + return ResourcePackVersion.of(fixedVersion, fixedVersion); + } catch (NumberFormatException ignored) { + try { + // Version range? + final String[] ranged = versionId.split("-"); + final int min = Integer.parseInt(ranged[0]); + final int max = Integer.parseInt(ranged[1]); + return ResourcePackVersion.of(min, max); + } catch (NumberFormatException | IndexOutOfBoundsException ignored2) {} + } + + throw new IllegalArgumentException("Invalid version id: " + versionId); + } + + default Set getPacksForVersion(int protocolVersion) { + final int packFormat = PackFormatResolver.getPackFormat(protocolVersion); + + log("Searching for a resource pack with pack version " + packFormat); + + ResourcePack anyVersionPack = null; + Set validPacks = new HashSet<>(); + for (ResourcePack resourcePack : getResourcePacks()) { + final Optional version = resourcePack.getVersion(); + log("Trying resource pack " + resourcePack.getURL() + " (" + (version.isEmpty() ? version.toString() : version.get().toString()) + ")"); + + if (version.isEmpty()) { + if (anyVersionPack == null) anyVersionPack = resourcePack; // Pick first all-version resource pack + validPacks.add(resourcePack); // This is still a valid pack that we want to apply. + continue; + } + + if (version.get().inVersion(packFormat)) { + validPacks.add(resourcePack); + log("Added resource pack " + resourcePack.getURL()); + if (protocolVersion < 765) { // If < 1.20.3, only one pack can be applied. + break; + } + } + } + + if (!validPacks.isEmpty()) { + log("Found multiple valid resource packs (" + validPacks.size() + ")"); + for (ResourcePack validPack : validPacks) { + log("Chosen resource pack " + validPack.getURL()); + } + return validPacks; + } + + log("Chosen resource pack is " + (anyVersionPack == null ? "null" : anyVersionPack.getURL())); + return anyVersionPack == null ? Set.of() : Set.of(anyVersionPack); + } + + void log(String info, Object... format); +} diff --git a/api/src/main/java/com/convallyria/forcepack/api/utils/HashingUtil.java b/api/src/main/java/com/convallyria/forcepack/api/utils/HashingUtil.java index 8af30e6..4400b74 100644 --- a/api/src/main/java/com/convallyria/forcepack/api/utils/HashingUtil.java +++ b/api/src/main/java/com/convallyria/forcepack/api/utils/HashingUtil.java @@ -2,16 +2,14 @@ import com.convallyria.forcepack.api.verification.ResourcePackURLData; import jakarta.xml.bind.DatatypeConverter; -import org.checkerframework.checker.nullness.qual.Nullable; import java.io.InputStream; import java.net.URL; import java.net.URLConnection; import java.security.MessageDigest; -import java.util.function.Consumer; public class HashingUtil { - + public static String toHexString(byte[] array) { return DatatypeConverter.printHexBinary(array); } diff --git a/api/src/main/java/com/convallyria/forcepack/api/utils/TriConsumer.java b/api/src/main/java/com/convallyria/forcepack/api/utils/TriConsumer.java index e1ebeda..27b3bec 100644 --- a/api/src/main/java/com/convallyria/forcepack/api/utils/TriConsumer.java +++ b/api/src/main/java/com/convallyria/forcepack/api/utils/TriConsumer.java @@ -14,4 +14,4 @@ default TriConsumer andThen(TriConsumer scheduler; @@ -80,41 +79,7 @@ public Set getResourcePacks() { public Set getPacksForVersion(Player player) { final int protocolVersion = ProtocolUtil.getProtocolVersion(player); - final int packFormat = PackFormatResolver.getPackFormat(protocolVersion); - - log("Searching for a resource pack with pack version " + packFormat); - - ResourcePack anyVersionPack = null; - Set validPacks = new HashSet<>(); - for (ResourcePack resourcePack : getResourcePacks()) { - final Optional version = resourcePack.getVersion(); - log("Trying resource pack " + resourcePack.getURL() + " (" + (version.isEmpty() ? version.toString() : version.get().toString()) + ")"); - - if (version.isEmpty()) { - if (anyVersionPack == null) anyVersionPack = resourcePack; // Pick first all-version resource pack - validPacks.add(resourcePack); // This is still a valid pack that we want to apply. - continue; - } - - if (version.get().inVersion(packFormat)) { - validPacks.add(resourcePack); - log("Added resource pack " + resourcePack.getURL()); - if (protocolVersion < 765) { // If < 1.20.3, only one pack can be applied. - break; - } - } - } - - if (!validPacks.isEmpty()) { - log("Found multiple valid resource packs (" + validPacks.size() + ")"); - for (ResourcePack validPack : validPacks) { - log("Chosen resource pack " + validPack.getURL()); - } - return validPacks; - } - - log("Chosen resource pack is " + (anyVersionPack == null ? "null" : anyVersionPack.getURL())); - return anyVersionPack == null ? Set.of() : Set.of(anyVersionPack); + return getPacksForVersion(protocolVersion); } @Override @@ -304,28 +269,6 @@ public void reload() { } } - private ResourcePackVersion getVersionFromId(String versionId) { - if (versionId.equals("all")) { - return null; - } - - try { - // One version? - final int fixedVersion = Integer.parseInt(versionId); - return ResourcePackVersion.of(fixedVersion, fixedVersion); - } catch (NumberFormatException ignored) { - try { - // Version range? - final String[] ranged = versionId.split("-"); - final int min = Integer.parseInt(ranged[0]); - final int max = Integer.parseInt(ranged[1]); - return ResourcePackVersion.of(min, max); - } catch (NumberFormatException | IndexOutOfBoundsException ignored2) {} - } - - throw new IllegalArgumentException("Invalid version id: " + versionId); - } - private boolean checkPack(@Nullable ResourcePackVersion version, String url, boolean generateHash, @Nullable String hash) { if (url.startsWith("forcepack://")) { // Localhost final File generatedFilePath = new File(getDataFolder() + File.separator + url.replace("forcepack://", "")); @@ -442,16 +385,7 @@ private void createConfig() { } private void checkValidEnding(String url) { - List validUrlEndings = Arrays.asList(".zip", "dl=1"); - boolean hasEnding = false; - for (String validUrlEnding : validUrlEndings) { - if (url.endsWith(validUrlEnding)) { - hasEnding = true; - break; - } - } - - if (!hasEnding) { + if (!isValidEnding(url)) { getLogger().severe("Your URL has an invalid or unknown format. " + "URLs must have no redirects and use the .zip extension. If you are using Dropbox, change dl=0 to dl=1."); getLogger().severe("ForcePack will still load in the event this check is incorrect. Please make an issue or pull request if this is so."); @@ -459,28 +393,16 @@ private void checkValidEnding(String url) { } private void checkForRehost(String url) { - List warnForHost = List.of("convallyria.com"); - boolean rehosted = true; - for (String host : warnForHost) { - if (url.contains(host)) { - rehosted = false; - break; - } - } - - if (!rehosted) { + if (isDefaultHost(url)) { getLogger().warning(String.format("[%s] You are using a default resource pack provided by the plugin. ", url) + " It's highly recommended you re-host this pack using the webserver or on a CDN such as https://mc-packs.net for faster load times. " + "Leaving this as default potentially sends a lot of requests to my personal web server, which isn't ideal!"); getLogger().warning("ForcePack will still load and function like normally."); } - List blacklisted = List.of("mediafire.com"); - for (String blacklistedSite : blacklisted) { - if (url.contains(blacklistedSite)) { - getLogger().severe("Invalid resource pack site used! '" + blacklistedSite + "' cannot be used for hosting resource packs!"); - } - } + getBlacklistedSite(url).ifPresent(blacklistedSite -> { + getLogger().severe("Invalid resource pack site used! '" + blacklistedSite + "' cannot be used for hosting resource packs!"); + }); } private void performLegacyCheck() throws IOException { @@ -536,8 +458,9 @@ public boolean debug() { return getConfig().getBoolean("Server.debug"); } - public void log(String info) { - if (debug()) getLogger().info(info); + @Override + public void log(String info, Object... format) { + if (debug()) getLogger().info(String.format(info, format)); } public static ForcePackAPI getAPI() { diff --git a/sponge/build.gradle.kts b/sponge/build.gradle.kts new file mode 100644 index 0000000..2cb9f23 --- /dev/null +++ b/sponge/build.gradle.kts @@ -0,0 +1,71 @@ +import org.spongepowered.gradle.plugin.config.PluginLoaders +import org.spongepowered.plugin.metadata.model.PluginDependency + +plugins { + `java-library` + id("buildlogic.java-platform-conventions") + id("org.spongepowered.gradle.plugin") version "2.3.0" +} + +sponge { + apiVersion("14.0.0-SNAPSHOT") + loader { + name(PluginLoaders.JAVA_PLAIN) + version("1.0.0-SNAPSHOT") + } + + plugin("forcepack") { + displayName("ForcePack") + description("Resource pack handling utilities and enforcement, with Velocity and multiple resource packs support. ") + entrypoint("com.convallyria.forcepack.sponge.ForcePackSponge") + links { + homepage("https://github.com/SamB440/ForcePack") + source("https://github.com/SamB440/ForcePack") + issues("https://github.com/SamB440/ForcePack/issues") + } + license("GPL-3") + + dependency("spongeapi") { + loadOrder(PluginDependency.LoadOrder.AFTER) + optional(false) + } + } +} + +repositories { + repositories { + maven("https://repo.convallyria.com/releases") + maven("https://repo.convallyria.com/snapshots") + maven("https://repo.codemc.io/repository/maven-snapshots/") + maven("https://repo.viaversion.com") + } +} + +dependencies { + implementation(project(":api")) + implementation(project(":webserver", "shadow")) + // TODO use grim fork + implementation("com.github.retrooper:packetevents-sponge:2.8.0-SNAPSHOT") + implementation("org.bstats:bstats-sponge:3.0.2") + implementation("org.incendo:cloud-sponge:2.0.0-SNAPSHOT") { + exclude("org.checkerframework") + exclude("io.leangen.geantyref") + } + + compileOnly("com.google.guava:guava:33.4.0-jre") + compileOnly("com.viaversion:viaversion-api:4.9.2") +} + +tasks { + shadowJar { + minimize { + exclude(project(":webserver")) + } + + relocate("org.bstats", "forcepack.libs.bstats") + relocate("net.kyori.adventure.nbt", "forcepack.libs.adventure.nbt") + relocate("net.kyori.examination", "forcepack.libs.adventure.ex") + + duplicatesStrategy = DuplicatesStrategy.EXCLUDE + } +} diff --git a/sponge/src/main/java/com/convallyria/forcepack/sponge/ForcePackSponge.java b/sponge/src/main/java/com/convallyria/forcepack/sponge/ForcePackSponge.java new file mode 100644 index 0000000..1179dbe --- /dev/null +++ b/sponge/src/main/java/com/convallyria/forcepack/sponge/ForcePackSponge.java @@ -0,0 +1,525 @@ +package com.convallyria.forcepack.sponge; + +import com.convallyria.forcepack.api.ForcePackAPI; +import com.convallyria.forcepack.api.ForcePackPlatform; +import com.convallyria.forcepack.api.player.ForcePackPlayer; +import com.convallyria.forcepack.api.resourcepack.ResourcePack; +import com.convallyria.forcepack.api.resourcepack.ResourcePackVersion; +import com.convallyria.forcepack.api.schedule.PlatformScheduler; +import com.convallyria.forcepack.api.utils.ClientVersion; +import com.convallyria.forcepack.api.utils.GeyserUtil; +import com.convallyria.forcepack.api.utils.HashingUtil; +import com.convallyria.forcepack.api.verification.ResourcePackURLData; +import com.convallyria.forcepack.sponge.command.Commands; +import com.convallyria.forcepack.sponge.event.MultiVersionResourcePackStatusEvent; +import com.convallyria.forcepack.sponge.listener.ExemptionListener; +import com.convallyria.forcepack.sponge.listener.PacketListener; +import com.convallyria.forcepack.sponge.listener.ResourcePackListener; +import com.convallyria.forcepack.sponge.player.ForcePackSpongePlayer; +import com.convallyria.forcepack.sponge.resourcepack.SpongeResourcePack; +import com.convallyria.forcepack.sponge.schedule.SpongeScheduler; +import com.convallyria.forcepack.sponge.util.FileSystemUtils; +import com.convallyria.forcepack.sponge.util.ProtocolUtil; +import com.convallyria.forcepack.webserver.ForcePackWebServer; +import com.convallyria.forcepack.webserver.downloader.WebServerDependencyDownloader; +import com.github.retrooper.packetevents.PacketEvents; +import com.google.inject.Inject; +import io.github.retrooper.packetevents.sponge.factory.SpongePacketEventsBuilder; +import net.kyori.adventure.key.Key; +import net.kyori.adventure.resource.ResourcePackStatus; +import net.kyori.adventure.text.Component; +import net.kyori.adventure.text.format.NamedTextColor; +import net.kyori.adventure.translation.GlobalTranslator; +import net.kyori.adventure.translation.TranslationRegistry; +import org.apache.logging.log4j.Logger; +import org.bstats.sponge.Metrics; +import org.checkerframework.checker.nullness.qual.NonNull; +import org.checkerframework.checker.nullness.qual.Nullable; +import org.spongepowered.api.ResourceKey; +import org.spongepowered.api.Server; +import org.spongepowered.api.Sponge; +import org.spongepowered.api.config.ConfigDir; +import org.spongepowered.api.entity.living.player.server.ServerPlayer; +import org.spongepowered.api.event.EventManager; +import org.spongepowered.api.event.Listener; +import org.spongepowered.api.event.lifecycle.RegisterChannelEvent; +import org.spongepowered.api.event.lifecycle.StartingEngineEvent; +import org.spongepowered.api.network.ServerConnectionState; +import org.spongepowered.api.network.channel.raw.RawDataChannel; +import org.spongepowered.configurate.CommentedConfigurationNode; +import org.spongepowered.configurate.ConfigurationNode; +import org.spongepowered.configurate.loader.ConfigurationLoader; +import org.spongepowered.configurate.yaml.YamlConfigurationLoader; +import org.spongepowered.plugin.PluginContainer; +import org.spongepowered.plugin.builtin.jvm.Plugin; + +import java.io.File; +import java.io.FileReader; +import java.io.IOException; +import java.nio.file.FileAlreadyExistsException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.HashSet; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Optional; +import java.util.Properties; +import java.util.ResourceBundle; +import java.util.Set; +import java.util.UUID; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.function.Consumer; +import java.util.stream.Collectors; +import java.util.stream.Stream; + +@Plugin("forcepack") +public class ForcePackSponge implements ForcePackPlatform { + + private final PluginContainer pluginContainer; + private final Logger logger; + private final Path configDir; + private final SpongeScheduler scheduler; + + public final Set temporaryExemptedPlayers = new HashSet<>(); + + @Override + public boolean exemptNextResourcePackSend(UUID uuid) { + return temporaryExemptedPlayers.add(uuid); + } + + @Inject + public ForcePackSponge(PluginContainer pluginContainer, Logger logger, @ConfigDir(sharedRoot = false) Path configDir, Metrics.Factory metrics) { + INSTANCE = this; + this.pluginContainer = pluginContainer; + this.logger = logger; + this.configDir = configDir; + this.scheduler = new SpongeScheduler(this); + this.loadConfig(); + this.registerCommands(); + metrics.make(13677); + } + + private final Map> resourcePacks = new HashMap<>(); + + @Override + public Set getResourcePacks() { + return resourcePacks.values().stream() + .flatMap(Set::stream) + .collect(Collectors.toCollection(LinkedHashSet::new)); + } + + @Listener + public void onServerStarting(final StartingEngineEvent event) { + registerTranslations(); + + PacketEvents.setAPI(SpongePacketEventsBuilder.build(pluginContainer)); + PacketEvents.getAPI().getSettings().debug(debug()).checkForUpdates(false); + PacketEvents.getAPI().load(); + + GeyserUtil.isGeyserInstalledHere = Sponge.pluginManager().plugin("geyser-sponge").isPresent(); + + this.registerListeners(); + PacketEvents.getAPI().init(); + + // Check server properties + try { + this.checkForServerProperties(); + } catch (IOException e) { + getLogger().error("Failed to check for server properties resource pack", e); + } + + Runnable run = () -> { + if (getConfig().node("web-server", "enabled").getBoolean()) { + try { + getLogger().info("Enabling web server..."); + getLogger().info("Downloading required dependencies, this might take a while! Subsequent startups will be faster."); + WebServerDependencyDownloader.download(this, configDir, this::log); + getLogger().info("Finished downloading required dependencies."); + final String configIp = getConfig().node("web-server", "server-ip").getString("localhost"); + final String serverIp = !configIp.equals("localhost") ? configIp : ForcePackWebServer.getIp(); + this.webServer = new ForcePackWebServer(configDir, serverIp, getConfig().node("web-server", "port").getInt(8080)); + getLogger().info("Started web server."); + } catch (IOException e) { + getLogger().error("Error starting web server: {}", e.getMessage()); + getLogger().error("It is highly likely you need to open a port or change it in the config. Please see the config for further information."); + return; + } + } + + reload(); + + this.getLogger().info("[ForcePack] Enabled!"); + }; + + if (getConfig().node("load-last").getBoolean()) { + scheduler.registerInitTask(run); + } else { + run.run(); + } + + if (GeyserUtil.isGeyserInstalledHere && !getConfig().node("Server", "geyser").getBoolean()) { + getLogger().warn("Geyser is installed but Geyser support is not enabled."); + } else if (!GeyserUtil.isGeyserInstalledHere && getConfig().node("Server", "geyser").getBoolean()) { + getLogger().warn("Geyser is not installed but Geyser support is enabled."); + } + } + + public Set getPacksForVersion(ServerPlayer player) { + final int protocolVersion = ProtocolUtil.getProtocolVersion(player); + return getPacksForVersion(protocolVersion); + } + + private final Map waiting = new HashMap<>(); + + public void processWaitingResourcePack(ServerPlayer player, UUID packId) { + final UUID playerId = player.uniqueId(); + // If the player is on a version older than 1.20.3, they can only have one resource pack. + if (ProtocolUtil.getProtocolVersion(player) < 765) { + removeFromWaiting(player); + return; + } + + final ForcePackPlayer newPlayer = waiting.computeIfPresent(playerId, (a, forcePackPlayer) -> { + final Set packs = forcePackPlayer.getWaitingPacks(); + packs.removeIf(pack -> pack.getUUID().equals(packId)); + return forcePackPlayer; + }); + + if (newPlayer == null || newPlayer.getWaitingPacks().isEmpty()) { + removeFromWaiting(player); + } + } + + public Optional getForcePackPlayer(ServerPlayer player) { + return Optional.ofNullable(waiting.get(player.uniqueId())); + } + + public boolean isWaiting(ServerPlayer player) { + return waiting.containsKey(player.uniqueId()); + } + + public boolean isWaitingFor(ServerPlayer player, UUID packId) { + if (!isWaiting(player)) return false; + + // If the player is on a version older than 1.20.3, they can only have one resource pack. + if (ProtocolUtil.getProtocolVersion(player) < 765) { + return true; + } + + final Set waitingPacks = waiting.get(player.uniqueId()).getWaitingPacks(); + return waitingPacks.stream().anyMatch(pack -> pack.getUUID().equals(packId)); + } + + public void removeFromWaiting(ServerPlayer player) { + waiting.remove(player.uniqueId()); + } + + public void addToWaiting(UUID uuid, @NonNull Set packs) { + waiting.compute(uuid, (a, existing) -> { + ForcePackPlayer newPlayer = existing != null ? existing : new ForcePackSpongePlayer(Sponge.server().player(uuid).orElseThrow()); + newPlayer.getWaitingPacks().addAll(packs); + return newPlayer; + }); + } + + private @Nullable ForcePackWebServer webServer; + + public Optional getWebServer() { + return Optional.ofNullable(webServer); + } + + public void reload() { + if (getConfig().node("velocity-mode").getBoolean()) return; + + resourcePacks.clear(); // Clear for reloads + getWebServer().ifPresent(ForcePackWebServer::clearHostedPacks); + + final ConfigurationNode packs = getConfig().node("Server", "packs"); + boolean success = true; + try { + for (Object key : packs.childrenMap().keySet()) { + String versionId = key.toString(); + ResourcePackVersion version = getVersionFromId(versionId); + final ConfigurationNode packSection = packs.node(versionId); + final List urls = packSection.hasChild("urls") + ? packSection.node("urls").getList(String.class, new ArrayList<>()) + : List.of(packSection.node("url").getString("")); + final List hashes = packSection.hasChild("hashes") + ? packSection.node("hashes").getList(String.class, new ArrayList<>()) + : List.of(packSection.node("hash").getString("")); + + final boolean generateHash = packSection.node("generate-hash").getBoolean(); + if (!generateHash && urls.size() != hashes.size()) { + getLogger().error("There are not the same amount of URLs and hashes! Please provide a hash for every resource pack URL!"); + } + + for (int i = 0; i < urls.size(); i++) { + String url = urls.get(i); + String hash = i >= hashes.size() ? null : hashes.get(i); + success = success && checkPack(version, url, generateHash, hash); + } + } + } catch (Exception e) { + throw new RuntimeException(e); + } + + if (!success) { + getLogger().error("Unable to load all resource packs correctly."); + } + } + + private boolean checkPack(@Nullable ResourcePackVersion version, String url, boolean generateHash, @Nullable String hash) { + if (url.startsWith("forcepack://")) { // Localhost + final File generatedFilePath = new File(configDir + File.separator + url.replace("forcepack://", "")); + log("Using local resource pack host for " + url + " (" + generatedFilePath + ")"); + if (webServer == null) { + getLogger().error("Unable to locally host resource pack '{}' because the web server is not active!", url); + return false; + } + webServer.addHostedPack(generatedFilePath); + url = webServer.getHostedEndpoint(url); + } + + checkForRehost(url); + checkValidEnding(url); + + AtomicInteger sizeMB = new AtomicInteger(); + + ResourcePackURLData data = null; + if (generateHash) { + getLogger().info("Auto-generating resource pack hash."); + try { + data = HashingUtil.performPackCheck(url, hash); + sizeMB.set(data.getSize()); + hash = data.getUrlHash(); + getLogger().info("Size of resource pack: {} MB", sizeMB.get()); + getLogger().info("Auto-generated resource pack hash: {}", hash); + } catch (Exception e) { + getLogger().error("Unable to auto-generate resource pack hash, reverting to config setting", e); + } + } + + if (getConfig().node("enable-mc-164316-fix").getBoolean()) { + url = url + "#" + hash; + } + + if (getConfig().node("Server", "verify").getBoolean()) { + try { + Consumer consumer = (size) -> { + getLogger().info("Performing version size check..."); + for (ClientVersion clientVersion : ClientVersion.values()) { + String sizeStr = clientVersion.getDisplay() + " (" + clientVersion.getMaxSizeMB() + " MB): "; + if (clientVersion.getMaxSizeMB() < size) { + // Paper support - use console sender for colour + Sponge.systemSubject().sendMessage(Component.text(sizeStr + "Unsupported.", NamedTextColor.RED)); + } else { + // Paper support - use console sender for colour + Sponge.systemSubject().sendMessage(Component.text(sizeStr + "Supported.", NamedTextColor.GREEN)); + } + } + + sizeMB.set(size); + }; + + if (data == null) { + data = HashingUtil.performPackCheck(url, hash); + } + + consumer.accept(data.getSize()); + + if (hash == null || !hash.equalsIgnoreCase(data.getUrlHash())) { + this.getLogger().error("-----------------------------------------------"); + this.getLogger().error("Your hash does not match the URL file provided!"); + this.getLogger().error("The URL hash returned: {}", data.getUrlHash()); + this.getLogger().error("Your config hash returned: {}", data.getConfigHash()); + this.getLogger().error("Please provide a correct SHA-1 hash!"); + this.getLogger().error("-----------------------------------------------"); + } else { + // Paper support - use console sender for colour + Sponge.systemSubject().sendMessage(Component.text("Hash verification complete.", NamedTextColor.GREEN)); + } + } catch (Exception e) { + this.getLogger().error("Please provide a correct SHA-1 hash/url!", e); + return false; + } + } + + final String finalUrl = url; + final String finalHash = hash; + resourcePacks.compute(version, (u, existingPacks) -> { + Set packs = existingPacks == null ? new HashSet<>() : existingPacks; + final SpongeResourcePack pack = new SpongeResourcePack(this, finalUrl, finalHash, sizeMB.get(), version); + packs.add(pack); + this.getLogger().info("Generated resource pack ({}) for version {} with id {}", pack.getURL(), version == null ? "all" : version, pack.getUUID()); + return packs; + }); + return true; + } + + @Listener + public void onRegisterChannels(RegisterChannelEvent event) { + if (!getConfig().node("velocity-mode").getBoolean()) return; + getLogger().info("Enabled velocity listener"); + final RawDataChannel channel = event.register(ResourceKey.of("forcepack", "status"), RawDataChannel.class); + channel.play().addHandler(ServerConnectionState.Game.class, (message, state) -> { + final ServerPlayer player = state.player(); + final String data = new String(message.readBytes(message.available())); + final String[] split = data.split(";"); + log("Posted event"); + + final ResourcePackStatus status = ResourcePackStatus.valueOf(split[1]); + final UUID packId = UUID.fromString(split[0]); + final boolean proxyRemove = Boolean.parseBoolean(split[2]); + getScheduler().executeAsync(() -> Sponge.eventManager().post(new MultiVersionResourcePackStatusEvent(player, packId, status, true, proxyRemove))); + }); + } + + private void registerListeners() { + EventManager pm = Sponge.eventManager(); + + pm.registerListeners(pluginContainer, new ResourcePackListener(this)); + pm.registerListeners(pluginContainer, new ExemptionListener(this)); + + PacketEvents.getAPI().getEventManager().registerListeners(new PacketListener(this)); + } + + private void registerCommands() { + new Commands(this); + } + + private ConfigurationNode rootNode; + + public ConfigurationNode getConfig() { + return rootNode; + } + + public void reloadConfig() { + this.loadConfig(); + } + + private void loadConfig() { + if (!configDir.toFile().exists()) configDir.toFile().mkdirs(); + final Path configPath = configDir.resolve("config.yml"); + try { + Files.copy(pluginContainer.openResource("/assets/forcepack/config.yml").orElseThrow(), configPath); + } catch (FileAlreadyExistsException ignored) { + } catch (IOException e) { + throw new RuntimeException(e); + } + + ConfigurationLoader loader = YamlConfigurationLoader.builder().path(configPath).build(); + try { + rootNode = loader.load(); + } catch (IOException e) { + throw new RuntimeException(e); + } + } + + private void checkValidEnding(String url) { + if (!isValidEnding(url)) { + getLogger().error("Your URL has an invalid or unknown format. " + + "URLs must have no redirects and use the .zip extension. If you are using Dropbox, change dl=0 to dl=1."); + getLogger().error("ForcePack will still load in the event this check is incorrect. Please make an issue or pull request if this is so."); + } + } + + private void checkForRehost(String url) { + if (isDefaultHost(url)) { + getLogger().warn(String.format("[%s] You are using a default resource pack provided by the plugin. ", url) + + " It's highly recommended you re-host this pack using the webserver or on a CDN such as https://mc-packs.net for faster load times. " + + "Leaving this as default potentially sends a lot of requests to my personal web server, which isn't ideal!"); + getLogger().warn("ForcePack will still load and function like normally."); + } + + getBlacklistedSite(url).ifPresent(blacklistedSite -> { + getLogger().error("Invalid resource pack site used! '{}' cannot be used for hosting resource packs!", blacklistedSite); + }); + } + + private void checkForServerProperties() throws IOException { + Properties properties = new Properties(); + try (FileReader reader = new FileReader("./server.properties")) { + properties.load(reader); + String packUrl = properties.getProperty("resource-pack"); + if (packUrl != null && !packUrl.isEmpty()) { + getLogger().error("You have a resource pack set in server.properties!"); + getLogger().error("This will cause ForcePack to not function correctly. You MUST remove the resource pack URL from server.properties!"); + } + } + } + + + private void registerTranslations() { + final TranslationRegistry translationRegistry = TranslationRegistry.create(Key.key("battlegrounds", "translations")); + translationRegistry.defaultLocale(Locale.US); + + try { + FileSystemUtils.visitResources(ForcePackSponge.class, path -> { + this.getLogger().info("Loading localizations..."); + + try (final Stream stream = Files.walk(path)) { + stream.forEach(file -> { + if (!Files.isRegularFile(file)) { + return; + } + + final String filename = com.google.common.io.Files.getNameWithoutExtension(file.getFileName().toString()); + final String localeName = filename + .replace("messages_", "") + .replace("messages", "") + .replace('_', '-'); + final Locale locale = localeName.isEmpty() ? Locale.US : Locale.forLanguageTag(localeName); + + translationRegistry.registerAll(locale, ResourceBundle.getBundle("org/empirewar/battlegrounds/l10n/messages", + locale), false); + + this.getLogger().info("Loaded translations for {}.", locale.getDisplayName()); + }); + } catch (final IOException e) { + getLogger().warn("Encountered an I/O error whilst loading translations", e); + } + }, "org", "empirewar", "battlegrounds", "l10n"); + } catch (final IOException e) { + getLogger().warn("Encountered an I/O error whilst loading translations", e); + return; + } + + GlobalTranslator.translator().addSource(translationRegistry); + } + + public PluginContainer pluginContainer() { + return pluginContainer; + } + + @Override + public PlatformScheduler getScheduler() { + return scheduler; + } + + public boolean debug() { + return getConfig().node("Server", "debug").getBoolean(); + } + + @Override + public void log(String info, Object... format) { + if (debug()) getLogger().info(String.format(info, format)); + } + + public Logger getLogger() { + return logger; + } + + private static ForcePackSponge INSTANCE; + + public static ForcePackAPI getAPI() { + return getInstance(); + } + + public static ForcePackSponge getInstance() { + return INSTANCE; + } +} diff --git a/sponge/src/main/java/com/convallyria/forcepack/sponge/command/Commands.java b/sponge/src/main/java/com/convallyria/forcepack/sponge/command/Commands.java new file mode 100644 index 0000000..6bc91c4 --- /dev/null +++ b/sponge/src/main/java/com/convallyria/forcepack/sponge/command/Commands.java @@ -0,0 +1,30 @@ +package com.convallyria.forcepack.sponge.command; + +import com.convallyria.forcepack.sponge.ForcePackSponge; +import org.incendo.cloud.SenderMapper; +import org.incendo.cloud.annotations.AnnotationParser; +import org.incendo.cloud.execution.ExecutionCoordinator; +import org.incendo.cloud.sponge.SpongeCommandManager; +import org.spongepowered.api.command.CommandCause; + +public class Commands { + + public Commands(ForcePackSponge plugin) { + + final SpongeCommandManager manager; + try { + manager = new SpongeCommandManager<>( + plugin.pluginContainer(), + ExecutionCoordinator.simpleCoordinator(), + SenderMapper.identity() + ); + } catch (Exception e) { + plugin.getLogger().error("Failed to initialize the command manager", e); + return; + } + + // This will allow you to decorate commands with descriptions + final AnnotationParser annotationParser = new AnnotationParser<>(manager, CommandCause.class); + annotationParser.parse(new ForcePackCommand(plugin)); + } +} diff --git a/sponge/src/main/java/com/convallyria/forcepack/sponge/command/ForcePackCommand.java b/sponge/src/main/java/com/convallyria/forcepack/sponge/command/ForcePackCommand.java new file mode 100644 index 0000000..3ec92e8 --- /dev/null +++ b/sponge/src/main/java/com/convallyria/forcepack/sponge/command/ForcePackCommand.java @@ -0,0 +1,70 @@ +package com.convallyria.forcepack.sponge.command; + +import com.convallyria.forcepack.api.permission.Permissions; +import com.convallyria.forcepack.api.resourcepack.ResourcePack; +import com.convallyria.forcepack.api.utils.GeyserUtil; +import com.convallyria.forcepack.sponge.ForcePackSponge; +import com.convallyria.forcepack.sponge.event.ForcePackReloadEvent; +import com.convallyria.forcepack.sponge.util.ProtocolUtil; +import com.github.retrooper.packetevents.PacketEvents; +import com.github.retrooper.packetevents.wrapper.play.server.WrapperPlayServerResourcePackRemove; +import net.kyori.adventure.text.Component; +import net.kyori.adventure.text.format.NamedTextColor; +import org.incendo.cloud.annotations.Argument; +import org.incendo.cloud.annotations.Command; +import org.incendo.cloud.annotations.CommandDescription; +import org.incendo.cloud.annotations.Default; +import org.incendo.cloud.annotations.Permission; +import org.spongepowered.api.Sponge; +import org.spongepowered.api.command.CommandCause; +import org.spongepowered.api.entity.living.player.server.ServerPlayer; + +import java.util.Set; +import java.util.UUID; + +public class ForcePackCommand { + + private final ForcePackSponge plugin; + + public ForcePackCommand(final ForcePackSponge plugin) { + this.plugin = plugin; + } + + @CommandDescription("Default ForcePack command") + @Command("forcepack") + public void onDefault(CommandCause sender) { + sender.sendMessage(Component.text("ForcePack by SamB440. Type /forcepack help for help.", NamedTextColor.GREEN)); + } + + @CommandDescription("Reloads the plugin config along with the resource pack") + @Permission(Permissions.RELOAD) + @Command("forcepack reload [send]") + public void onReload(CommandCause sender, + @Argument(value = "send", description = "Whether to send the updated resource pack to players") @Default("true") boolean send) { + sender.sendMessage(Component.text("Reloading...", NamedTextColor.GREEN)); + plugin.reloadConfig(); + plugin.reload(); + PacketEvents.getAPI().getSettings().debug(plugin.debug()); + Sponge.eventManager().post(new ForcePackReloadEvent()); + if (!plugin.getConfig().node("velocity-mode").getBoolean() && send) { + for (ServerPlayer player : Sponge.server().onlinePlayers()) { + if (plugin.isWaiting(player)) continue; + boolean geyser = plugin.getConfig().node("Server", "geyser").getBoolean() && GeyserUtil.isBedrockPlayer(player.uniqueId()); + boolean canBypass = player.hasPermission(Permissions.BYPASS) && plugin.getConfig().node("Server", "bypass-permission").getBoolean(); + plugin.log(player.name() + "'s exemptions: geyser, " + geyser + ". permission, " + canBypass + "."); + if (geyser || canBypass) continue; + + player.sendMessage(Component.translatable("forcepack.reloading")); + + final Set resourcePacks = plugin.getPacksForVersion(player); + plugin.addToWaiting(player.uniqueId(), resourcePacks); + if (ProtocolUtil.getProtocolVersion(player) >= 765) { // 1.20.3+ + ProtocolUtil.sendPacketBypassingVia(player, new WrapperPlayServerResourcePackRemove((UUID) null)); + } + resourcePacks.forEach(pack -> pack.setResourcePack(player.uniqueId())); + } + } + + sender.sendMessage(Component.text("Done!", NamedTextColor.GREEN)); + } +} diff --git a/sponge/src/main/java/com/convallyria/forcepack/sponge/event/ForcePackReloadEvent.java b/sponge/src/main/java/com/convallyria/forcepack/sponge/event/ForcePackReloadEvent.java new file mode 100644 index 0000000..f4effdd --- /dev/null +++ b/sponge/src/main/java/com/convallyria/forcepack/sponge/event/ForcePackReloadEvent.java @@ -0,0 +1,20 @@ +package com.convallyria.forcepack.sponge.event; + +import com.convallyria.forcepack.sponge.ForcePackSponge; +import org.spongepowered.api.event.Cause; +import org.spongepowered.api.event.EventContext; +import org.spongepowered.api.event.impl.AbstractEvent; + +public class ForcePackReloadEvent extends AbstractEvent { + + private final Cause cause; + + public ForcePackReloadEvent() { + this.cause = Cause.of(EventContext.empty(), ForcePackSponge.getInstance()); + } + + @Override + public Cause cause() { + return cause; + } +} diff --git a/sponge/src/main/java/com/convallyria/forcepack/sponge/event/MultiVersionResourcePackStatusEvent.java b/sponge/src/main/java/com/convallyria/forcepack/sponge/event/MultiVersionResourcePackStatusEvent.java new file mode 100644 index 0000000..d87416b --- /dev/null +++ b/sponge/src/main/java/com/convallyria/forcepack/sponge/event/MultiVersionResourcePackStatusEvent.java @@ -0,0 +1,93 @@ +package com.convallyria.forcepack.sponge.event; + +import net.kyori.adventure.resource.ResourcePackStatus; +import org.checkerframework.checker.nullness.qual.NonNull; +import org.checkerframework.checker.nullness.qual.Nullable; +import org.spongepowered.api.entity.living.player.server.ServerPlayer; +import org.spongepowered.api.event.Cancellable; +import org.spongepowered.api.event.Cause; +import org.spongepowered.api.event.EventContext; +import org.spongepowered.api.event.impl.AbstractEvent; + +import java.util.UUID; + +/** + * Like {@link org.spongepowered.api.event.entity.living.player.ResourcePackStatusEvent} but helps with multiple version support + */ +public class MultiVersionResourcePackStatusEvent extends AbstractEvent implements Cancellable { + + private final ServerPlayer player; + private final Cause cause; + private final UUID id; + private final ResourcePackStatus status; + private final boolean proxy; + private final boolean proxyRemove; + private boolean cancel; + + public MultiVersionResourcePackStatusEvent(@NonNull final ServerPlayer who, @NonNull UUID id, @NonNull ResourcePackStatus resourcePackStatus, boolean proxy, boolean proxyRemove) { + this.player = who; + this.id = id; + this.status = resourcePackStatus; + this.proxy = proxy; + this.proxyRemove = proxyRemove; + this.cause = Cause.of(EventContext.empty(), who); + } + + /** + * Returns the player involved in this event + * @return Player who is involved in this event + */ + @NonNull + public final ServerPlayer getPlayer() { + return player; + } + + /** + * Gets the unique ID of this pack. + * @return unique resource pack ID. + */ + @Nullable + public UUID getID() { + return id; + } + + /** + * Gets the status of this pack. + * @return the current status + */ + @NonNull + public ResourcePackStatus getStatus() { + return status; + } + + /** + * Gets whether this event was fired by the proxy or not. + * @return whether the proxy caused this event to be fired + */ + public boolean isProxy() { + return proxy; + } + + /** + * Gets whether the proxy has indicated the player is no longer waiting. + * @return whether the proxy has requested the removal of the player from waiting + */ + public boolean isProxyRemove() { + return proxyRemove; + } + + @Override + public boolean isCancelled() { + return cancel; + } + + @Override + public void setCancelled(boolean cancel) { + this.cancel = cancel; + } + + @Override + public Cause cause() { + return cause; + } +} diff --git a/sponge/src/main/java/com/convallyria/forcepack/sponge/listener/ExemptionListener.java b/sponge/src/main/java/com/convallyria/forcepack/sponge/listener/ExemptionListener.java new file mode 100644 index 0000000..64400ca --- /dev/null +++ b/sponge/src/main/java/com/convallyria/forcepack/sponge/listener/ExemptionListener.java @@ -0,0 +1,49 @@ +package com.convallyria.forcepack.sponge.listener; + +import com.convallyria.forcepack.sponge.ForcePackSponge; +import org.spongepowered.api.entity.living.player.server.ServerPlayer; +import org.spongepowered.api.event.Listener; +import org.spongepowered.api.event.entity.DamageEntityEvent; +import org.spongepowered.api.event.entity.MoveEntityEvent; + +public class ExemptionListener { + + private final ForcePackSponge plugin; + + public ExemptionListener(ForcePackSponge plugin) { + this.plugin = plugin; + } + + @Listener + public void onDamage(DamageEntityEvent event) { + if (!plugin.getConfig().node("prevent-damage").getBoolean()) return; + + event.cause().first(ServerPlayer.class).ifPresent(damager -> { + if (damager.equals(event.entity())) return; + if (plugin.isWaiting(damager)) { + event.setCancelled(true); + plugin.log("Cancelled damage for damager '" + damager.name() + "' due to resource pack not applied."); + } + }); + + if (event.entity() instanceof ServerPlayer) { + ServerPlayer damaged = (ServerPlayer) event.entity(); + if (plugin.isWaiting(damaged)) { + event.setCancelled(true); + plugin.log("Cancelled damage for player '" + damaged.name() + "' due to resource pack not applied."); + } + } + } + + @Listener + public void onMove(MoveEntityEvent event) { + if (!(event.entity() instanceof ServerPlayer)) return; + if (!plugin.getConfig().node("prevent-movement").getBoolean()) return; + + ServerPlayer player = (ServerPlayer) event.entity(); + if (plugin.isWaiting(player)) { + event.setCancelled(true); + plugin.log("Cancelled movement for player '" + player.name() + "' due to resource pack not applied."); + } + } +} diff --git a/sponge/src/main/java/com/convallyria/forcepack/sponge/listener/PacketListener.java b/sponge/src/main/java/com/convallyria/forcepack/sponge/listener/PacketListener.java new file mode 100644 index 0000000..fdf4139 --- /dev/null +++ b/sponge/src/main/java/com/convallyria/forcepack/sponge/listener/PacketListener.java @@ -0,0 +1,48 @@ +package com.convallyria.forcepack.sponge.listener; + +import com.convallyria.forcepack.sponge.ForcePackSponge; +import com.convallyria.forcepack.sponge.event.MultiVersionResourcePackStatusEvent; +import com.github.retrooper.packetevents.event.PacketListenerAbstract; +import com.github.retrooper.packetevents.event.PacketReceiveEvent; +import com.github.retrooper.packetevents.protocol.packettype.PacketType; +import com.github.retrooper.packetevents.wrapper.play.client.WrapperPlayClientResourcePackStatus; +import net.kyori.adventure.resource.ResourcePackStatus; +import org.spongepowered.api.Sponge; +import org.spongepowered.api.entity.living.player.server.ServerPlayer; + +import java.util.UUID; + +public class PacketListener extends PacketListenerAbstract { + + private final ForcePackSponge plugin; + + public PacketListener(ForcePackSponge plugin) { + this.plugin = plugin; + } + +// @Override +// public boolean isPreVia() { +// return true; +// } + + @Override + public void onPacketReceive(PacketReceiveEvent event) { + if (event.getPacketType() == PacketType.Play.Client.RESOURCE_PACK_STATUS) { + final ServerPlayer player = Sponge.server().player(event.getUser().getUUID()).orElse(null); + if (player == null) { + plugin.getLogger().warn("Unable to get player for resource pack status!?!? {}, {}", event.getUser(), event.getPlayer()); + return; + } + + plugin.log("Received packet resource pack status from " + player.name() + " (version: " + event.getServerVersion().getReleaseName() + ")"); + + final WrapperPlayClientResourcePackStatus status = new WrapperPlayClientResourcePackStatus(event); + final WrapperPlayClientResourcePackStatus.Result result = status.getResult(); + final UUID packId = status.getPackId(); + final MultiVersionResourcePackStatusEvent packEvent = new MultiVersionResourcePackStatusEvent(player, packId, ResourcePackStatus.valueOf(result.name()), false, false); + if (Sponge.eventManager().post(packEvent)) { + event.setCancelled(true); + } + } + } +} diff --git a/sponge/src/main/java/com/convallyria/forcepack/sponge/listener/ResourcePackListener.java b/sponge/src/main/java/com/convallyria/forcepack/sponge/listener/ResourcePackListener.java new file mode 100644 index 0000000..779a4c1 --- /dev/null +++ b/sponge/src/main/java/com/convallyria/forcepack/sponge/listener/ResourcePackListener.java @@ -0,0 +1,328 @@ +package com.convallyria.forcepack.sponge.listener; + +import com.convallyria.forcepack.api.check.SpoofCheck; +import com.convallyria.forcepack.api.permission.Permissions; +import com.convallyria.forcepack.api.player.ForcePackPlayer; +import com.convallyria.forcepack.api.resourcepack.ResourcePack; +import com.convallyria.forcepack.api.schedule.PlatformScheduler; +import com.convallyria.forcepack.api.utils.ClientVersion; +import com.convallyria.forcepack.api.utils.GeyserUtil; +import com.convallyria.forcepack.sponge.ForcePackSponge; +import com.convallyria.forcepack.sponge.event.ForcePackReloadEvent; +import com.convallyria.forcepack.sponge.event.MultiVersionResourcePackStatusEvent; +import com.convallyria.forcepack.sponge.util.ProtocolUtil; +import net.kyori.adventure.resource.ResourcePackInfo; +import net.kyori.adventure.resource.ResourcePackStatus; +import net.kyori.adventure.text.Component; +import net.kyori.adventure.title.Title; +import org.jetbrains.annotations.NotNull; +import org.spongepowered.api.Sponge; +import org.spongepowered.api.command.exception.CommandException; +import org.spongepowered.api.entity.living.player.server.ServerPlayer; +import org.spongepowered.api.event.Cause; +import org.spongepowered.api.event.EventContext; +import org.spongepowered.api.event.Listener; +import org.spongepowered.api.event.entity.living.player.ResourcePackStatusEvent; +import org.spongepowered.api.event.network.ServerSideConnectionEvent; +import org.spongepowered.api.network.ServerSideConnection; +import org.spongepowered.api.profile.GameProfile; +import org.spongepowered.configurate.ConfigurationNode; +import org.spongepowered.configurate.serialize.SerializationException; + +import java.net.URI; +import java.time.Duration; +import java.util.ArrayList; +import java.util.Map; +import java.util.Optional; +import java.util.Set; +import java.util.UUID; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.atomic.AtomicReference; + +public class ResourcePackListener { + + private final ForcePackSponge plugin; + + private final Map sentAccept = new ConcurrentHashMap<>(); + + public ResourcePackListener(final ForcePackSponge plugin) { + this.plugin = plugin; + } + + @Listener + public void onStatus(MultiVersionResourcePackStatusEvent event) { + final ServerPlayer player = event.getPlayer(); + final UUID id = event.getID(); + + boolean geyser = getConfig().node("Server", "geyser").getBoolean() && GeyserUtil.isBedrockPlayer(player.uniqueId()); + boolean canBypass = player.hasPermission(Permissions.BYPASS) && getConfig().node("Server", "bypass-permission").getBoolean(); + plugin.log(player.name() + "'s exemptions: geyser, " + geyser + ". permission, " + canBypass + "."); + + if (canBypass || geyser) { + return; + } + + if (plugin.temporaryExemptedPlayers.remove(player.uniqueId())) { + plugin.log("Ignoring player " + player.name() + " as they have a one-off exemption."); + return; + } + + final ResourcePackStatus status = event.getStatus(); + plugin.log(player.name() + " sent status: " + status); + + final boolean velocityMode = getConfig().node("velocity-mode").getBoolean(); + if (!velocityMode && tryValidateHacks(player, event, status)) return; + + // If we did not set this resource pack, ignore + if (!event.isProxy() && !plugin.isWaitingFor(player, id)) { + plugin.log("Ignoring resource pack " + id + " because it wasn't set by ForcePack."); + return; + } else if (event.isProxy()) { + plugin.log("Resource pack with id " + id + " sent by proxy. Removal state: " + event.isProxyRemove()); + } + + // Only remove from waiting if they actually loaded the resource pack, rather than any status + // Declined/failed is valid and should be allowed, server owner decides whether they get kicked + if (status != ResourcePackStatus.ACCEPTED && status != ResourcePackStatus.DOWNLOADED) { + if (event.isProxy()) { + if (event.isProxyRemove()) { + plugin.removeFromWaiting(player); + } + } else { + plugin.processWaitingResourcePack(player, id); + } + } + + try { + for (String cmd : getConfig().node("Server", "Actions", status.name(), "Commands").getList(String.class, new ArrayList<>())) { + ensureMainThread(() -> { + try { + Sponge.server().commandManager().process(cmd.replace("[player]", player.name())); + } catch (CommandException e) { + throw new RuntimeException(e); + } + }); + } + } catch (SerializationException e) { + throw new RuntimeException(e); + } + + // Don't execute kicks - handled by proxy + if (velocityMode) { + plugin.getScheduler().executeOnMain(() -> this.callSpongeEvent(event)); + return; + } + + final boolean kick = getConfig().node("Server", "Actions", status.name(), "kick").getBoolean(); + + switch (status) { + case ACCEPTED: { + sentAccept.put(player.uniqueId(), System.currentTimeMillis()); + break; + } + case DECLINED: { + ensureMainThread(() -> { + if (kick) player.kick(Component.translatable("forcepack.declined")); + else player.sendMessage(Component.translatable("forcepack.declined")); + }); + + sentAccept.remove(player.uniqueId()); + break; + } + case DISCARDED: + case INVALID_URL: + case FAILED_RELOAD: + case FAILED_DOWNLOAD: { + ensureMainThread(() -> { + if (kick) player.kick(Component.translatable("forcepack.download_failed")); + else player.sendMessage(Component.translatable("forcepack.download_failed")); + }); + + sentAccept.remove(player.uniqueId()); + break; + } + case SUCCESSFULLY_LOADED: { + if (kick) ensureMainThread(() -> player.kick(Component.translatable("forcepack.accepted"))); + else { + ensureMainThread(() -> player.sendMessage(Component.translatable("forcepack.accepted"))); + boolean sendTitle = plugin.getConfig().node("send-loading-title").getBoolean(); + if (sendTitle) player.clearTitle(); + } + break; + } + } + } + + private boolean tryValidateHacks(ServerPlayer player, MultiVersionResourcePackStatusEvent event, ResourcePackStatus status) { + final boolean tryPrevent = getConfig().node("try-to-stop-fake-accept-hacks").getBoolean(true); + if (!tryPrevent) return false; + + final ForcePackPlayer forcePackPlayer = plugin.getForcePackPlayer(player).orElse(null); + if (forcePackPlayer == null) { + plugin.log("Not checking " + player.name() + " because they are not in waiting."); + return false; + } + + boolean hasFailed = false; + for (SpoofCheck check : forcePackPlayer.getChecks()) { + final SpoofCheck.CheckStatus checkStatus = check.receiveStatus(status.name(), plugin::log); + hasFailed = checkStatus == SpoofCheck.CheckStatus.FAILED; + if (checkStatus == SpoofCheck.CheckStatus.CANCEL) { + plugin.log("Cancelling status " + status + " as a check requested it."); + event.setCancelled(true); + return true; + } + } + + if (hasFailed) { + plugin.log("Kicking player " + player.name() + " because they failed a check."); + ensureMainThread(() -> player.kick(Component.translatable("forcepack.download_failed"))); + } + + return hasFailed; + } + + private void callSpongeEvent(MultiVersionResourcePackStatusEvent event) { + // Velocity doesn't correctly pass things to the backend server + // Call sponge event manually for other plugins to handle status events + + // Can the ID be null? I'm not sure, let's just pass a random ID to avoid errors if this happens. + // I doubt another plugin is using it anyway. + Sponge.eventManager().post(new ResourcePackStatusEvent() { + @Override + public ServerSideConnection connection() { + return event.getPlayer().connection(); + } + + @Override + public GameProfile profile() { + return event.getPlayer().profile(); + } + + @Override + public Optional player() { + return Optional.of(event.getPlayer()); + } + + @Override + public ResourcePackInfo pack() { + return new ResourcePackInfo() { + @Override + public @NotNull UUID id() { + return event.getID() == null ? UUID.randomUUID() : event.getID(); + } + + @Override + public @NotNull URI uri() { + return URI.create("forcepack://proxy"); + } + + @Override + public @NotNull String hash() { + return "forcepack-proxy"; + } + }; + } + + @Override + public ResourcePackStatus status() { + return event.getStatus(); + } + + @Override + public Cause cause() { + return Cause.of(EventContext.empty(), plugin); + } + }); + } + + @Listener + public void onPlayerJoin(ServerSideConnectionEvent.Join event) { + ServerPlayer player = event.player(); + + boolean geyser = getConfig().node("Server", "geyser").getBoolean() && GeyserUtil.isBedrockPlayer(player.uniqueId()); + boolean canBypass = player.hasPermission(Permissions.BYPASS) && getConfig().node("Server", "bypass-permission").getBoolean(); + plugin.log(player.name() + "'s exemptions: geyser, " + geyser + ". permission, " + canBypass + "."); + + if (canBypass || geyser) return; + + if (getConfig().node("velocity-mode").getBoolean()) { + plugin.log("Velocity mode is enabled"); + plugin.addToWaiting(player.uniqueId(), Set.of()); + return; + } + + final Set packs = plugin.getPacksForVersion(player); + if (packs.isEmpty()) { + plugin.log("Warning: Packs for player " + player.name() + " are empty."); + return; + } + + plugin.addToWaiting(player.uniqueId(), packs); + + for (ResourcePack pack : packs) { + plugin.log("Sending pack " + pack.getUUID() + " to player " + player.name()); + final int version = ProtocolUtil.getProtocolVersion(player); + final int maxSize = ClientVersion.getMaxSizeForVersion(version); + final boolean forceSend = getConfig().node("Server", "force-invalid-size").getBoolean(); + if (!forceSend && pack.getSize() > maxSize) { + if (plugin.debug()) plugin.getLogger().info("Not sending pack to {} because of excessive size for version {} ({}MB, {}MB).", player.name(), version, pack.getSize(), maxSize); + continue; + } + + plugin.getScheduler().executeOnMain(() -> this.runSetPackTask(player, pack, version)); + } + } + + @Listener + public void onReload(ForcePackReloadEvent event) { + for (ServerPlayer onlinePlayer : Sponge.server().onlinePlayers()) { + if (plugin.isWaiting(onlinePlayer)) continue; + sentAccept.remove(onlinePlayer.uniqueId()); + } + } + + private void runSetPackTask(ServerPlayer player, ResourcePack pack, int version) { + AtomicReference task = new AtomicReference<>(); + Runnable packTask = () -> { + if (plugin.isWaiting(player)) { + plugin.log("Sent resource pack to player"); + pack.setResourcePack(player.uniqueId()); + } + + boolean sendTitle = plugin.getConfig().node("send-loading-title").getBoolean(); + if (sendTitle && sentAccept.containsKey(player.uniqueId())) { + player.showTitle(Title.title( + Component.translatable("forcepack.download_start_title"), + Component.translatable("forcepack.download_start_subtitle"), + Title.Times.times(Duration.ZERO, Duration.ofMillis(30 * 50), Duration.ofMillis(10 * 50)))); + } + + final PlatformScheduler.ForcePackTask acquired = task.get(); + if (acquired != null && !plugin.isWaiting(player) && !sentAccept.containsKey(player.uniqueId())) { + acquired.cancel(); + } + }; + + if (getConfig().node("Server", "Update GUI").getBoolean() && version <= 340) { // 340 is 1.12 + task.set(plugin.getScheduler().executeRepeating(packTask, 0L, getConfig().node("Server", "Update GUI Speed").getInt(20))); + } else { + packTask.run(); + } + } + + @Listener + public void onQuit(ServerSideConnectionEvent.Leave event) { + ServerPlayer player = event.player(); + plugin.removeFromWaiting(player); + sentAccept.remove(player.uniqueId()); + } + + private void ensureMainThread(Runnable runnable) { + plugin.getScheduler().executeOnMain(runnable); + } + + private ConfigurationNode getConfig() { + return plugin.getConfig(); + } +} diff --git a/sponge/src/main/java/com/convallyria/forcepack/sponge/player/ForcePackSpongePlayer.java b/sponge/src/main/java/com/convallyria/forcepack/sponge/player/ForcePackSpongePlayer.java new file mode 100644 index 0000000..6160854 --- /dev/null +++ b/sponge/src/main/java/com/convallyria/forcepack/sponge/player/ForcePackSpongePlayer.java @@ -0,0 +1,35 @@ +package com.convallyria.forcepack.sponge.player; + +import com.convallyria.forcepack.api.check.DelayedSuccessSpoofCheck; +import com.convallyria.forcepack.api.check.InvalidOrderSpoofCheck; +import com.convallyria.forcepack.api.check.SpoofCheck; +import com.convallyria.forcepack.api.player.ForcePackPlayer; +import com.convallyria.forcepack.api.resourcepack.ResourcePack; +import com.convallyria.forcepack.sponge.ForcePackSponge; +import org.spongepowered.api.entity.living.player.server.ServerPlayer; + +import java.util.ArrayList; +import java.util.HashSet; +import java.util.List; +import java.util.Set; + +public class ForcePackSpongePlayer implements ForcePackPlayer { + + private final Set waitingPacks = new HashSet<>(); + private final List checks = new ArrayList<>(); + + public ForcePackSpongePlayer(ServerPlayer player) { + checks.add(new DelayedSuccessSpoofCheck(ForcePackSponge.getAPI(), player.uniqueId())); + checks.add(new InvalidOrderSpoofCheck(ForcePackSponge.getAPI(), player.uniqueId())); + } + + @Override + public Set getWaitingPacks() { + return waitingPacks; + } + + @Override + public List getChecks() { + return checks; + } +} diff --git a/sponge/src/main/java/com/convallyria/forcepack/sponge/resourcepack/SpongeResourcePack.java b/sponge/src/main/java/com/convallyria/forcepack/sponge/resourcepack/SpongeResourcePack.java new file mode 100644 index 0000000..cae902e --- /dev/null +++ b/sponge/src/main/java/com/convallyria/forcepack/sponge/resourcepack/SpongeResourcePack.java @@ -0,0 +1,53 @@ +package com.convallyria.forcepack.sponge.resourcepack; + +import com.convallyria.forcepack.api.resourcepack.ResourcePack; +import com.convallyria.forcepack.api.resourcepack.ResourcePackVersion; +import com.convallyria.forcepack.sponge.ForcePackSponge; +import com.convallyria.forcepack.sponge.util.ProtocolUtil; +import com.github.retrooper.packetevents.wrapper.play.server.WrapperPlayServerResourcePackSend; +import net.kyori.adventure.text.Component; +import net.kyori.adventure.text.JoinConfiguration; +import org.checkerframework.checker.nullness.qual.Nullable; +import org.spongepowered.api.Sponge; +import org.spongepowered.api.entity.living.player.server.ServerPlayer; + +import java.util.UUID; + +public final class SpongeResourcePack extends ResourcePack { + + private final ForcePackSponge spongePlugin; + + public SpongeResourcePack(final ForcePackSponge plugin, String url, String hash, int size, @Nullable ResourcePackVersion packVersion) { + this(plugin, "Sponge", url, hash, size, packVersion); + } + + public SpongeResourcePack(final ForcePackSponge plugin, String server, String url, String hash, int size, @Nullable ResourcePackVersion packVersion) { + super(plugin, server, url, hash, size, packVersion); + this.spongePlugin = plugin; + } + + @Override + public void setResourcePack(UUID uuid) { + final int delay = spongePlugin.getConfig().node("delay-pack-sending-by").getInt(0); + if (delay > 0) { + plugin.getScheduler().executeDelayed(() -> runSetResourcePack(uuid), delay); + } else { + runSetResourcePack(uuid); + } + } + + private void runSetResourcePack(UUID uuid) { + final ServerPlayer player = Sponge.server().player(uuid).orElse(null); + if (player == null) return; // Either the player disconnected or this is an NPC + + spongePlugin.getForcePackPlayer(player).ifPresent(forcePackPlayer -> { + forcePackPlayer.getChecks().forEach(check -> check.sendPack(this)); + }); + + WrapperPlayServerResourcePackSend send = new WrapperPlayServerResourcePackSend(getUUID(), url, getHash(), + spongePlugin.getConfig().node("use-new-force-pack-screen").getBoolean(true), + Component.join(JoinConfiguration.newlines(), Component.translatable("forcepack.prompt_text"))); + + ProtocolUtil.sendPacketBypassingVia(player, send); + } +} diff --git a/sponge/src/main/java/com/convallyria/forcepack/sponge/schedule/SpongeScheduler.java b/sponge/src/main/java/com/convallyria/forcepack/sponge/schedule/SpongeScheduler.java new file mode 100644 index 0000000..c45d957 --- /dev/null +++ b/sponge/src/main/java/com/convallyria/forcepack/sponge/schedule/SpongeScheduler.java @@ -0,0 +1,48 @@ +package com.convallyria.forcepack.sponge.schedule; + +import com.convallyria.forcepack.api.schedule.PlatformScheduler; +import com.convallyria.forcepack.sponge.ForcePackSponge; +import org.spongepowered.api.Sponge; +import org.spongepowered.api.scheduler.ScheduledTask; +import org.spongepowered.api.scheduler.Task; +import org.spongepowered.api.util.Ticks; + +public class SpongeScheduler extends PlatformScheduler { + + public SpongeScheduler(ForcePackSponge api) { + super(api); + } + + @Override + public void executeOnMain(Runnable runnable) { + Sponge.server().scheduler().submit(Task.builder().plugin(api.pluginContainer()).execute(runnable).build()); + } + + @Override + public void executeAsync(Runnable runnable) { + Sponge.asyncScheduler().submit(Task.builder().plugin(api.pluginContainer()).execute(runnable).build()); + } + + @Override + public ForcePackTask executeRepeating(Runnable runnable, long delay, long period) { + final ScheduledTask task = Sponge.server().scheduler().submit(Task.builder().plugin(api.pluginContainer()) + .delay(Ticks.of(delay)) + .interval(Ticks.of(period)) + .execute(runnable) + .build()); + return task::cancel; + } + + @Override + public void executeDelayed(Runnable runnable, long delay) { + Sponge.server().scheduler().submit(Task.builder().plugin(api.pluginContainer()) + .delay(Ticks.of(delay)) + .execute(runnable) + .build()); + } + + @Override + public void registerInitTask(Runnable runnable) { + executeOnMain(runnable); + } +} diff --git a/sponge/src/main/java/com/convallyria/forcepack/sponge/util/FileSystemUtils.java b/sponge/src/main/java/com/convallyria/forcepack/sponge/util/FileSystemUtils.java new file mode 100644 index 0000000..7e69ca8 --- /dev/null +++ b/sponge/src/main/java/com/convallyria/forcepack/sponge/util/FileSystemUtils.java @@ -0,0 +1,81 @@ +package com.convallyria.forcepack.sponge.util; + +import com.google.common.base.Splitter; +import com.google.common.collect.Iterables; + +import java.io.IOException; +import java.net.URI; +import java.net.URISyntaxException; +import java.net.URL; +import java.nio.file.FileSystem; +import java.nio.file.FileSystems; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; +import java.util.function.Consumer; + +public final class FileSystemUtils { + + private FileSystemUtils() { + throw new UnsupportedOperationException("This class cannot be instantiated"); + } + + /** + * Visits the resources at the given {@link Path} within the resource + * path of the given {@link Class}. + * + * @param target The target class of the resource path to scan + * @param consumer The consumer to visit the resolved path + * @param firstPathComponent First path component + * @param remainingPathComponents Remaining path components + */ + public static boolean visitResources(final Class target, final Consumer consumer, final String firstPathComponent, final String... remainingPathComponents) throws IOException { + final URL knownResource = FileSystemUtils.class.getClassLoader().getResource("assets/forcepack/config.yml"); + if (knownResource == null) { + throw new IllegalStateException("config.yml does not exist, don't know where we are"); + } + if (knownResource.getProtocol().equals("jar")) { + // Running from a JAR + final String jarPathRaw = Iterables.get(Splitter.on('!').split(knownResource.toString()), 0); + final URI path = URI.create(jarPathRaw + "!/"); + +// try (final FileSystem fileSystem = FileSystems.newFileSystem(path, Map.of("create", "true"))) { +// final Path toVisit = fileSystem.getPath(firstPathComponent, remainingPathComponents); +// if (Files.exists(toVisit)) { +// consumer.accept(toVisit); +// return true; +// } +// return false; +// } + + final FileSystem fileSystem = FileSystems.getFileSystem(path); + final Path toVisit = fileSystem.getPath(firstPathComponent, remainingPathComponents); + if (Files.exists(toVisit)) { + consumer.accept(toVisit); + return true; + } + return false; + } else { + // Running from the file system + final URI uri; + final List componentList = new ArrayList<>(); + componentList.add(firstPathComponent); + componentList.addAll(Arrays.asList(remainingPathComponents)); + + try { + final URL url = target.getClassLoader().getResource(String.join("/", componentList)); + if (url == null) { + return false; + } + uri = url.toURI(); + } catch (final URISyntaxException e) { + throw new IllegalStateException(e); + } + consumer.accept(Paths.get(uri)); + return true; + } + } +} diff --git a/sponge/src/main/java/com/convallyria/forcepack/sponge/util/ProtocolUtil.java b/sponge/src/main/java/com/convallyria/forcepack/sponge/util/ProtocolUtil.java new file mode 100644 index 0000000..4836792 --- /dev/null +++ b/sponge/src/main/java/com/convallyria/forcepack/sponge/util/ProtocolUtil.java @@ -0,0 +1,67 @@ +package com.convallyria.forcepack.sponge.util; + +import com.convallyria.forcepack.sponge.ForcePackSponge; +import com.github.retrooper.packetevents.PacketEvents; +import com.github.retrooper.packetevents.netty.buffer.ByteBufHelper; +import com.github.retrooper.packetevents.netty.channel.ChannelHelper; +import com.github.retrooper.packetevents.protocol.player.User; +import com.github.retrooper.packetevents.wrapper.PacketWrapper; +import com.viaversion.viaversion.api.Via; +import org.spongepowered.api.Sponge; +import org.spongepowered.api.entity.living.player.server.ServerPlayer; + +public class ProtocolUtil { + + public static int getProtocolVersion(ServerPlayer player) { + final boolean viaversion = Sponge.pluginManager().plugin("viaversion").isPresent(); + return viaversion + ? Via.getAPI().getPlayerVersion(player) + : PacketEvents.getAPI().getPlayerManager().getUser(player).getClientVersion().getProtocolVersion(); + } + + private static boolean warnedBadPlugin; + + /** + * Sends a packet to a player bypassing viaversion. + */ + public static void sendPacketBypassingVia(ServerPlayer player, PacketWrapper packet) { + final User user = PacketEvents.getAPI().getPlayerManager().getUser(player); + // This can only ever happen if there is a bad plugin creating NPCs AND adding them to the online player map + if (user == null) { + if (!warnedBadPlugin) { + ForcePackSponge.getInstance().getLogger().error("You have a poorly coded plugin creating entirely fake players that are not specified as such/"); + ForcePackSponge.getInstance().getLogger().error("Additionally, this message can only appear when the player exists in the Bukkit#getOnlinePlayers map."); + ForcePackSponge.getInstance().getLogger().error("This has a performance impact, including memory leaks, and must be fixed."); + warnedBadPlugin = true; + } + return; + } + + final Object channel = user.getChannel(); + if (!ChannelHelper.isOpen(channel)) return; + + sendBypassingPacket(user, channel, packet); + } + + private static void sendBypassingPacket(User user, Object channel, PacketWrapper packet) { + // If ViaVersion is present in the pipeline + if (user.getClientVersion().isNewerThan(PacketEvents.getAPI().getServerManager().getVersion().toClientVersion()) + && ChannelHelper.getPipelineHandler(channel, "via-encoder") != null) { + // Allocate the buffer for the wrapper + packet.buffer = ChannelHelper.pooledByteBuf(channel); + + // We need to convert the packet ID to the appropriate one for that user's version + int id = packet.getPacketTypeData().getPacketType().getId(user.getClientVersion()); + // Write the packet ID to the buffer + ByteBufHelper.writeVarInt(packet.buffer, id); + // Change the version in which the wrapper is processed + packet.setServerVersion(user.getClientVersion().toServerVersion()); + // Write the packet content + packet.write(); + // Send the buffer + ChannelHelper.writeAndFlushInContext(channel, "via-encoder", packet.buffer); + } else { + user.sendPacketSilently(packet); + } + } +} diff --git a/sponge/src/main/resources/assets/forcepack/config.yml b/sponge/src/main/resources/assets/forcepack/config.yml new file mode 100644 index 0000000..efb4747 --- /dev/null +++ b/sponge/src/main/resources/assets/forcepack/config.yml @@ -0,0 +1,118 @@ +# Need help? Join our Discord: https://discord.gg/fh62mxU +# ForcePack documentation: https://fortitude.islandearth.net/category/forcepack + +# If this is enabled, the plugin will be used purely for further compatibility with your Velocity proxy. +# This is required for the prevent-movement feature to work when behind the velocity plugin. +# Actions (excluding kicks) will still take place. Bypass permissions will also still be enabled. +velocity-mode: false + +# If this is true, movement will be prevented until the resource pack is loaded. +prevent-movement: true + +# If this is true, damage will be prevented to players currently loading the resource pack. +prevent-damage: true + +# Appends the hash to your URL to fix this bug. +enable-mc-164316-fix: true + +# Should ForcePack load on the first tick after the server has started? +# Ensures we load after any plugin you may be hosting a resource pack on +# So that you can use its hosting feature +load-last: false + +# Should we use the 1.17+ force resource pack screen, or the old one? +# You can still define a custom message that will show even if this is false for 1.17+ clients +# Note that with this true, the custom disconnect message will not work because the client forcefully kicks itself +use-new-force-pack-screen: true + +# Should we try and prevent hacked clients sending fake resource pack accept packets? +# Still bypassable, but some are stupid and we are able to detect them. +try-to-stop-fake-accept-hacks: true + +# Slow clients might take some time between accepting the pack and then successfully loading. +# Option to send a title asking them to wait patiently +send-loading-title: true + +# How many ticks to delay sending a resource pack by +# Use this if you have a plugin teleporting someone after joining which closes the resource pack screen +delay-pack-sending-by: 0 + +# This configures the web server for force pack. +# See https://fortitude.islandearth.net/forcepack/configuration#self-hosting +# Note that you can leave the IP as localhost unless it is not working, as in most cases the plugin can automatically resolve it. +# Having this enabled allows the use of the "forcepack://" protocol in resource pack URL +# Enabling this DOES NOT mean you are forced to use a localhost resource pack. +# You need to make sure the port is open. Usually you can just add it via pterodactyl panel. +web-server: + enabled: false + server-ip: localhost + port: 8080 + +Server: + packs: + all: + # The ResourcePack URL. This must be a direct URL, ending with .zip. For Dropbox URLs, add ?dl=1 to the end. + # To host a local file on the embedded webserver, change this to the relative path of the force pack folder prefixed by "forcepack://". + # For example, placing your resource pack as "plugins/ForcePack/pack.zip", the url will just be "forcepack://pack.zip". + # ONLY PLAYERS ON 1.20.3+ CAN RECEIVE MULTIPLE RESOURCE PACKS. The first URL/Hash pair will be taken for older client versions. + # URLs list and hashes list MUST BE THE SAME SIZE if generate-hash is disabled. + urls: ["https://www.convallyria.com/files/BlankPack.zip"] + # Whether to automatically generate the SHA-1 hash. + # The hash setting will be ignored if this is true, however it will fall back to that if the generation fails. + generate-hash: false + # See https://fortitude.islandearth.net/forcepack/configuration#getting-the-sha-1-hash-of-a-resource-pack + hashes: ["118AFFFC54CDCD308702F81BA24E03223F15FE5F"] + # Here you can specify a pack format version-specific resource pack. You would copy the section above, replacing "all" with the number. + # Note that the "all" section must be kept as a fallback. + # See https://minecraft.wiki/w/Pack_format. + + Actions: + # To disable commands, you can do Commands: [] + # List of valid actions: https://jd.advntr.dev/api/4.15.0/net/kyori/adventure/resource/ResourcePackStatus.html + # DOWNLOADED, FAILED_RELOAD, DISCARDED only apply to 1.20.3+ clients. + ACCEPTED: + kick: false + Commands: + - say [player] accepted the resource pack! + DOWNLOADED: + kick: false + Commands: + - say [player] finished downloading the resource pack! + SUCCESSFULLY_LOADED: + kick: false + Commands: + - say [player] successfully loaded the resource pack! + DECLINED: + kick: true + Commands: + - say [player] denied the resource pack! + FAILED_DOWNLOAD: + kick: true + Commands: + - say [player] failed to download the resource pack! + FAILED_RELOAD: + kick: true + Commands: + - say [player] failed to reload the resource pack! + DISCARDED: + kick: true + Commands: + - say [player] discarded the resource pack! + + # Speed in ticks at which the ResourcePack prompt will be resent to the player (prevents escaping out) + Update GUI Speed: 20 + # Whether to re-send the GUI. This will only update for clients <= 1.12. + Update GUI: true + # Should the plugin verify your URL + hash? + verify: true + # Should we resend the resource pack on reload? Any players that have already accepted will receive the updated one. + resend: true + # Should we forcefully send a resource pack that has an invalid size for the player's client version? + force-invalid-size: false + # Whether to ignore geyser players. Only enable this if you use geyser. + geyser: false + # Whether to enable the bypass permission. If you disable this, all OPs will no longer be able to bypass. + # But you should really fix your permissions instead. + bypass-permission: true + # Whether to enable debug mode. Prints some extra info. + debug: false \ No newline at end of file diff --git a/sponge/src/main/resources/com.convallyria.forcepack.sponge.l10n/messages.properties b/sponge/src/main/resources/com.convallyria.forcepack.sponge.l10n/messages.properties new file mode 100644 index 0000000..535a5ae --- /dev/null +++ b/sponge/src/main/resources/com.convallyria.forcepack.sponge.l10n/messages.properties @@ -0,0 +1,7 @@ +forcepack.declined="You must accept the resource pack to play on our server.You can do this by:1. In the server list, select the server.2. Click edit.3. Change 'Server Resource Packs' to Enabled." +forcepack.accepted="Thank you for accepting our resource pack! You can now play." +forcepack.download_start_title="Downloading resource pack..." +forcepack.download_start_subtitle="This may take a moment..." +forcepack.download_failed="The resource pack download failed. Please reconnect and try again." +forcepack.prompt_text="Please accept our resource pack to improve your server experience!" # Only on 1.18+ (1.17 does not have API) +forcepack.reloading="Re-applying your resource pack due to an update!" \ No newline at end of file diff --git a/sponge/src/main/resources/com.convallyria.forcepack.sponge.l10n/messages_es_ES.properties b/sponge/src/main/resources/com.convallyria.forcepack.sponge.l10n/messages_es_ES.properties new file mode 100644 index 0000000..e69de29 diff --git a/sponge/src/main/resources/com.convallyria.forcepack.sponge.l10n/messages_zh_CN.properties b/sponge/src/main/resources/com.convallyria.forcepack.sponge.l10n/messages_zh_CN.properties new file mode 100644 index 0000000..e69de29 diff --git a/sponge/src/main/resources/com.convallyria.forcepack.sponge.l10n/messages_zh_TW.properties b/sponge/src/main/resources/com.convallyria.forcepack.sponge.l10n/messages_zh_TW.properties new file mode 100644 index 0000000..e69de29 diff --git a/velocity/build.gradle.kts b/velocity/build.gradle.kts index e537639..7b698b0 100644 --- a/velocity/build.gradle.kts +++ b/velocity/build.gradle.kts @@ -1,3 +1,7 @@ +plugins { + id("buildlogic.java-platform-conventions") +} + repositories { maven("https://s01.oss.sonatype.org/content/repositories/snapshots/") maven("https://repo.papermc.io/repository/maven-public/") @@ -22,5 +26,9 @@ tasks.shadowJar { minimize { exclude(project(":webserver")) } + mergeServiceFiles() + relocate("io.leangen.geantyref", "forcepack.libs.geantyref") relocate("org.bstats", "com.convallyria.forcepack.velocity.libs.bstats") -} \ No newline at end of file + relocate("org.glassfish.jaxb", "com.convallyria.forcepack.libs.jaxb") + relocate("org.objectweb.asm", "com.convallyria.forcepack.libs.asm") +} diff --git a/velocity/src/main/java/com/convallyria/forcepack/velocity/ForcePackVelocity.java b/velocity/src/main/java/com/convallyria/forcepack/velocity/ForcePackVelocity.java index 713df9e..8d952f4 100644 --- a/velocity/src/main/java/com/convallyria/forcepack/velocity/ForcePackVelocity.java +++ b/velocity/src/main/java/com/convallyria/forcepack/velocity/ForcePackVelocity.java @@ -1,6 +1,6 @@ package com.convallyria.forcepack.velocity; -import com.convallyria.forcepack.api.ForcePackAPI; +import com.convallyria.forcepack.api.ForcePackPlatform; import com.convallyria.forcepack.api.resourcepack.PackFormatResolver; import com.convallyria.forcepack.api.resourcepack.ResourcePack; import com.convallyria.forcepack.api.resourcepack.ResourcePackVersion; @@ -44,7 +44,6 @@ import java.io.IOException; import java.nio.file.Files; import java.nio.file.Path; -import java.util.Arrays; import java.util.Collections; import java.util.HashMap; import java.util.HashSet; @@ -70,7 +69,7 @@ }, authors = {"SamB440"} ) -public class ForcePackVelocity implements ForcePackAPI { +public class ForcePackVelocity implements ForcePackPlatform { public static final String EMPTY_SERVER_NAME = "ForcePack-Empty-Server"; public static final String GLOBAL_SERVER_NAME = "ForcePack-Global-Server"; @@ -237,7 +236,7 @@ private void addResourcePacks(@Nullable Player player, String rootName) { }); } } - + private void registerResourcePack(VelocityConfig rootServerConfig, VelocityConfig resourcePack, String id, String name, String typeName, boolean groups, boolean verifyPacks, @Nullable Player player) { List urls = resourcePack.getStringList("urls"); if (urls.isEmpty()) { @@ -467,16 +466,7 @@ private String checkLocalHostUrl(String url) { } private void checkValidEnding(String url) { - List validUrlEndings = Arrays.asList(".zip", "dl=1"); - boolean hasEnding = false; - for (String validUrlEnding : validUrlEndings) { - if (url.endsWith(validUrlEnding)) { - hasEnding = true; - break; - } - } - - if (!hasEnding) { + if (!isValidEnding(url)) { getLogger().error("Your URL has an invalid or unknown format. " + "URLs must have no redirects and use the .zip extension. If you are using Dropbox, change dl=0 to dl=1."); getLogger().error("ForcePack will still load in the event this check is incorrect. Please make an issue or pull request if this is so."); @@ -484,28 +474,16 @@ private void checkValidEnding(String url) { } private void checkForRehost(String url, String section) { - List warnForHost = List.of("convallyria.com"); - boolean rehosted = true; - for (String host : warnForHost) { - if (url.contains(host)) { - rehosted = false; - break; - } - } - - if (!rehosted) { + if (isDefaultHost(url)) { getLogger().warn("[{}] You are using a default resource pack provided by the plugin. " + " It's highly recommended you re-host this pack using the webserver or on a CDN such as https://mc-packs.net for faster load times. " + "Leaving this as default potentially sends a lot of requests to my personal web server, which isn't ideal!", section); getLogger().warn("ForcePack will still load and function like normally."); } - List blacklisted = List.of("mediafire.com"); - for (String blacklistedSite : blacklisted) { - if (url.contains(blacklistedSite)) { - getLogger().error("Invalid resource pack site used! '{}' cannot be used for hosting resource packs!", blacklistedSite); - } - } + getBlacklistedSite(url).ifPresent(blacklistedSite -> { + getLogger().error("Invalid resource pack site used! '{}' cannot be used for hosting resource packs!", blacklistedSite); + }); } @Nullable @@ -557,6 +535,11 @@ public boolean exemptNextResourcePackSend(UUID uuid) { return temporaryExemptedPlayers.add(uuid); } + @Override + public Set getPacksForVersion(int protocolVersion) { + throw new IllegalStateException("Use getPacksByServerAndVersion for Velocity"); + } + public Optional> getPacksByServerAndVersion(final String server, final ProtocolVersion version) { final int protocolVersion = version.getProtocol(); final int packFormat = PackFormatResolver.getPackFormat(protocolVersion); @@ -613,6 +596,7 @@ public MiniMessage getMiniMessage() { return this.miniMessage = MiniMessage.miniMessage(); } + @Override public void log(String info, Object... format) { if (this.getConfig().getBoolean("debug")) this.getLogger().info(String.format(info, format)); } diff --git a/velocity/src/main/java/com/convallyria/forcepack/velocity/listener/ResourcePackListener.java b/velocity/src/main/java/com/convallyria/forcepack/velocity/listener/ResourcePackListener.java index f0db275..3064a18 100644 --- a/velocity/src/main/java/com/convallyria/forcepack/velocity/listener/ResourcePackListener.java +++ b/velocity/src/main/java/com/convallyria/forcepack/velocity/listener/ResourcePackListener.java @@ -115,7 +115,11 @@ public void onPackStatus(PlayerResourcePackStatusEvent event) { // No longer applying, remove them from the list plugin.getPackHandler().processWaitingResourcePack(player, packByServer.getUUID()); final String name = status == PlayerResourcePackStatusEvent.Status.SUCCESSFUL ? "SUCCESSFULLY_LOADED" : status.name(); - currentServer.get().sendPluginMessage(PackHandler.FORCEPACK_STATUS_IDENTIFIER, (packByServer.getUUID().toString() + ";" + name + ";" + !plugin.getPackHandler().isWaiting(player)).getBytes(StandardCharsets.UTF_8)); + final boolean waiting = plugin.getPackHandler().isWaiting(player); + currentServer.get().sendPluginMessage(PackHandler.FORCEPACK_STATUS_IDENTIFIER, (packByServer.getUUID().toString() + ";" + name + ";" + !waiting).getBytes(StandardCharsets.UTF_8)); + plugin.getPackHandler().getForcePackPlayer(player).ifPresentOrElse(forcePackPlayer -> { + plugin.log("Current packs we are waiting for: %s", forcePackPlayer.getWaitingPacks()); + }, () -> plugin.log("Waiting for? %s", waiting)); } final String text = actions == null ? null : actions.getString("message"); diff --git a/webserver/build.gradle.kts b/webserver/build.gradle.kts index bdc0cb4..4344ffb 100644 --- a/webserver/build.gradle.kts +++ b/webserver/build.gradle.kts @@ -1,4 +1,5 @@ plugins { + id("buildlogic.java-common-conventions") id("dev.vankka.dependencydownload.plugin") version "1.3.1" } diff --git a/webserver/src/main/java/com/convallyria/forcepack/webserver/downloader/URLClassLoaderAccess.java b/webserver/src/main/java/com/convallyria/forcepack/webserver/downloader/URLClassLoaderAccess.java index d7ac226..7735cc8 100644 --- a/webserver/src/main/java/com/convallyria/forcepack/webserver/downloader/URLClassLoaderAccess.java +++ b/webserver/src/main/java/com/convallyria/forcepack/webserver/downloader/URLClassLoaderAccess.java @@ -163,4 +163,4 @@ public void addURL(@NonNull URL url) { } } -} \ No newline at end of file +}