diff --git a/settings.gradle.kts b/settings.gradle.kts index 91dfc63..1387665 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -8,7 +8,7 @@ include( "messaging", "bukkit", //"bungee", - //"velocity", + "velocity", "examples:shop", "examples:shop-html", ) \ No newline at end of file diff --git a/velocity/build.gradle.kts b/velocity/build.gradle.kts index 0350882..f4c81e9 100644 --- a/velocity/build.gradle.kts +++ b/velocity/build.gradle.kts @@ -1,5 +1,12 @@ plugins { id("feather-server-api.java-conventions") + id("com.github.johnrengelman.shadow") version "7.1.2" +} + +java { + toolchain { + languageVersion.set(JavaLanguageVersion.of(17)) + } } repositories { @@ -10,6 +17,9 @@ repositories { } dependencies { - compileOnly("com.velocitypowered:velocity-api:1.1.5") - annotationProcessor("com.velocitypowered:velocity-api:1.1.5") + compileOnly("com.velocitypowered:velocity-api:3.1.1") + annotationProcessor("com.velocitypowered:velocity-api:3.1.1") + implementation(project(":api")) + implementation(project(":common")) + implementation(project(":messaging")) } \ No newline at end of file diff --git a/velocity/src/main/java/net/digitalingot/feather/serverapi/velocity/FeatherVelocityPlugin.java b/velocity/src/main/java/net/digitalingot/feather/serverapi/velocity/FeatherVelocityPlugin.java index ac823ff..0e04cb1 100644 --- a/velocity/src/main/java/net/digitalingot/feather/serverapi/velocity/FeatherVelocityPlugin.java +++ b/velocity/src/main/java/net/digitalingot/feather/serverapi/velocity/FeatherVelocityPlugin.java @@ -1,10 +1,22 @@ package net.digitalingot.feather.serverapi.velocity; import com.google.inject.Inject; +import com.velocitypowered.api.event.Subscribe; +import com.velocitypowered.api.event.proxy.ProxyInitializeEvent; +import com.velocitypowered.api.event.proxy.ProxyShutdownEvent; import com.velocitypowered.api.proxy.ProxyServer; -import java.util.logging.Logger; +import net.digitalingot.feather.serverapi.api.FeatherAPI; +import net.digitalingot.feather.serverapi.velocity.event.VelocityEventService; +import net.digitalingot.feather.serverapi.velocity.messaging.VelocityMessagingService; +import net.digitalingot.feather.serverapi.velocity.meta.VelocityMetaService; +import net.digitalingot.feather.serverapi.velocity.player.VelocityPlayerService; +import net.digitalingot.feather.serverapi.velocity.ui.VelocityUIService; +import net.digitalingot.feather.serverapi.velocity.ui.rpc.RpcService; +import net.digitalingot.feather.serverapi.velocity.update.UpdateNotifier; +import org.slf4j.Logger; public class FeatherVelocityPlugin { + private final ProxyServer server; private final Logger logger; @@ -12,7 +24,35 @@ public class FeatherVelocityPlugin { public FeatherVelocityPlugin(ProxyServer server, Logger logger) { this.server = server; this.logger = logger; + } + + @Subscribe + public void onProxyInitialize(ProxyInitializeEvent event) { + VelocityEventService eventService = new VelocityEventService(this, server); + VelocityPlayerService playerService = new VelocityPlayerService(this, server); + + RpcService rpcService = new RpcService(this, server); + UpdateNotifier updateNotifier = new UpdateNotifier(this, server); + VelocityMessagingService messagingService = + new VelocityMessagingService(this, server, playerService, rpcService, updateNotifier); + VelocityUIService uiService = new VelocityUIService(messagingService, rpcService, server); + + VelocityMetaService metaService = new VelocityMetaService(this); + VelocityFeatherService velocityFeatherService = + new VelocityFeatherService(eventService, playerService, uiService, metaService); + FeatherAPI.register(velocityFeatherService); + } + + @Subscribe + public void onProxyShutdown(ProxyShutdownEvent event) { + + } + + public ProxyServer getServer() { + return server; + } - throw new UnsupportedOperationException("Not yet implemented"); + public Logger getLogger() { + return logger; } } diff --git a/velocity/src/main/java/net/digitalingot/feather/serverapi/velocity/VelocityFeatherService.java b/velocity/src/main/java/net/digitalingot/feather/serverapi/velocity/VelocityFeatherService.java new file mode 100644 index 0000000..8c51e0c --- /dev/null +++ b/velocity/src/main/java/net/digitalingot/feather/serverapi/velocity/VelocityFeatherService.java @@ -0,0 +1,61 @@ +package net.digitalingot.feather.serverapi.velocity; + +import net.digitalingot.feather.serverapi.api.FeatherService; +import net.digitalingot.feather.serverapi.api.event.EventService; +import net.digitalingot.feather.serverapi.api.meta.MetaService; +import net.digitalingot.feather.serverapi.api.player.PlayerService; +import net.digitalingot.feather.serverapi.api.ui.UIService; +import net.digitalingot.feather.serverapi.api.waypoint.WaypointService; +import net.digitalingot.feather.serverapi.velocity.event.VelocityEventService; +import net.digitalingot.feather.serverapi.velocity.meta.VelocityMetaService; +import net.digitalingot.feather.serverapi.velocity.player.VelocityPlayerService; +import net.digitalingot.feather.serverapi.velocity.ui.VelocityUIService; +import org.jetbrains.annotations.NotNull; + +public class VelocityFeatherService implements FeatherService { + + @NotNull + private final VelocityEventService eventService; + @NotNull + private final VelocityPlayerService playerService; + @NotNull + private final VelocityUIService uiService; + @NotNull + private final VelocityMetaService metaService; + + public VelocityFeatherService( + @NotNull VelocityEventService eventService, + @NotNull VelocityPlayerService playerService, + @NotNull VelocityUIService uiService, + @NotNull VelocityMetaService metaService) { + this.eventService = eventService; + this.playerService = playerService; + this.uiService = uiService; + this.metaService = metaService; + } + + @Override + public @NotNull EventService getEventService() { + return this.eventService; + } + + @Override + public @NotNull PlayerService getPlayerService() { + return this.playerService; + } + + @Override + public @NotNull UIService getUIService() { + return this.uiService; + } + + @Override + public @NotNull WaypointService getWaypointService() { + return null; + } + + @Override + public @NotNull MetaService getMetaService() { + return this.metaService; + } +} diff --git a/velocity/src/main/java/net/digitalingot/feather/serverapi/velocity/event/VelocityEventService.java b/velocity/src/main/java/net/digitalingot/feather/serverapi/velocity/event/VelocityEventService.java new file mode 100644 index 0000000..c5021d3 --- /dev/null +++ b/velocity/src/main/java/net/digitalingot/feather/serverapi/velocity/event/VelocityEventService.java @@ -0,0 +1,61 @@ +package net.digitalingot.feather.serverapi.velocity.event; + +import com.google.common.collect.ImmutableMap; +import com.velocitypowered.api.event.EventManager; +import com.velocitypowered.api.proxy.ProxyServer; +import java.util.Map; +import java.util.function.Consumer; +import net.digitalingot.feather.serverapi.api.event.EventService; +import net.digitalingot.feather.serverapi.api.event.EventSubscription; +import net.digitalingot.feather.serverapi.api.event.FeatherEvent; +import net.digitalingot.feather.serverapi.api.event.player.PlayerHelloEvent; +import net.digitalingot.feather.serverapi.velocity.FeatherVelocityPlugin; +import net.digitalingot.feather.serverapi.velocity.event.player.VelocityPlayerHelloEvent; +import org.jetbrains.annotations.NotNull; + +public class VelocityEventService implements EventService { + + private static final Map, Class> + MAPPING = + ImmutableMap., Class>builder() + .put(PlayerHelloEvent.class, VelocityPlayerHelloEvent.class) + .build(); + + private final FeatherVelocityPlugin plugin; + private final ProxyServer server; + + public VelocityEventService(FeatherVelocityPlugin plugin, ProxyServer server) { + this.plugin = plugin; + this.server = server; + } + + @Override + public @NotNull EventSubscription subscribe( + @NotNull Class eventClazz, @NotNull Consumer handler) { + return subscribe(eventClazz, handler, this.plugin); + } + + @SuppressWarnings("unchecked") + @Override + public @NotNull EventSubscription subscribe( + @NotNull Class eventClazz, @NotNull Consumer handler, @NotNull Object plugin) { + final Class velocityEventClass = MAPPING.get(eventClazz); + final EventManager eventManager = server.getEventManager(); + final Object listener = new Object(); + eventManager.register( + plugin, + velocityEventClass, + event -> { + try { + handler.accept((T) event); + } catch (Throwable throwable) { + throwable.printStackTrace(); + } + }); + + return new VelocityEventSubscription<>( + (Class) velocityEventClass, handler, eventManager, listener); + } + + +} diff --git a/velocity/src/main/java/net/digitalingot/feather/serverapi/velocity/event/VelocityEventSubscription.java b/velocity/src/main/java/net/digitalingot/feather/serverapi/velocity/event/VelocityEventSubscription.java new file mode 100644 index 0000000..29b43ea --- /dev/null +++ b/velocity/src/main/java/net/digitalingot/feather/serverapi/velocity/event/VelocityEventSubscription.java @@ -0,0 +1,38 @@ +package net.digitalingot.feather.serverapi.velocity.event; + +import com.velocitypowered.api.event.EventManager; +import java.util.function.Consumer; +import net.digitalingot.feather.serverapi.api.event.EventSubscription; +import net.digitalingot.feather.serverapi.api.event.FeatherEvent; +import org.jetbrains.annotations.NotNull; + +public class VelocityEventSubscription implements EventSubscription { + + private final Class event; + private final Consumer handler; + private final EventManager eventManager; + private final Object listener; + + public VelocityEventSubscription( + Class event, Consumer handler, EventManager eventManager, Object listener) { + this.event = event; + this.handler = handler; + this.eventManager = eventManager; + this.listener = listener; + } + + @Override + public @NotNull Class getEvent() { + return event; + } + + @Override + public @NotNull Consumer getHandler() { + return handler; + } + + @Override + public void unsubscribe() { + eventManager.unregisterListeners(listener); + } +} diff --git a/velocity/src/main/java/net/digitalingot/feather/serverapi/velocity/event/VelocityFeatherEvent.java b/velocity/src/main/java/net/digitalingot/feather/serverapi/velocity/event/VelocityFeatherEvent.java new file mode 100644 index 0000000..64d2a18 --- /dev/null +++ b/velocity/src/main/java/net/digitalingot/feather/serverapi/velocity/event/VelocityFeatherEvent.java @@ -0,0 +1,20 @@ +package net.digitalingot.feather.serverapi.velocity.event; + +import net.digitalingot.feather.serverapi.api.event.FeatherEvent; +import net.digitalingot.feather.serverapi.api.player.FeatherPlayer; +import org.jetbrains.annotations.NotNull; + +public abstract class VelocityFeatherEvent implements FeatherEvent { + + private final FeatherPlayer player; + + public VelocityFeatherEvent(@NotNull FeatherPlayer player) { + this.player = player; + } + + @NotNull + @Override + public FeatherPlayer getPlayer() { + return this.player; + } +} diff --git a/velocity/src/main/java/net/digitalingot/feather/serverapi/velocity/event/player/FeatherPlayerQuitEvent.java b/velocity/src/main/java/net/digitalingot/feather/serverapi/velocity/event/player/FeatherPlayerQuitEvent.java new file mode 100644 index 0000000..2681585 --- /dev/null +++ b/velocity/src/main/java/net/digitalingot/feather/serverapi/velocity/event/player/FeatherPlayerQuitEvent.java @@ -0,0 +1,12 @@ +package net.digitalingot.feather.serverapi.velocity.event.player; + +import net.digitalingot.feather.serverapi.api.player.FeatherPlayer; +import net.digitalingot.feather.serverapi.velocity.event.VelocityFeatherEvent; +import org.jetbrains.annotations.NotNull; + +public class FeatherPlayerQuitEvent extends VelocityFeatherEvent { + + public FeatherPlayerQuitEvent(@NotNull FeatherPlayer player) { + super(player); + } +} diff --git a/velocity/src/main/java/net/digitalingot/feather/serverapi/velocity/event/player/VelocityPlayerHelloEvent.java b/velocity/src/main/java/net/digitalingot/feather/serverapi/velocity/event/player/VelocityPlayerHelloEvent.java new file mode 100644 index 0000000..d6ea0ec --- /dev/null +++ b/velocity/src/main/java/net/digitalingot/feather/serverapi/velocity/event/player/VelocityPlayerHelloEvent.java @@ -0,0 +1,39 @@ +package net.digitalingot.feather.serverapi.velocity.event.player; + +import java.util.Collection; +import java.util.Collections; +import net.digitalingot.feather.serverapi.api.event.player.PlayerHelloEvent; +import net.digitalingot.feather.serverapi.api.model.FeatherMod; +import net.digitalingot.feather.serverapi.api.model.Platform; +import net.digitalingot.feather.serverapi.api.player.FeatherPlayer; +import net.digitalingot.feather.serverapi.velocity.event.VelocityFeatherEvent; +import org.jetbrains.annotations.NotNull; + +public class VelocityPlayerHelloEvent extends VelocityFeatherEvent implements PlayerHelloEvent { + + @NotNull + private final Platform platform; + @NotNull + private final Collection featherMods; + + public VelocityPlayerHelloEvent( + @NotNull FeatherPlayer player, + @NotNull Platform platform, + @NotNull Collection featherMods) { + super(player); + this.platform = platform; + this.featherMods = featherMods; + } + + @NotNull + @Override + public Platform getPlatform() { + return this.platform; + } + + @NotNull + @Override + public Collection getFeatherMods() { + return Collections.unmodifiableCollection(this.featherMods); + } +} diff --git a/velocity/src/main/java/net/digitalingot/feather/serverapi/velocity/messaging/VelocityMessagingService.java b/velocity/src/main/java/net/digitalingot/feather/serverapi/velocity/messaging/VelocityMessagingService.java new file mode 100644 index 0000000..4f6f6c3 --- /dev/null +++ b/velocity/src/main/java/net/digitalingot/feather/serverapi/velocity/messaging/VelocityMessagingService.java @@ -0,0 +1,260 @@ +package net.digitalingot.feather.serverapi.velocity.messaging; + +import com.google.common.collect.Maps; +import com.velocitypowered.api.event.Subscribe; +import com.velocitypowered.api.event.connection.DisconnectEvent; +import com.velocitypowered.api.event.connection.PluginMessageEvent; +import com.velocitypowered.api.proxy.Player; +import com.velocitypowered.api.proxy.ProxyServer; +import com.velocitypowered.api.proxy.messages.ChannelIdentifier; +import com.velocitypowered.api.proxy.messages.MinecraftChannelIdentifier; +import java.util.Collection; +import java.util.List; +import java.util.Map; +import java.util.UUID; +import java.util.stream.Collectors; +import net.digitalingot.feather.serverapi.api.model.FeatherMod; +import net.digitalingot.feather.serverapi.api.model.Platform; +import net.digitalingot.feather.serverapi.api.player.FeatherPlayer; +import net.digitalingot.feather.serverapi.messaging.Message; +import net.digitalingot.feather.serverapi.messaging.MessageConstants; +import net.digitalingot.feather.serverapi.messaging.MessageDecoder; +import net.digitalingot.feather.serverapi.messaging.MessageEncoder; +import net.digitalingot.feather.serverapi.messaging.MessageFragmenter; +import net.digitalingot.feather.serverapi.messaging.ServerMessageHandler; +import net.digitalingot.feather.serverapi.messaging.messages.client.S2CHandshake; +import net.digitalingot.feather.serverapi.messaging.messages.server.C2SClientHello; +import net.digitalingot.feather.serverapi.messaging.messages.server.C2SHandshake; +import net.digitalingot.feather.serverapi.velocity.FeatherVelocityPlugin; +import net.digitalingot.feather.serverapi.velocity.event.player.VelocityPlayerHelloEvent; +import net.digitalingot.feather.serverapi.velocity.player.VelocityFeatherPlayer; +import net.digitalingot.feather.serverapi.velocity.player.VelocityPlayerService; +import net.digitalingot.feather.serverapi.velocity.ui.rpc.RpcService; +import net.digitalingot.feather.serverapi.velocity.update.UpdateNotifier; +import org.jetbrains.annotations.NotNull; + +public class VelocityMessagingService { + + static final ChannelIdentifier CHANNEL = MinecraftChannelIdentifier.from( + "feather:client"); + static final ChannelIdentifier CHANNEL_FRAGMENTED = MinecraftChannelIdentifier.from( + "feather:client/frag"); + + @NotNull + final FeatherVelocityPlugin plugin; + @NotNull + final ProxyServer server; + @NotNull + final VelocityPlayerService playerService; + @NotNull + final RpcService rpcService; + @NotNull + final Handshaking handshaking; + + public VelocityMessagingService( + @NotNull FeatherVelocityPlugin plugin, + @NotNull ProxyServer server, + @NotNull VelocityPlayerService playerService, + @NotNull RpcService rpcService, + @NotNull UpdateNotifier updateNotifier) { + this.plugin = plugin; + this.server = server; + this.playerService = playerService; + this.rpcService = rpcService; + this.handshaking = new Handshaking(this, updateNotifier); + server.getEventManager().register(plugin, this.handshaking); + server.getChannelRegistrar().register(CHANNEL, CHANNEL_FRAGMENTED); + server.getEventManager().register(plugin, this); + } + + @Subscribe + public void onPluginMessage(PluginMessageEvent event) { + event.setResult(PluginMessageEvent.ForwardResult.handled()); + + if (!(event.getSource() instanceof Player player)) { + return; + } + + if (!event.getIdentifier().equals(CHANNEL)) { + return; + } + + VelocityFeatherPlayer featherPlayer = this.playerService.getPlayer(player.getUniqueId()); + + if (featherPlayer != null) { + Message decodedMessage; + + try { + decodedMessage = MessageDecoder.SERVER_BOUND.decode(event.getData()); + } catch (Exception exception) { + exception.printStackTrace(); + return; + } + + handleMessage(featherPlayer, decodedMessage); + } else { + C2SClientHello hello = this.handshaking.handle(player, event.getData()); + + if (hello != null) { + handleHello(player, hello); + } + } + } + + public void fireEvent(Object event) { + this.server.getEventManager().fireAndForget(event); + } + + private void handleHello(Player player, C2SClientHello hello) { + VelocityFeatherPlayer featherPlayer = new VelocityFeatherPlayer(player, this, this.rpcService); + this.playerService.register(featherPlayer); + + Platform platform = switch (hello.getPlatform()) { + case FABRIC -> Platform.FABRIC; + case FORGE -> Platform.FORGE; + }; + + VelocityPlayerHelloEvent helloEvent = + new VelocityPlayerHelloEvent( + featherPlayer, + platform, + hello.getFeatherMods().stream() + .map(domain -> new FeatherMod(domain.getName())) + .collect(Collectors.toList())); + + this.fireEvent(helloEvent); + } + + private void handleMessage(VelocityFeatherPlayer player, Message message) { + player.handleMessage(message); + } + + public void sendMessage(VelocityFeatherPlayer player, Message message) { + sendMessage(player.getPlayer(), message); + } + + public void sendMessage(Collection recipients, Message message) { + if (recipients.isEmpty()) { + return; + } + + byte[] encoded = MessageEncoder.CLIENT_BOUND.encode(message); + if (encoded.length > 32767) { + List fragments = MessageFragmenter.CLIENT_BOUND.fragment(message); + for (FeatherPlayer recipient : recipients) { + for (byte[] data : fragments) { + sendPluginMessage( + ((VelocityFeatherPlayer) recipient).getPlayer(), CHANNEL_FRAGMENTED, data); + } + } + } else { + for (FeatherPlayer recipient : recipients) { + sendPluginMessage(((VelocityFeatherPlayer) recipient).getPlayer(), CHANNEL, encoded); + } + } + } + + public void sendMessage(Player player, Message message) { + byte[] encoded = MessageEncoder.CLIENT_BOUND.encode(message); + + if (encoded.length > 32767) { + for (byte[] data : MessageFragmenter.CLIENT_BOUND.fragment(message)) { + sendPluginMessage(player, CHANNEL_FRAGMENTED, data); + } + } else { + sendPluginMessage(player, CHANNEL, encoded); + } + } + + private void sendPluginMessage(@NotNull Player player, @NotNull ChannelIdentifier channel, + byte[] data) { + player.sendPluginMessage(channel, data); + } + + private static class Handshaking { + + private final VelocityMessagingService messagingService; + private final Map handshakes = Maps.newHashMap(); + private final UpdateNotifier updateNotifier; + + public Handshaking(VelocityMessagingService messagingService, UpdateNotifier updateNotifier) { + this.messagingService = messagingService; + this.updateNotifier = updateNotifier; + } + + private HandshakeState getState(Player player) { + return this.handshakes.getOrDefault(player.getUniqueId(), HandshakeState.EXPECTING_HANDSHAKE); + } + + private void setState(UUID playerId, HandshakeState state) { + this.handshakes.put(playerId, state); + } + + private void accept(Player player) { + setState(player.getUniqueId(), HandshakeState.EXPECTING_HELLO); + this.messagingService.sendMessage(player, new S2CHandshake()); + } + + private void reject(Player player) { + setState(player.getUniqueId(), HandshakeState.REJECTED); + } + + private void finish(Player player) { + this.handshakes.remove(player.getUniqueId()); + } + + private C2SClientHello handle(Player player, byte[] data) { + HandshakeState state = getState(player); + + if (state == HandshakeState.REJECTED) { + return null; + } + + Message message; + try { + message = MessageDecoder.SERVER_BOUND.decode(data); + } catch (Exception exception) { + reject(player); + return null; + } + + if (state == HandshakeState.EXPECTING_HANDSHAKE) { + if (handleExpectingHandshake(message)) { + accept(player); + } else { + reject(player); + } + } else if (state == HandshakeState.EXPECTING_HELLO) { + if ((message instanceof C2SClientHello)) { + finish(player); + return (C2SClientHello) message; + } + reject(player); + } + + return null; + } + + private boolean handleExpectingHandshake(Message message) { + if (!(message instanceof C2SHandshake handshake)) { + return false; + } + int protocolVersion = handshake.getProtocolVersion(); + if (protocolVersion > MessageConstants.VERSION) { + this.updateNotifier.setPotentiallyOutOfDate(protocolVersion); + } + return true; + } + + @Subscribe + public void onPlayerQuit(DisconnectEvent event) { + finish(event.getPlayer()); + } + + private enum HandshakeState { + EXPECTING_HANDSHAKE, + EXPECTING_HELLO, + REJECTED + } + } +} diff --git a/velocity/src/main/java/net/digitalingot/feather/serverapi/velocity/meta/HashingUtils.java b/velocity/src/main/java/net/digitalingot/feather/serverapi/velocity/meta/HashingUtils.java new file mode 100644 index 0000000..ef89dd1 --- /dev/null +++ b/velocity/src/main/java/net/digitalingot/feather/serverapi/velocity/meta/HashingUtils.java @@ -0,0 +1,23 @@ +package net.digitalingot.feather.serverapi.velocity.meta; + +import com.google.common.hash.Hashing; + +/** + * Utility class for consistent hashing across the server list background implementation. + */ +class HashingUtils { + + private HashingUtils() { + throw new AssertionError(); + } + + /** + * Computes the SHA-1 hash of the given bytes. + * + * @param bytes the bytes to hash + * @return the SHA-1 hash as a byte array + */ + static byte[] sha1(byte[] bytes) { + return Hashing.sha1().hashBytes(bytes).asBytes(); + } +} diff --git a/velocity/src/main/java/net/digitalingot/feather/serverapi/velocity/meta/ServerListBackgroundValidator.java b/velocity/src/main/java/net/digitalingot/feather/serverapi/velocity/meta/ServerListBackgroundValidator.java new file mode 100644 index 0000000..cf9311f --- /dev/null +++ b/velocity/src/main/java/net/digitalingot/feather/serverapi/velocity/meta/ServerListBackgroundValidator.java @@ -0,0 +1,157 @@ +package net.digitalingot.feather.serverapi.velocity.meta; + +import java.awt.image.BufferedImage; +import java.io.ByteArrayInputStream; +import java.io.IOException; +import java.util.Arrays; +import java.util.Iterator; +import javax.imageio.ImageIO; +import javax.imageio.ImageReader; +import javax.imageio.stream.ImageInputStream; +import net.digitalingot.feather.serverapi.api.meta.ServerListBackground; +import net.digitalingot.feather.serverapi.api.meta.exception.ImageSizeExceededException; +import net.digitalingot.feather.serverapi.api.meta.exception.InvalidImageException; +import net.digitalingot.feather.serverapi.api.meta.exception.UnsupportedImageFormatException; +import net.digitalingot.feather.serverapi.api.meta.format.ImageFormat; +import org.jetbrains.annotations.NotNull; + +/** + * Validates server list background images according to size and format requirements. + */ +class ServerListBackgroundValidator { + + private static final int MAX_WIDTH = 1009; + private static final int MAX_HEIGHT = 202; + private static final long MAX_FILE_SIZE = 512 * 1024; // 512KB + + private static final byte[] PNG_HEADER = {(byte) 0x89, 'P', 'N', 'G', '\r', '\n', 0x1A, '\n'}; + + /** + * Validates a server list background according to size and format requirements. + * + *

Note: This method performs I/O operations when validating images and should be called off + * the main thread to avoid blocking. + * + * @param background the background to validate + * @throws UnsupportedImageFormatException if the image format is not supported + * @throws ImageSizeExceededException if the image exceeds the maximum allowed dimensions or + * file size + * @throws InvalidImageException if the image is invalid or corrupted + */ + static void validate(@NotNull ServerListBackground background) + throws UnsupportedImageFormatException, ImageSizeExceededException, InvalidImageException { + if (background instanceof SimpleServerListBackground) { + return; + } + + byte[] imageBytes = background.getImage(); + validateImageBytes(imageBytes); + validateHash(imageBytes, background.getHash()); + + if (background.getImageFormat() != ImageFormat.PNG) { + throw new UnsupportedImageFormatException("Only PNG format is supported"); + } + } + + /** + * Validates raw image bytes according to size and format requirements. + * + * @param imageBytes the image bytes to validate + * @throws UnsupportedImageFormatException if the image format is not supported + * @throws ImageSizeExceededException if the image exceeds the maximum allowed dimensions or + * file size + * @throws InvalidImageException if the image is invalid or corrupted + */ + static void validateImageBytes(byte[] imageBytes) + throws UnsupportedImageFormatException, ImageSizeExceededException, InvalidImageException { + validateFileSize(imageBytes); + validatePngFormat(imageBytes); + validateDimensions(loadImage(imageBytes)); + } + + /** + * Validates the file size of the image. + */ + private static void validateFileSize(byte[] imageBytes) throws ImageSizeExceededException { + if (imageBytes.length > MAX_FILE_SIZE) { + throw new ImageSizeExceededException( + ImageSizeExceededException.Type.FILE_SIZE, imageBytes.length, MAX_FILE_SIZE); + } + } + + /** + * Validates that the image is in PNG format by checking the header bytes. + */ + private static void validatePngFormat(byte[] imageBytes) throws UnsupportedImageFormatException { + if (!isPngFormat(imageBytes)) { + throw new UnsupportedImageFormatException("Only PNG format is supported"); + } + } + + /** + * Loads the image from bytes into a BufferedImage. + */ + private static BufferedImage loadImage(byte[] imageBytes) throws InvalidImageException { + try { + Iterator readers = ImageIO.getImageReadersByFormatName("PNG"); + if (!readers.hasNext()) { + throw new InvalidImageException("No PNG image reader available"); + } + + ImageReader reader = readers.next(); + try (ByteArrayInputStream input = new ByteArrayInputStream(imageBytes); + ImageInputStream imageInput = ImageIO.createImageInputStream(input)) { + reader.setInput(imageInput); + return reader.read(0); + } finally { + reader.dispose(); + } + } catch (IOException ioException) { + throw new InvalidImageException("Failed to load image", ioException); + } + } + + /** + * Validates the dimensions of the image. + */ + private static void validateDimensions(BufferedImage image) throws ImageSizeExceededException { + int width = image.getWidth(); + int height = image.getHeight(); + + if (width > MAX_WIDTH) { + throw new ImageSizeExceededException(ImageSizeExceededException.Type.WIDTH, width, MAX_WIDTH); + } + if (height > MAX_HEIGHT) { + throw new ImageSizeExceededException( + ImageSizeExceededException.Type.HEIGHT, height, MAX_HEIGHT); + } + } + + /** + * Validates that the provided hash matches the image bytes. + */ + private static void validateHash(byte[] imageBytes, byte[] providedHash) + throws InvalidImageException { + byte[] computedHash = HashingUtils.sha1(imageBytes); + if (!Arrays.equals(computedHash, providedHash)) { + throw new InvalidImageException("Image hash does not match provided hash"); + } + } + + /** + * Checks if the given bytes represent a PNG image by examining the file header. + */ + private static boolean isPngFormat(byte[] bytes) { + if (bytes.length < PNG_HEADER.length) { + return false; + } + + for (int iii = 0; iii < PNG_HEADER.length; iii++) { + if (bytes[iii] != PNG_HEADER[iii]) { + return false; + } + } + + return true; + } +} diff --git a/velocity/src/main/java/net/digitalingot/feather/serverapi/velocity/meta/SimpleServerListBackground.java b/velocity/src/main/java/net/digitalingot/feather/serverapi/velocity/meta/SimpleServerListBackground.java new file mode 100644 index 0000000..82c13e5 --- /dev/null +++ b/velocity/src/main/java/net/digitalingot/feather/serverapi/velocity/meta/SimpleServerListBackground.java @@ -0,0 +1,34 @@ +package net.digitalingot.feather.serverapi.velocity.meta; + +import net.digitalingot.feather.serverapi.api.meta.ServerListBackground; +import net.digitalingot.feather.serverapi.api.meta.format.ImageFormat; +import org.jetbrains.annotations.NotNull; + +class SimpleServerListBackground implements ServerListBackground { + + private final byte[] image; + private final byte[] hash; + private final ImageFormat imageFormat; + + SimpleServerListBackground(byte[] image, byte[] hash, ImageFormat imageFormat) { + this.image = image; + this.hash = hash; + this.imageFormat = imageFormat; + } + + @Override + public byte[] getImage() { + return this.image; + } + + @Override + public byte[] getHash() { + return this.hash; + } + + @NotNull + @Override + public ImageFormat getImageFormat() { + return this.imageFormat; + } +} diff --git a/velocity/src/main/java/net/digitalingot/feather/serverapi/velocity/meta/VelocityMetaService.java b/velocity/src/main/java/net/digitalingot/feather/serverapi/velocity/meta/VelocityMetaService.java new file mode 100644 index 0000000..24aeb85 --- /dev/null +++ b/velocity/src/main/java/net/digitalingot/feather/serverapi/velocity/meta/VelocityMetaService.java @@ -0,0 +1,80 @@ +package net.digitalingot.feather.serverapi.velocity.meta; + +import com.velocitypowered.api.event.Subscribe; +import net.digitalingot.feather.serverapi.api.meta.DiscordActivity; +import net.digitalingot.feather.serverapi.api.meta.MetaService; +import net.digitalingot.feather.serverapi.api.meta.ServerListBackground; +import net.digitalingot.feather.serverapi.api.meta.ServerListBackgroundFactory; +import net.digitalingot.feather.serverapi.api.meta.exception.ImageSizeExceededException; +import net.digitalingot.feather.serverapi.api.meta.exception.InvalidImageException; +import net.digitalingot.feather.serverapi.api.meta.exception.UnsupportedImageFormatException; +import net.digitalingot.feather.serverapi.api.player.FeatherPlayer; +import net.digitalingot.feather.serverapi.messaging.messages.client.S2CClearDiscordActivity; +import net.digitalingot.feather.serverapi.messaging.messages.client.S2CServerBackground; +import net.digitalingot.feather.serverapi.messaging.messages.client.S2CServerBackground.Action; +import net.digitalingot.feather.serverapi.messaging.messages.client.S2CSetDiscordActivity; +import net.digitalingot.feather.serverapi.velocity.FeatherVelocityPlugin; +import net.digitalingot.feather.serverapi.velocity.event.player.VelocityPlayerHelloEvent; +import net.digitalingot.feather.serverapi.velocity.player.VelocityFeatherPlayer; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +public class VelocityMetaService implements MetaService { + + private final ServerListBackgroundFactory serverListBackgroundFactory = + new VelocityServerListBackgroundFactory(); + @Nullable + private volatile ServerListBackground serverListBackground = null; + + public VelocityMetaService(@NotNull FeatherVelocityPlugin plugin) { + plugin.getServer().getEventManager().register(plugin, this); + } + + @Override + public synchronized void setServerListBackground( + @NotNull ServerListBackground serverListBackground) + throws UnsupportedImageFormatException, ImageSizeExceededException, InvalidImageException { + ServerListBackgroundValidator.validate(serverListBackground); + this.serverListBackground = serverListBackground; + } + + @Override + public synchronized @Nullable ServerListBackground getServerListBackground() { + return this.serverListBackground; + } + + @Override + public void updateDiscordActivity( + @NotNull FeatherPlayer player, @NotNull DiscordActivity discordActivity) { + ((VelocityFeatherPlayer) player) + .sendMessage( + new S2CSetDiscordActivity( + discordActivity.getImage().orElse(null), + discordActivity.getImageText().orElse(null), + discordActivity.getState().orElse(null), + discordActivity.getDetails().orElse(null), + discordActivity.getPartySize().orElse(null), + discordActivity.getPartyMax().orElse(null), + discordActivity.getStartTimestamp().orElse(null), + discordActivity.getEndTimestamp().orElse(null))); + } + + @Override + public void clearDiscordActivity(@NotNull FeatherPlayer player) { + ((VelocityFeatherPlayer) player).sendMessage(new S2CClearDiscordActivity()); + } + + @Override + public @NotNull ServerListBackgroundFactory getServerListBackgroundFactory() { + return this.serverListBackgroundFactory; + } + + @Subscribe + public void onFeatherPlayerHello(VelocityPlayerHelloEvent event) { + ServerListBackground background = getServerListBackground(); + if (background != null) { + ((VelocityFeatherPlayer) event.getPlayer()) + .sendMessage(new S2CServerBackground(Action.HASH, background.getHash())); + } + } +} diff --git a/velocity/src/main/java/net/digitalingot/feather/serverapi/velocity/meta/VelocityServerListBackgroundFactory.java b/velocity/src/main/java/net/digitalingot/feather/serverapi/velocity/meta/VelocityServerListBackgroundFactory.java new file mode 100644 index 0000000..75e0e58 --- /dev/null +++ b/velocity/src/main/java/net/digitalingot/feather/serverapi/velocity/meta/VelocityServerListBackgroundFactory.java @@ -0,0 +1,49 @@ +package net.digitalingot.feather.serverapi.velocity.meta; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.EnumSet; +import java.util.Set; +import net.digitalingot.feather.serverapi.api.meta.ServerListBackground; +import net.digitalingot.feather.serverapi.api.meta.ServerListBackgroundFactory; +import net.digitalingot.feather.serverapi.api.meta.exception.ImageSizeExceededException; +import net.digitalingot.feather.serverapi.api.meta.exception.InvalidImageException; +import net.digitalingot.feather.serverapi.api.meta.exception.UnsupportedImageFormatException; +import net.digitalingot.feather.serverapi.api.meta.format.ImageFormat; +import org.jetbrains.annotations.NotNull; + +class VelocityServerListBackgroundFactory implements ServerListBackgroundFactory { + + @Override + public @NotNull Set getSupportedFormats() { + return EnumSet.allOf(ImageFormat.class); + } + + @Override + public @NotNull ServerListBackground byPath(@NotNull Path path) + throws IOException, + UnsupportedImageFormatException, + ImageSizeExceededException, + InvalidImageException { + byte[] imageBytes = Files.readAllBytes(path); + ServerListBackgroundValidator.validateImageBytes(imageBytes); + byte[] imageHash = HashingUtils.sha1(imageBytes); + return new SimpleServerListBackground(imageBytes, imageHash, ImageFormat.PNG); + } + + @Override + public @NotNull ServerListBackground fromBytes(byte[] bytes, @NotNull ImageFormat format) + throws UnsupportedImageFormatException, ImageSizeExceededException, InvalidImageException { + ServerListBackgroundValidator.validateImageBytes(bytes); + byte[] imageHash = HashingUtils.sha1(bytes); + return new SimpleServerListBackground(bytes, imageHash, format); + } + + @Override + public @NotNull ServerListBackground fromBytes(byte[] bytes) + throws UnsupportedImageFormatException, ImageSizeExceededException, InvalidImageException { + // Currently only PNG is supported, so we can assume PNG format + return fromBytes(bytes, ImageFormat.PNG); + } +} diff --git a/velocity/src/main/java/net/digitalingot/feather/serverapi/velocity/player/PlayerMessageHandler.java b/velocity/src/main/java/net/digitalingot/feather/serverapi/velocity/player/PlayerMessageHandler.java new file mode 100644 index 0000000..f251a55 --- /dev/null +++ b/velocity/src/main/java/net/digitalingot/feather/serverapi/velocity/player/PlayerMessageHandler.java @@ -0,0 +1,144 @@ +package net.digitalingot.feather.serverapi.velocity.player; + +import com.google.common.cache.Cache; +import com.google.common.cache.CacheBuilder; +import com.google.common.cache.RemovalCause; +import java.util.Collection; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.TimeoutException; +import java.util.stream.Collectors; +import net.digitalingot.feather.serverapi.api.FeatherAPI; +import net.digitalingot.feather.serverapi.api.meta.ServerListBackground; +import net.digitalingot.feather.serverapi.api.model.FeatherMod; +import net.digitalingot.feather.serverapi.messaging.ServerMessageHandler; +import net.digitalingot.feather.serverapi.messaging.messages.client.S2CGetEnabledMods; +import net.digitalingot.feather.serverapi.messaging.messages.client.S2CServerBackground; +import net.digitalingot.feather.serverapi.messaging.messages.server.C2SEnabledMods; +import net.digitalingot.feather.serverapi.messaging.messages.server.C2SFUILoadError; +import net.digitalingot.feather.serverapi.messaging.messages.server.C2SFUIRequest; +import net.digitalingot.feather.serverapi.messaging.messages.server.C2SFUIStateChange; +import net.digitalingot.feather.serverapi.messaging.messages.server.C2SRequestServerBackground; +import net.digitalingot.feather.serverapi.velocity.ui.VelocityUIPage; +import net.digitalingot.feather.serverapi.velocity.ui.VelocityUIService; +import net.digitalingot.feather.serverapi.velocity.ui.rpc.RpcService; +import org.jetbrains.annotations.NotNull; + +class PlayerMessageHandler implements ServerMessageHandler { + + private final int REQUEST_TIMEOUT_SECONDS = 30; + + private final VelocityFeatherPlayer player; + private final RpcService rpcService; + + private boolean sentServerListBackground = false; + + @SuppressWarnings("UnstableApiUsage") + private final Cache>> pendingModsRequests = + CacheBuilder.newBuilder() + .expireAfterWrite(REQUEST_TIMEOUT_SECONDS, TimeUnit.SECONDS) + .>>removalListener( + removalNotification -> { + RemovalCause cause = removalNotification.getCause(); + if (cause != RemovalCause.EXPLICIT && cause != RemovalCause.REPLACED) { + removalNotification.getValue().completeExceptionally(new TimeoutException()); + } + }) + .build(); + + private int requestId = 0; + + public PlayerMessageHandler(VelocityFeatherPlayer player, RpcService rpcService) { + this.player = player; + this.rpcService = rpcService; + } + + @Override + public void handle(C2SFUIRequest request) { + String rpcHost = request.getFrame(); + String rpcPath = request.getPath(); + int requestId = request.getId(); + String payload = request.getPayload(); + this.rpcService.handle(this.player, rpcHost, rpcPath, requestId, payload); + } + + @Override + public void handle(C2SFUIStateChange stateChange) { + VelocityUIService uiService = (VelocityUIService) FeatherAPI.getUIService(); + + VelocityUIPage page = uiService.getPageByFrame(stateChange.getFrame()); + + if (page == null) { + return; + } + + switch (stateChange.getType()) { + case CREATED: + page.onCreated(this.player); + break; + case DESTROYED: + page.onDestroyed(this.player); + break; + case FOCUS_GAINED: + page.onFocusGained(this.player); + break; + case FOCUS_LOST: + page.onFocusLost(this.player); + break; + case VISIBLE: + page.onShow(this.player); + break; + case INVISIBLE: + page.onHide(this.player); + break; + default: + break; + } + } + + @Override + public void handle(C2SFUILoadError loadError) { + VelocityUIService uiService = (VelocityUIService) FeatherAPI.getUIService(); + VelocityUIPage page = uiService.getPageByFrame(loadError.getFrame()); + + if (page != null) { + page.onLoadError(this.player, loadError.getErrorText()); + } + } + + + @Override + public void handle(C2SEnabledMods enabledMods) { + @SuppressWarnings("UnstableApiUsage") + CompletableFuture<@NotNull Collection<@NotNull FeatherMod>> future = + this.pendingModsRequests.asMap().remove(enabledMods.getId()); + if (future != null) { + future.complete( + enabledMods.getMods().stream() + .map(mod -> new FeatherMod(mod.getName())) + .collect(Collectors.toList())); + } + } + + @Override + public void handle(C2SRequestServerBackground serverBackground) { + if (!this.sentServerListBackground) { + ServerListBackground serverListBackground = + FeatherAPI.getMetaService().getServerListBackground(); + if (serverListBackground != null) { + this.player.sendMessage( + new S2CServerBackground(S2CServerBackground.Action.DATA, + serverListBackground.getImage())); + this.sentServerListBackground = true; + } + } + } + + public @NotNull CompletableFuture<@NotNull Collection<@NotNull FeatherMod>> requestEnabledMods() { + int id = this.requestId++; + CompletableFuture<@NotNull Collection<@NotNull FeatherMod>> future = new CompletableFuture<>(); + this.pendingModsRequests.put(id, future); + this.player.sendMessage(new S2CGetEnabledMods(id)); + return future; + } +} diff --git a/velocity/src/main/java/net/digitalingot/feather/serverapi/velocity/player/VelocityFeatherPlayer.java b/velocity/src/main/java/net/digitalingot/feather/serverapi/velocity/player/VelocityFeatherPlayer.java new file mode 100644 index 0000000..ee48da0 --- /dev/null +++ b/velocity/src/main/java/net/digitalingot/feather/serverapi/velocity/player/VelocityFeatherPlayer.java @@ -0,0 +1,118 @@ +package net.digitalingot.feather.serverapi.velocity.player; + +import com.google.common.collect.Sets; +import com.velocitypowered.api.proxy.Player; +import java.util.Collection; +import java.util.Collections; +import java.util.Set; +import java.util.UUID; +import java.util.concurrent.CompletableFuture; +import java.util.stream.Collectors; +import net.digitalingot.feather.serverapi.api.model.FeatherMod; +import net.digitalingot.feather.serverapi.api.player.FeatherPlayer; +import net.digitalingot.feather.serverapi.messaging.Message; +import net.digitalingot.feather.serverapi.messaging.ServerMessageHandler; +import net.digitalingot.feather.serverapi.messaging.messages.client.S2CMissPenaltyState; +import net.digitalingot.feather.serverapi.messaging.messages.client.S2CModsAction; +import net.digitalingot.feather.serverapi.velocity.messaging.VelocityMessagingService; +import net.digitalingot.feather.serverapi.velocity.ui.rpc.RpcService; +import org.jetbrains.annotations.NotNull; + +public class VelocityFeatherPlayer implements FeatherPlayer { + + @NotNull + private final Player player; + @NotNull + private final VelocityMessagingService messagingService; + @NotNull + private final PlayerMessageHandler messageHandler; + private final Set blockedMods = Sets.newHashSet(); + + public VelocityFeatherPlayer( + @NotNull Player player, + @NotNull VelocityMessagingService messagingService, + @NotNull RpcService rpcService) { + this.player = player; + this.messagingService = messagingService; + this.messageHandler = new PlayerMessageHandler(this, rpcService); + } + + public @NotNull Player getPlayer() { + return this.player; + } + + @Override + public @NotNull UUID getUniqueId() { + return this.player.getUniqueId(); + } + + @Override + public @NotNull String getName() { + return this.player.getUsername(); + } + + @Override + public void blockMods(@NotNull Collection<@NotNull FeatherMod> mods) { + this.blockedMods.addAll(mods); + sendModsAction(S2CModsAction.Action.BLOCK, mods); + } + + @Override + public void unblockMods(@NotNull Collection<@NotNull FeatherMod> mods) { + this.blockedMods.removeAll(mods); + sendModsAction(S2CModsAction.Action.UNBLOCK, mods); + } + + @Override + public CompletableFuture<@NotNull Collection<@NotNull FeatherMod>> getBlockedMods() { + return CompletableFuture.completedFuture(Collections.unmodifiableCollection(this.blockedMods)); + } + + @Override + public void enableMods(@NotNull Collection<@NotNull FeatherMod> mods) { + sendModsAction(S2CModsAction.Action.ENABLE, mods); + } + + @Override + public void disableMods(@NotNull Collection<@NotNull FeatherMod> mods) { + sendModsAction(S2CModsAction.Action.DISABLE, mods); + } + + @Override + public @NotNull CompletableFuture<@NotNull Collection<@NotNull FeatherMod>> getEnabledMods() { + return this.messageHandler.requestEnabledMods(); + } + + @Override + public void bypassMissPenalty(boolean enabled) { + sendMessage(new S2CMissPenaltyState(!enabled)); + } + + private void sendModsAction( + S2CModsAction.Action action, @NotNull Collection<@NotNull FeatherMod> mods) { + if (!mods.isEmpty()) { + sendMessage(new S2CModsAction(action, mapFeatherModsToDomain(mods))); + } + } + + private static Collection + mapFeatherModsToDomain(Collection mods) { + return mods.stream() + .map( + mod -> + new net.digitalingot.feather.serverapi.messaging.domain.FeatherMod(mod.getName())) + .collect(Collectors.toSet()); + } + + public void sendMessage(@NotNull Message message) { + this.messagingService.sendMessage(this.player, message); + } + + public void handleMessage(@NotNull Message message) { + message.handle(this.messageHandler); + } + + public void fireEvent(Object event) { + this.messagingService.fireEvent(event); + } +} diff --git a/velocity/src/main/java/net/digitalingot/feather/serverapi/velocity/player/VelocityPlayerService.java b/velocity/src/main/java/net/digitalingot/feather/serverapi/velocity/player/VelocityPlayerService.java new file mode 100644 index 0000000..8d18501 --- /dev/null +++ b/velocity/src/main/java/net/digitalingot/feather/serverapi/velocity/player/VelocityPlayerService.java @@ -0,0 +1,53 @@ +package net.digitalingot.feather.serverapi.velocity.player; + +import com.velocitypowered.api.event.PostOrder; +import com.velocitypowered.api.event.Subscribe; +import com.velocitypowered.api.event.connection.DisconnectEvent; +import com.velocitypowered.api.proxy.ProxyServer; +import java.util.Collection; +import java.util.Collections; +import java.util.HashMap; +import java.util.Map; +import java.util.UUID; +import net.digitalingot.feather.serverapi.api.player.FeatherPlayer; +import net.digitalingot.feather.serverapi.api.player.PlayerService; +import net.digitalingot.feather.serverapi.velocity.FeatherVelocityPlugin; +import net.digitalingot.feather.serverapi.velocity.event.player.FeatherPlayerQuitEvent; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +public class VelocityPlayerService implements PlayerService { + + private final ProxyServer server; + private final FeatherVelocityPlugin plugin; + private final Map players = new HashMap<>(); + + public VelocityPlayerService(FeatherVelocityPlugin plugin, ProxyServer server) { + this.plugin = plugin; + this.server = server; + this.server.getEventManager().register(plugin, this); + } + + public void register(VelocityFeatherPlayer player) { + this.players.put(player.getUniqueId(), player); + } + + @Override + public @Nullable VelocityFeatherPlayer getPlayer(@NotNull UUID playerId) { + return this.players.get(playerId); + } + + @Override + public @NotNull Collection getPlayers() { + return Collections.unmodifiableCollection(this.players.values()); + } + + @Subscribe(order = PostOrder.LAST) + public void onPlayerQuit(DisconnectEvent event) { + FeatherPlayer player = this.players.remove(event.getPlayer().getUniqueId()); + + if (player != null) { + this.server.getEventManager().fireAndForget(new FeatherPlayerQuitEvent(player)); + } + } +} diff --git a/velocity/src/main/java/net/digitalingot/feather/serverapi/velocity/ui/VelocityUIPage.java b/velocity/src/main/java/net/digitalingot/feather/serverapi/velocity/ui/VelocityUIPage.java new file mode 100644 index 0000000..f637be4 --- /dev/null +++ b/velocity/src/main/java/net/digitalingot/feather/serverapi/velocity/ui/VelocityUIPage.java @@ -0,0 +1,127 @@ +package net.digitalingot.feather.serverapi.velocity.ui; + +import com.velocitypowered.api.plugin.PluginContainer; +import java.util.Objects; +import net.digitalingot.feather.serverapi.api.player.FeatherPlayer; +import net.digitalingot.feather.serverapi.api.ui.UIPage; +import net.digitalingot.feather.serverapi.api.ui.handler.UIFocusHandler; +import net.digitalingot.feather.serverapi.api.ui.handler.UILifecycleHandler; +import net.digitalingot.feather.serverapi.api.ui.handler.UILoadHandler; +import net.digitalingot.feather.serverapi.api.ui.handler.UIVisibilityHandler; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +public class VelocityUIPage + implements UIPage, UILifecycleHandler, UILoadHandler, UIFocusHandler, UIVisibilityHandler { + + @NotNull + private final Object owner; + @NotNull + private final String url; + + @Nullable + private UILifecycleHandler lifecycleHandler; + @Nullable + private UILoadHandler loadHandler; + @Nullable + private UIFocusHandler focusHandler; + @Nullable + private UIVisibilityHandler visibilityHandler; + + public VelocityUIPage(@NotNull Object owner, @NotNull String url) { + this.owner = owner; + this.url = url; + } + + @NotNull + public Object getOwner() { + return owner; + } + + @NotNull + public String getRpcHostname() { + if (owner instanceof PluginContainer) { + return ((PluginContainer) owner).getDescription().getId(); + } else { + return owner.getClass().getSimpleName(); + } + } + + @NotNull + public String getPage() { + return this.url; + } + + @Override + public void setLifecycleHandler(@NotNull UILifecycleHandler lifecycleHandler) { + Objects.requireNonNull(lifecycleHandler); + this.lifecycleHandler = lifecycleHandler; + } + + @Override + public void setLoadHandler(@NotNull UILoadHandler loadHandler) { + Objects.requireNonNull(loadHandler); + this.loadHandler = loadHandler; + } + + @Override + public void setVisibilityHandler(@NotNull UIVisibilityHandler visibilityHandler) { + Objects.requireNonNull(visibilityHandler); + this.visibilityHandler = visibilityHandler; + } + + @Override + public void setFocusHandler(@NotNull UIFocusHandler focusHandler) { + Objects.requireNonNull(focusHandler); + this.focusHandler = focusHandler; + } + + @Override + public void onCreated(@NotNull FeatherPlayer player) { + if (this.lifecycleHandler != null) { + this.lifecycleHandler.onCreated(player); + } + } + + @Override + public void onDestroyed(@NotNull FeatherPlayer player) { + if (this.lifecycleHandler != null) { + this.lifecycleHandler.onDestroyed(player); + } + } + + @Override + public void onLoadError(@NotNull FeatherPlayer player, @NotNull String errorText) { + if (this.loadHandler != null) { + this.loadHandler.onLoadError(player, errorText); + } + } + + @Override + public void onFocusGained(@NotNull FeatherPlayer player) { + if (this.focusHandler != null) { + this.focusHandler.onFocusGained(player); + } + } + + @Override + public void onFocusLost(@NotNull FeatherPlayer player) { + if (this.focusHandler != null) { + this.focusHandler.onFocusLost(player); + } + } + + @Override + public void onShow(@NotNull FeatherPlayer player) { + if (this.visibilityHandler != null) { + this.visibilityHandler.onShow(player); + } + } + + @Override + public void onHide(@NotNull FeatherPlayer player) { + if (this.visibilityHandler != null) { + this.visibilityHandler.onHide(player); + } + } +} diff --git a/velocity/src/main/java/net/digitalingot/feather/serverapi/velocity/ui/VelocityUIService.java b/velocity/src/main/java/net/digitalingot/feather/serverapi/velocity/ui/VelocityUIService.java new file mode 100644 index 0000000..1228767 --- /dev/null +++ b/velocity/src/main/java/net/digitalingot/feather/serverapi/velocity/ui/VelocityUIService.java @@ -0,0 +1,145 @@ +package net.digitalingot.feather.serverapi.velocity.ui; + +import com.google.common.collect.Maps; +import com.velocitypowered.api.plugin.PluginContainer; +import com.velocitypowered.api.proxy.ProxyServer; +import java.util.Map; +import net.digitalingot.feather.serverapi.api.player.FeatherPlayer; +import net.digitalingot.feather.serverapi.api.ui.UIPage; +import net.digitalingot.feather.serverapi.api.ui.UIService; +import net.digitalingot.feather.serverapi.api.ui.rpc.RpcController; +import net.digitalingot.feather.serverapi.messaging.messages.client.S2CCreateFUI; +import net.digitalingot.feather.serverapi.messaging.messages.client.S2CDestroyFUI; +import net.digitalingot.feather.serverapi.messaging.messages.client.S2CFUIMessage; +import net.digitalingot.feather.serverapi.messaging.messages.client.S2CSetFUIState; +import net.digitalingot.feather.serverapi.messaging.messages.client.S2CSetFUIState.Action; +import net.digitalingot.feather.serverapi.velocity.messaging.VelocityMessagingService; +import net.digitalingot.feather.serverapi.velocity.player.VelocityFeatherPlayer; +import net.digitalingot.feather.serverapi.velocity.ui.rpc.RpcService; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +public class VelocityUIService implements UIService { + + @NotNull + private final VelocityMessagingService messagingService; + @NotNull + private final RpcService rpcService; + @NotNull + private final ProxyServer server; + + private final Map registeredPages = Maps.newHashMap(); + + public VelocityUIService( + @NotNull VelocityMessagingService messagingService, + @NotNull RpcService rpcService, + @NotNull ProxyServer server) { + this.messagingService = messagingService; + this.rpcService = rpcService; + this.server = server; + } + + private Object validatePlugin(Object plugin) { + return plugin; + } + + @Override + public UIPage registerPage(@NotNull Object pluginObject, @NotNull String url) { + Object plugin = validatePlugin(pluginObject); + String pluginId = getPluginId(plugin); + + if (this.registeredPages.containsKey(pluginId)) { + throw new IllegalArgumentException("Page already exists"); + } + + VelocityUIPage page = new VelocityUIPage(plugin, url); + this.registeredPages.put(pluginId, page); + return page; + } + + private String getPluginId(Object plugin) { + if (plugin instanceof PluginContainer) { + return ((PluginContainer) plugin).getDescription().getId(); + } else { + return plugin.getClass().getSimpleName(); + } + } + + @Override + public void unregisterPage(@NotNull UIPage page) { + Object owner = ((VelocityUIPage) page).getOwner(); + this.registeredPages.remove(getPluginId(owner)); + this.rpcService.unregisterByPlugin(owner); + } + + @Override + public void createPageForPlayer(@NotNull FeatherPlayer player, @NotNull UIPage page) { + VelocityUIPage velocityPage = (VelocityUIPage) page; + this.messagingService.sendMessage( + (VelocityFeatherPlayer) player, + new S2CCreateFUI(velocityPage.getRpcHostname(), velocityPage.getPage())); + } + + @Override + public void destroyPageForPlayer(@NotNull FeatherPlayer player, @NotNull UIPage page) { + this.messagingService.sendMessage( + (VelocityFeatherPlayer) player, + new S2CDestroyFUI(((VelocityUIPage) page).getRpcHostname())); + } + + private void setState( + @NotNull FeatherPlayer player, @NotNull UIPage page, @NotNull Action action, boolean state) { + this.messagingService.sendMessage( + (VelocityFeatherPlayer) player, + new S2CSetFUIState(((VelocityUIPage) page).getRpcHostname(), action, state)); + } + + @Override + public void showOverlayForPlayer(@NotNull FeatherPlayer player, @NotNull UIPage page) { + setState(player, page, Action.VISIBILITY, true); + } + + @Override + public void hideOverlayFromPlayer(@NotNull FeatherPlayer player, @NotNull UIPage page) { + setState(player, page, Action.VISIBILITY, false); + } + + @Override + public void openPageForPlayer(@NotNull FeatherPlayer player, @NotNull UIPage page) { + setState(player, page, Action.FOCUS, true); + } + + @Override + public void closePageForPlayer(@NotNull FeatherPlayer player, @NotNull UIPage page) { + setState(player, page, Action.FOCUS, false); + } + + @Override + public void sendPageMessage( + @NotNull FeatherPlayer player, @NotNull UIPage page, @NotNull String jsonString) { + this.messagingService.sendMessage( + (VelocityFeatherPlayer) player, + new S2CFUIMessage(((VelocityUIPage) page).getRpcHostname(), jsonString)); + } + + @Override + public void registerCallbacks(@NotNull UIPage page, @NotNull RpcController controller) { + this.rpcService.register(((VelocityUIPage) page).getOwner(), controller); + } + + @Override + public void unregisterCallbacksForController( + @NotNull UIPage page, @NotNull RpcController controller) { + this.rpcService.unregisterByController(((VelocityUIPage) page).getOwner(), controller); + } + + @Override + public void unregisterCallbacksForPage(@NotNull UIPage page) { + this.rpcService.unregisterByPlugin(((VelocityUIPage) page).getOwner()); + } + + @Nullable + public VelocityUIPage getPageByFrame(String frame) { + return this.registeredPages.get(frame); + } +} diff --git a/velocity/src/main/java/net/digitalingot/feather/serverapi/velocity/ui/rpc/RegisteredRpcHandler.java b/velocity/src/main/java/net/digitalingot/feather/serverapi/velocity/ui/rpc/RegisteredRpcHandler.java new file mode 100644 index 0000000..1ee841d --- /dev/null +++ b/velocity/src/main/java/net/digitalingot/feather/serverapi/velocity/ui/rpc/RegisteredRpcHandler.java @@ -0,0 +1,55 @@ +package net.digitalingot.feather.serverapi.velocity.ui.rpc; + +import java.lang.invoke.CallSite; +import java.lang.invoke.LambdaMetafactory; +import java.lang.invoke.MethodHandle; +import java.lang.invoke.MethodHandles; +import java.lang.invoke.MethodType; +import java.lang.reflect.Method; +import net.digitalingot.feather.serverapi.api.ui.rpc.RpcController; +import net.digitalingot.feather.serverapi.api.ui.rpc.RpcRequest; +import net.digitalingot.feather.serverapi.api.ui.rpc.RpcResponse; +import org.jetbrains.annotations.NotNull; + +class RegisteredRpcHandler { + + @NotNull + public final RpcHandlerExecutor executor; + @NotNull + private final RpcController controller; + + public RegisteredRpcHandler(@NotNull RpcController controller, @NotNull Method method) + throws Throwable { + this.controller = controller; + this.executor = generateLambdaExecutor(controller, method); + } + + private static RpcHandlerExecutor generateLambdaExecutor(RpcController controller, Method method) + throws Throwable { + method.setAccessible(true); + + MethodHandles.Lookup lookup = MethodHandles.lookup(); + MethodHandle methodHandle = lookup.unreflect(method); + + MethodType instanceSignature = + MethodType.methodType(RpcHandlerExecutor.class, controller.getClass()); + MethodType handlerSignature = + MethodType.methodType(void.class, RpcRequest.class, RpcResponse.class); + + CallSite callSite = + LambdaMetafactory.metafactory( + lookup, "invoke", instanceSignature, handlerSignature, methodHandle, handlerSignature); + + MethodHandle target = callSite.getTarget(); + return (RpcHandlerExecutor) target.invoke(controller); + } + + @NotNull + public RpcController getController() { + return this.controller; + } + + public void invoke(RpcRequest request, RpcResponse response) { + this.executor.invoke(request, response); + } +} diff --git a/velocity/src/main/java/net/digitalingot/feather/serverapi/velocity/ui/rpc/RpcHandlerExecutor.java b/velocity/src/main/java/net/digitalingot/feather/serverapi/velocity/ui/rpc/RpcHandlerExecutor.java new file mode 100644 index 0000000..8edfbd7 --- /dev/null +++ b/velocity/src/main/java/net/digitalingot/feather/serverapi/velocity/ui/rpc/RpcHandlerExecutor.java @@ -0,0 +1,10 @@ +package net.digitalingot.feather.serverapi.velocity.ui.rpc; + +import net.digitalingot.feather.serverapi.api.ui.rpc.RpcRequest; +import net.digitalingot.feather.serverapi.api.ui.rpc.RpcResponse; + +@FunctionalInterface +public interface RpcHandlerExecutor { + + void invoke(RpcRequest request, RpcResponse response); +} diff --git a/velocity/src/main/java/net/digitalingot/feather/serverapi/velocity/ui/rpc/RpcService.java b/velocity/src/main/java/net/digitalingot/feather/serverapi/velocity/ui/rpc/RpcService.java new file mode 100644 index 0000000..85a9bca --- /dev/null +++ b/velocity/src/main/java/net/digitalingot/feather/serverapi/velocity/ui/rpc/RpcService.java @@ -0,0 +1,218 @@ +package net.digitalingot.feather.serverapi.velocity.ui.rpc; + +import com.velocitypowered.api.event.Subscribe; +import com.velocitypowered.api.event.proxy.ProxyShutdownEvent; +import com.velocitypowered.api.plugin.PluginContainer; +import com.velocitypowered.api.proxy.ProxyServer; +import java.lang.reflect.Method; +import java.lang.reflect.Modifier; +import java.util.HashMap; +import java.util.Locale; +import java.util.Map; +import java.util.Map.Entry; +import java.util.Objects; +import net.digitalingot.feather.serverapi.api.ui.rpc.RpcController; +import net.digitalingot.feather.serverapi.api.ui.rpc.RpcHandler; +import net.digitalingot.feather.serverapi.api.ui.rpc.RpcRequest; +import net.digitalingot.feather.serverapi.api.ui.rpc.RpcResponse; +import net.digitalingot.feather.serverapi.messaging.messages.client.S2CFUIResponse; +import net.digitalingot.feather.serverapi.velocity.FeatherVelocityPlugin; +import net.digitalingot.feather.serverapi.velocity.player.VelocityFeatherPlayer; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; +import org.slf4j.Logger; + +public class RpcService { + + private final FeatherVelocityPlugin plugin; + private final Logger logger; + private final Map rpcHosts; + private final ProxyServer server; + + public RpcService(FeatherVelocityPlugin plugin, ProxyServer server) { + this.plugin = plugin; + this.server = server; + this.logger = plugin.getLogger(); + this.rpcHosts = new HashMap<>(); + this.server.getEventManager().register(plugin, this); + } + + public void register(@NotNull Object plugin, @NotNull RpcController controller) { + Objects.requireNonNull(plugin); + String rpcHostName = getRpcHostName(plugin); + register(rpcHostName, controller); + } + + private void register(@NotNull String rpcHostName, @NotNull RpcController controller) { + Objects.requireNonNull(controller); + + RpcHost rpcHost = this.rpcHosts.get(rpcHostName); + + if (isControllerRegistered(rpcHost, controller)) { + throw new IllegalStateException( + "Controller '" + controller.getClass().getName() + "' is already registered"); + } + + Class controllerClazz = controller.getClass(); + Method[] methods = controllerClazz.getDeclaredMethods(); + + Map nameToMethod = new HashMap<>(methods.length); + + for (Method method : methods) { + RpcHandler rpcHandler = method.getAnnotation(RpcHandler.class); + + if (rpcHandler == null) { + continue; + } + + if (method.isBridge() || method.isSynthetic()) { + continue; + } + + if ((method.getModifiers() & Modifier.PUBLIC) == 0) { + this.logger.warn("Handler '" + method.getName() + "' is not public."); + continue; + } + + if ((method.getModifiers() & Modifier.STATIC) != 0) { + this.logger.warn("Handler '" + method.getName() + "' is static."); + continue; + } + + if (method.getReturnType() != void.class) { + this.logger.warn( + "Handler '" + method.getName() + " has invalid return type. Expecting void."); + continue; + } + + Class[] paramTypes = method.getParameterTypes(); + if (paramTypes.length != 2 + || !RpcRequest.class.isAssignableFrom(paramTypes[0]) + || !RpcResponse.class.isAssignableFrom(paramTypes[1])) { + this.logger.warn( + "Handler '" + + method.getName() + + "' has invalid parameter type(s). Expecting (RpcRequest, RpcResponse)."); + continue; + } + + String rpcName = rpcHandler.value(); + + if (isNameRegistered(rpcHost, rpcName) || nameToMethod.put(rpcName, method) != null) { + throw new IllegalArgumentException("Handler for key '" + rpcName + "' already exists"); + } + } + + if (nameToMethod.isEmpty()) { + return; + } + + Map handlers = new HashMap<>(nameToMethod.size()); + + for (Entry entry : nameToMethod.entrySet()) { + String rpcName = entry.getKey(); + Method rpcMethod = entry.getValue(); + + try { + RegisteredRpcHandler handler = new RegisteredRpcHandler(controller, rpcMethod); + handlers.put(rpcName, handler); + } catch (Throwable throwable) { + throw new IllegalStateException(throwable); + } + } + + if (rpcHost == null) { + rpcHost = new RpcHost(); + this.rpcHosts.put(rpcHostName, rpcHost); + } + + rpcHost.registerAll(handlers); + } + + public void unregisterByController(Object owner, RpcController controller) { + RpcHost rpcHost = this.rpcHosts.get(getRpcHostName(owner)); + if (rpcHost != null) { + rpcHost.unregisterByController(controller); + } + } + + public void unregisterByPlugin(Object plugin) { + this.rpcHosts.remove(getRpcHostName(plugin)); + } + + public void handle( + VelocityFeatherPlayer player, String rpcHostName, String rpcName, int requestId, + String body) { + RegisteredRpcHandler handler = getRpcHandler(rpcHostName, rpcName); + + if (handler != null) { + try { + handler.invoke( + new VelocityRpcRequest(player, body), + new VelocityRpcResponse(requestId, player, this.plugin)); + } catch (Throwable throwable) { + this.logger.warn("Error occurred handling RPC request", throwable); + } + } else { + player.sendMessage(new S2CFUIResponse(requestId, false, "")); + } + } + + private RegisteredRpcHandler getRpcHandler(@NotNull String rpcHostName, @NotNull String rpcName) { + rpcHostName = rpcHostName.toLowerCase(Locale.ROOT); + RpcHost rpcHost = this.rpcHosts.get(rpcHostName); + return rpcHost != null ? rpcHost.getHandlerByName(rpcName) : null; + } + + private static boolean isControllerRegistered( + @Nullable RpcHost rpcHost, @NotNull RpcController controller) { + return rpcHost != null && rpcHost.isControllerRegistered(controller); + } + + private static boolean isNameRegistered(@Nullable RpcHost rpcHost, @NotNull String name) { + return rpcHost != null && rpcHost.isNameRegistered(name); + } + + private static String getRpcHostName(Object plugin) { + if (plugin instanceof PluginContainer) { + return ((PluginContainer) plugin).getDescription().getId().toLowerCase(Locale.ROOT); + } else { + return plugin.getClass().getSimpleName().toLowerCase(Locale.ROOT); + } + } + + @Subscribe + public void onProxyShutdown(ProxyShutdownEvent event) { + this.rpcHosts.clear(); + } + + private static class RpcHost { + + private final Map handlers; + + public RpcHost() { + this.handlers = new HashMap<>(); + } + + public void registerAll(@NotNull Map handlers) { + this.handlers.putAll(handlers); + } + + public void unregisterByController(@NotNull RpcController controller) { + this.handlers.values().removeIf(handler -> handler.getController() == controller); + } + + public RegisteredRpcHandler getHandlerByName(@NotNull String name) { + return this.handlers.get(name); + } + + public boolean isControllerRegistered(@NotNull RpcController controller) { + return this.handlers.values().stream() + .anyMatch(handler -> handler.getController() == controller); + } + + public boolean isNameRegistered(@NotNull String name) { + return this.handlers.containsKey(name); + } + } +} diff --git a/velocity/src/main/java/net/digitalingot/feather/serverapi/velocity/ui/rpc/VelocityRpcRequest.java b/velocity/src/main/java/net/digitalingot/feather/serverapi/velocity/ui/rpc/VelocityRpcRequest.java new file mode 100644 index 0000000..c44644d --- /dev/null +++ b/velocity/src/main/java/net/digitalingot/feather/serverapi/velocity/ui/rpc/VelocityRpcRequest.java @@ -0,0 +1,29 @@ +package net.digitalingot.feather.serverapi.velocity.ui.rpc; + +import net.digitalingot.feather.serverapi.api.player.FeatherPlayer; +import net.digitalingot.feather.serverapi.api.ui.rpc.RpcRequest; +import net.digitalingot.feather.serverapi.velocity.player.VelocityFeatherPlayer; +import org.jetbrains.annotations.NotNull; + +public class VelocityRpcRequest implements RpcRequest { + + @NotNull + private final VelocityFeatherPlayer player; + @NotNull + private final String body; + + public VelocityRpcRequest(@NotNull VelocityFeatherPlayer player, @NotNull String body) { + this.player = player; + this.body = body; + } + + @Override + public @NotNull FeatherPlayer getSource() { + return this.player; + } + + @Override + public @NotNull String getBody() { + return this.body; + } +} diff --git a/velocity/src/main/java/net/digitalingot/feather/serverapi/velocity/ui/rpc/VelocityRpcResponse.java b/velocity/src/main/java/net/digitalingot/feather/serverapi/velocity/ui/rpc/VelocityRpcResponse.java new file mode 100644 index 0000000..0d33cd0 --- /dev/null +++ b/velocity/src/main/java/net/digitalingot/feather/serverapi/velocity/ui/rpc/VelocityRpcResponse.java @@ -0,0 +1,34 @@ +package net.digitalingot.feather.serverapi.velocity.ui.rpc; + +import net.digitalingot.feather.serverapi.api.ui.rpc.RpcResponse; +import net.digitalingot.feather.serverapi.messaging.messages.client.S2CFUIResponse; +import net.digitalingot.feather.serverapi.velocity.FeatherVelocityPlugin; +import net.digitalingot.feather.serverapi.velocity.player.VelocityFeatherPlayer; +import org.jetbrains.annotations.NotNull; + +public class VelocityRpcResponse implements RpcResponse { + + private final FeatherVelocityPlugin plugin; + public final int id; + @NotNull + public final VelocityFeatherPlayer player; + + public VelocityRpcResponse( + int id, @NotNull VelocityFeatherPlayer player, @NotNull FeatherVelocityPlugin plugin) { + this.id = id; + this.player = player; + this.plugin = plugin; + } + + @Override + public void respond(final @NotNull String jsonResponse) { + sendOnMainThread( + () -> this.player.sendMessage(new S2CFUIResponse(this.id, true, jsonResponse))); + } + + private void sendOnMainThread(Runnable task) { + plugin.getServer().getScheduler() + .buildTask(plugin, task) + .schedule(); + } +} diff --git a/velocity/src/main/java/net/digitalingot/feather/serverapi/velocity/update/UpdateNotifier.java b/velocity/src/main/java/net/digitalingot/feather/serverapi/velocity/update/UpdateNotifier.java new file mode 100644 index 0000000..61cb185 --- /dev/null +++ b/velocity/src/main/java/net/digitalingot/feather/serverapi/velocity/update/UpdateNotifier.java @@ -0,0 +1,62 @@ +package net.digitalingot.feather.serverapi.velocity.update; + +import com.velocitypowered.api.event.Subscribe; +import com.velocitypowered.api.event.connection.PostLoginEvent; +import com.velocitypowered.api.proxy.Player; +import com.velocitypowered.api.proxy.ProxyServer; +import java.util.UUID; +import java.util.concurrent.TimeUnit; +import net.digitalingot.feather.serverapi.messaging.MessageConstants; +import net.digitalingot.feather.serverapi.velocity.FeatherVelocityPlugin; +import net.kyori.adventure.text.Component; +import net.kyori.adventure.text.format.NamedTextColor; + +public class UpdateNotifier { + + private static final String NOTIFY_PERMISSION = "feather-server-api.notify"; + private static final long NOTIFICATION_DELAY_SECONDS = 3; + private static final Component NOTIFY_MESSAGE_PREFIX = Component.text( + "[Feather Server API] Is potentially out of date. Found protocol version: ", + NamedTextColor.YELLOW); + private static final Component NOTIFY_MESSAGE_MIDDLE = Component.text( + ". Plugin protocol version: ", NamedTextColor.YELLOW); + + private final FeatherVelocityPlugin plugin; + private final ProxyServer server; + private boolean potentiallyOutOfDate = false; + private Component outOfDateMessage; + + public UpdateNotifier(FeatherVelocityPlugin plugin, ProxyServer server) { + this.plugin = plugin; + this.server = server; + this.server.getEventManager().register(plugin, this); + } + + public void setPotentiallyOutOfDate(int protocolVersion) { + if (!this.potentiallyOutOfDate) { + this.potentiallyOutOfDate = true; + this.outOfDateMessage = Component.empty() + .append(NOTIFY_MESSAGE_PREFIX) + .append(Component.text(protocolVersion, NamedTextColor.GOLD)) + .append(NOTIFY_MESSAGE_MIDDLE) + .append(Component.text(MessageConstants.VERSION, NamedTextColor.GRAY)); + } + } + + @Subscribe + public void onPlayerJoin(PostLoginEvent event) { + if (this.potentiallyOutOfDate) { + Player joiningPlayer = event.getPlayer(); + if (joiningPlayer.hasPermission(NOTIFY_PERMISSION)) { + final UUID joiningPlayerId = joiningPlayer.getUniqueId(); + server.getScheduler() + .buildTask(plugin, () -> { + server.getPlayer(joiningPlayerId) + .ifPresent(player -> player.sendMessage(this.outOfDateMessage)); + }) + .delay(NOTIFICATION_DELAY_SECONDS, TimeUnit.SECONDS) + .schedule(); + } + } + } +}