From 7a740b35dd112e9e04c187686a8d5d034e6bc9e6 Mon Sep 17 00:00:00 2001 From: MiniDigger | Martin Date: Thu, 12 Oct 2023 17:05:54 +0200 Subject: [PATCH 1/3] feat: add initial server list ping implementation --- build.gradle.kts | 2 + gradle/libs.versions.toml | 2 + .../kyori/adventure/webui/jvm/Application.kt | 34 ++-- .../webui/jvm/minimessage/SocketTest.kt | 164 ++++++++++++++++++ 4 files changed, 187 insertions(+), 15 deletions(-) create mode 100644 src/jvmMain/kotlin/net/kyori/adventure/webui/jvm/minimessage/SocketTest.kt diff --git a/build.gradle.kts b/build.gradle.kts index b111cf5..d6a392e 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -91,9 +91,11 @@ kotlin { dependencies { implementation(libs.bundles.ktor.server) implementation(libs.bundles.ktor.client) + implementation(libs.ktor.network) implementation(libs.adventure.minimessage) implementation(libs.adventure.text.serializer.gson) + implementation(libs.adventure.text.serializer.legacy) implementation(libs.cache4k) implementation(libs.logback.classic) } diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 419fd4a..226efd5 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -14,6 +14,7 @@ spotless = { id = "com.diffplug.spotless", version = "6.25.0" } [libraries] adventure-minimessage = { group = "net.kyori", name = "adventure-text-minimessage", version.ref = "adventure" } adventure-text-serializer-gson = { group = "net.kyori", name = "adventure-text-serializer-gson", version.ref = "adventure" } +adventure-text-serializer-legacy = { group = "net.kyori", name = "adventure-text-serializer-legacy", version.ref = "adventure" } cache4k = { group = "io.github.reactivecircus.cache4k", name = "cache4k", version = "0.13.0" } kotlinx-html = { group = "org.jetbrains.kotlinx", name = "kotlinx-html", version = "0.8.0" } kotlinx-serialization-json = { group = "org.jetbrains.kotlinx", name = "kotlinx-serialization-json", version = "1.7.3" } @@ -24,6 +25,7 @@ ktor-client-okhttp = { group = "io.ktor", name = "ktor-client-okhttp", version.r ktor-server-websockets = { group = "io.ktor", name = "ktor-server-websockets", version.ref = "ktor" } ktor-server-caching-headers = { group = "io.ktor", name = "ktor-server-caching-headers", version.ref = "ktor" } ktor-server-compression = { group = "io.ktor", name = "ktor-server-compression", version.ref = "ktor" } +ktor-network = { group = "io.ktor", name = "ktor-network", version.ref = "ktor" } logback-classic = { group = "ch.qos.logback", name = "logback-classic", version = "1.5.8" } zKtlint = { group = "com.pinterest", name = "ktlint", version.ref = "ktlint"} diff --git a/src/jvmMain/kotlin/net/kyori/adventure/webui/jvm/Application.kt b/src/jvmMain/kotlin/net/kyori/adventure/webui/jvm/Application.kt index 17bbbe1..4ebea5b 100644 --- a/src/jvmMain/kotlin/net/kyori/adventure/webui/jvm/Application.kt +++ b/src/jvmMain/kotlin/net/kyori/adventure/webui/jvm/Application.kt @@ -1,20 +1,21 @@ package net.kyori.adventure.webui.jvm -import io.ktor.http.CacheControl -import io.ktor.http.ContentType -import io.ktor.http.content.CachingOptions -import io.ktor.server.application.Application -import io.ktor.server.application.install -import io.ktor.server.application.log -import io.ktor.server.plugins.cachingheaders.CachingHeaders -import io.ktor.server.plugins.compression.Compression -import io.ktor.server.plugins.compression.deflate -import io.ktor.server.plugins.compression.gzip -import io.ktor.server.routing.routing -import io.ktor.server.websocket.WebSockets -import io.ktor.server.websocket.pingPeriod -import io.ktor.server.websocket.timeout -import io.ktor.websocket.WebSocketDeflateExtension +import io.ktor.http.* +import io.ktor.http.content.* +import io.ktor.network.selector.* +import io.ktor.network.sockets.* +import io.ktor.server.application.* +import io.ktor.server.plugins.cachingheaders.* +import io.ktor.server.plugins.compression.* +import io.ktor.server.routing.* +import io.ktor.server.websocket.* +import io.ktor.utils.io.* +import io.ktor.websocket.* +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.runBlocking +import net.kyori.adventure.webui.jvm.minimessage.SocketTest +import okhttp3.internal.and import java.time.Duration public fun Application.main() { @@ -48,8 +49,11 @@ public fun Application.main() { trace { route -> this@main.log.debug(route.buildText()) } } } + + SocketTest().main() } + /** Reads a string value from the `config` block in `application.conf`. */ public fun Application.getConfigString(key: String): String = environment.config.property("ktor.config.$key").getString() diff --git a/src/jvmMain/kotlin/net/kyori/adventure/webui/jvm/minimessage/SocketTest.kt b/src/jvmMain/kotlin/net/kyori/adventure/webui/jvm/minimessage/SocketTest.kt new file mode 100644 index 0000000..a54e898 --- /dev/null +++ b/src/jvmMain/kotlin/net/kyori/adventure/webui/jvm/minimessage/SocketTest.kt @@ -0,0 +1,164 @@ +package net.kyori.adventure.webui.jvm.minimessage + +import io.ktor.network.selector.* +import io.ktor.network.sockets.* +import io.ktor.utils.io.* +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.withContext +import net.kyori.adventure.text.minimessage.MiniMessage +import net.kyori.adventure.text.serializer.gson.GsonComponentSerializer +import net.kyori.adventure.text.serializer.legacy.LegacyComponentSerializer +import okhttp3.internal.and +import java.io.ByteArrayOutputStream +import java.io.DataOutputStream +import kotlin.text.Charsets.UTF_8 + +public class SocketTest { + + public fun main() { + // TODO + // 1. make this non blocking somehow, idk how kotlin works + // 2. add api/ui to store into some cache + // 3. parse server address to get stuff from the cache and return that in the status response + runBlocking { + val serverSocket = aSocket(SelectorManager(Dispatchers.IO)).tcp().bind("127.0.0.1", 9002) + println("Server is listening at ${serverSocket.localAddress}") + while (true) { + val socket = serverSocket.accept() + println("Accepted ${socket.remoteAddress}") + launch { + try { + val receiveChannel = socket.openReadChannel() + val sendChannel = socket.openWriteChannel(autoFlush = true) + + // handshake + val handshakePacket = receiveChannel.readMcPacket() + val protocolVersion = handshakePacket.readVarInt() + val serverAddress = handshakePacket.readUtf8String() + val serverPort = handshakePacket.readShort() + val nextState = handshakePacket.readVarInt() + + if (nextState != 1) { + // send kick + sendChannel.writeMcPacket(0) { + it.writeString( + GsonComponentSerializer.gson() + .serialize(MiniMessage.miniMessage().deserialize("You cant join here!")) + ) + } + } else { + // send status response + sendChannel.writeMcPacket(0) { + it.writeString( + """{ + "version": { + "name": "${ + LegacyComponentSerializer.legacySection() + .serialize(MiniMessage.miniMessage().deserialize("MiniMessage")) + }", + "protocol": 762 + }, + "description": ${ + GsonComponentSerializer.gson().serialize( + MiniMessage.miniMessage().deserialize("MiniMessage is cool!") + ) + } + }""".trimIndent() + ) + } + } + + sendChannel.close() + } catch (e: Exception) { + println(e) + } + + socket.close() + return@launch + } + } + } + } +} + +public suspend fun ByteWriteChannel.writeMcPacket(packetId: Int, consumer: (packet: DataOutputStream) -> Unit) { + val stream = ByteArrayOutputStream() + val packet = DataOutputStream(stream) + + consumer.invoke(packet) + + val data = stream.toByteArray() + writeVarInt(data.size + 1) + writeVarInt(packetId) + writeFully(data) +} + +public fun DataOutputStream.writeString(string: String) { + val bytes = string.toByteArray(UTF_8) + writeVarInt(bytes.size) + write(bytes) +} + +public fun DataOutputStream.writeVarInt(int: Int) { + var value = int + while (true) { + if ((value and 0x7F.inv()) == 0) { + writeByte(value) + return + } + + writeByte((value and 0x7F) or 0x80) + + value = value ushr 7 + } +} + +public suspend fun ByteWriteChannel.writeVarInt(int: Int) { + var value = int + while (true) { + if ((value and 0x7F.inv()) == 0) { + writeByte(value) + return + } + + writeByte((value and 0x7F) or 0x80) + + value = value ushr 7 + } +} + +public suspend fun ByteReadChannel.readMcPacket(): ByteReadChannel { + val length = readVarInt() + val packetId = readVarInt() + val data = ByteArray(length) + readFully(data, 0, length) + return ByteReadChannel(data) +} + +public suspend fun ByteReadChannel.readVarInt(): Int { + var value = 0 + var position = 0 + var currentByte: Byte + + while (true) { + currentByte = readByte() + value = value or ((currentByte and 0x7F) shl position) + + if ((currentByte and 0x80) == 0) break + + position += 7 + + if (position >= 32) throw RuntimeException("VarInt is too big") + } + + return value +} + +public suspend fun ByteReadChannel.readUtf8String(): String { + val length = readVarInt() + val data = ByteArray(length) + readFully(data, 0, length) + return String(data) +} From 0bac3e09afd748e485e1b702dd94580798221789 Mon Sep 17 00:00:00 2001 From: Kieran Wallbanks Date: Fri, 13 Oct 2023 17:20:28 +0100 Subject: [PATCH 2/3] better logging and coroutine handling --- .../kyori/adventure/webui/jvm/Application.kt | 35 ++-- .../webui/jvm/minimessage/SocketTest.kt | 164 ---------------- .../preview/ServerStatusPreviewManager.kt | 182 ++++++++++++++++++ 3 files changed, 200 insertions(+), 181 deletions(-) delete mode 100644 src/jvmMain/kotlin/net/kyori/adventure/webui/jvm/minimessage/SocketTest.kt create mode 100644 src/jvmMain/kotlin/net/kyori/adventure/webui/jvm/minimessage/preview/ServerStatusPreviewManager.kt diff --git a/src/jvmMain/kotlin/net/kyori/adventure/webui/jvm/Application.kt b/src/jvmMain/kotlin/net/kyori/adventure/webui/jvm/Application.kt index 4ebea5b..0b0bd43 100644 --- a/src/jvmMain/kotlin/net/kyori/adventure/webui/jvm/Application.kt +++ b/src/jvmMain/kotlin/net/kyori/adventure/webui/jvm/Application.kt @@ -1,21 +1,21 @@ package net.kyori.adventure.webui.jvm -import io.ktor.http.* -import io.ktor.http.content.* -import io.ktor.network.selector.* -import io.ktor.network.sockets.* -import io.ktor.server.application.* -import io.ktor.server.plugins.cachingheaders.* -import io.ktor.server.plugins.compression.* -import io.ktor.server.routing.* -import io.ktor.server.websocket.* -import io.ktor.utils.io.* -import io.ktor.websocket.* -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.launch -import kotlinx.coroutines.runBlocking -import net.kyori.adventure.webui.jvm.minimessage.SocketTest -import okhttp3.internal.and +import io.ktor.http.CacheControl +import io.ktor.http.ContentType +import io.ktor.http.content.CachingOptions +import io.ktor.server.application.Application +import io.ktor.server.application.install +import io.ktor.server.application.log +import io.ktor.server.plugins.cachingheaders.CachingHeaders +import io.ktor.server.plugins.compression.Compression +import io.ktor.server.plugins.compression.deflate +import io.ktor.server.plugins.compression.gzip +import io.ktor.server.routing.routing +import io.ktor.server.websocket.WebSockets +import io.ktor.server.websocket.pingPeriod +import io.ktor.server.websocket.timeout +import io.ktor.websocket.WebSocketDeflateExtension +import net.kyori.adventure.webui.jvm.minimessage.preview.ServerStatusPreviewManager import java.time.Duration public fun Application.main() { @@ -50,7 +50,8 @@ public fun Application.main() { } } - SocketTest().main() + // Initialise the server status preview manager. + ServerStatusPreviewManager(this) } diff --git a/src/jvmMain/kotlin/net/kyori/adventure/webui/jvm/minimessage/SocketTest.kt b/src/jvmMain/kotlin/net/kyori/adventure/webui/jvm/minimessage/SocketTest.kt deleted file mode 100644 index a54e898..0000000 --- a/src/jvmMain/kotlin/net/kyori/adventure/webui/jvm/minimessage/SocketTest.kt +++ /dev/null @@ -1,164 +0,0 @@ -package net.kyori.adventure.webui.jvm.minimessage - -import io.ktor.network.selector.* -import io.ktor.network.sockets.* -import io.ktor.utils.io.* -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.launch -import kotlinx.coroutines.runBlocking -import kotlinx.coroutines.withContext -import net.kyori.adventure.text.minimessage.MiniMessage -import net.kyori.adventure.text.serializer.gson.GsonComponentSerializer -import net.kyori.adventure.text.serializer.legacy.LegacyComponentSerializer -import okhttp3.internal.and -import java.io.ByteArrayOutputStream -import java.io.DataOutputStream -import kotlin.text.Charsets.UTF_8 - -public class SocketTest { - - public fun main() { - // TODO - // 1. make this non blocking somehow, idk how kotlin works - // 2. add api/ui to store into some cache - // 3. parse server address to get stuff from the cache and return that in the status response - runBlocking { - val serverSocket = aSocket(SelectorManager(Dispatchers.IO)).tcp().bind("127.0.0.1", 9002) - println("Server is listening at ${serverSocket.localAddress}") - while (true) { - val socket = serverSocket.accept() - println("Accepted ${socket.remoteAddress}") - launch { - try { - val receiveChannel = socket.openReadChannel() - val sendChannel = socket.openWriteChannel(autoFlush = true) - - // handshake - val handshakePacket = receiveChannel.readMcPacket() - val protocolVersion = handshakePacket.readVarInt() - val serverAddress = handshakePacket.readUtf8String() - val serverPort = handshakePacket.readShort() - val nextState = handshakePacket.readVarInt() - - if (nextState != 1) { - // send kick - sendChannel.writeMcPacket(0) { - it.writeString( - GsonComponentSerializer.gson() - .serialize(MiniMessage.miniMessage().deserialize("You cant join here!")) - ) - } - } else { - // send status response - sendChannel.writeMcPacket(0) { - it.writeString( - """{ - "version": { - "name": "${ - LegacyComponentSerializer.legacySection() - .serialize(MiniMessage.miniMessage().deserialize("MiniMessage")) - }", - "protocol": 762 - }, - "description": ${ - GsonComponentSerializer.gson().serialize( - MiniMessage.miniMessage().deserialize("MiniMessage is cool!") - ) - } - }""".trimIndent() - ) - } - } - - sendChannel.close() - } catch (e: Exception) { - println(e) - } - - socket.close() - return@launch - } - } - } - } -} - -public suspend fun ByteWriteChannel.writeMcPacket(packetId: Int, consumer: (packet: DataOutputStream) -> Unit) { - val stream = ByteArrayOutputStream() - val packet = DataOutputStream(stream) - - consumer.invoke(packet) - - val data = stream.toByteArray() - writeVarInt(data.size + 1) - writeVarInt(packetId) - writeFully(data) -} - -public fun DataOutputStream.writeString(string: String) { - val bytes = string.toByteArray(UTF_8) - writeVarInt(bytes.size) - write(bytes) -} - -public fun DataOutputStream.writeVarInt(int: Int) { - var value = int - while (true) { - if ((value and 0x7F.inv()) == 0) { - writeByte(value) - return - } - - writeByte((value and 0x7F) or 0x80) - - value = value ushr 7 - } -} - -public suspend fun ByteWriteChannel.writeVarInt(int: Int) { - var value = int - while (true) { - if ((value and 0x7F.inv()) == 0) { - writeByte(value) - return - } - - writeByte((value and 0x7F) or 0x80) - - value = value ushr 7 - } -} - -public suspend fun ByteReadChannel.readMcPacket(): ByteReadChannel { - val length = readVarInt() - val packetId = readVarInt() - val data = ByteArray(length) - readFully(data, 0, length) - return ByteReadChannel(data) -} - -public suspend fun ByteReadChannel.readVarInt(): Int { - var value = 0 - var position = 0 - var currentByte: Byte - - while (true) { - currentByte = readByte() - value = value or ((currentByte and 0x7F) shl position) - - if ((currentByte and 0x80) == 0) break - - position += 7 - - if (position >= 32) throw RuntimeException("VarInt is too big") - } - - return value -} - -public suspend fun ByteReadChannel.readUtf8String(): String { - val length = readVarInt() - val data = ByteArray(length) - readFully(data, 0, length) - return String(data) -} diff --git a/src/jvmMain/kotlin/net/kyori/adventure/webui/jvm/minimessage/preview/ServerStatusPreviewManager.kt b/src/jvmMain/kotlin/net/kyori/adventure/webui/jvm/minimessage/preview/ServerStatusPreviewManager.kt new file mode 100644 index 0000000..20fc4f4 --- /dev/null +++ b/src/jvmMain/kotlin/net/kyori/adventure/webui/jvm/minimessage/preview/ServerStatusPreviewManager.kt @@ -0,0 +1,182 @@ +package net.kyori.adventure.webui.jvm.minimessage.preview + +import io.ktor.network.selector.SelectorManager +import io.ktor.network.sockets.aSocket +import io.ktor.network.sockets.openReadChannel +import io.ktor.network.sockets.openWriteChannel +import io.ktor.server.application.Application +import io.ktor.utils.io.ByteReadChannel +import io.ktor.utils.io.ByteWriteChannel +import io.ktor.utils.io.close +import io.ktor.utils.io.writeByte +import io.ktor.utils.io.writeFully +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.ensureActive +import kotlinx.coroutines.job +import kotlinx.coroutines.launch +import net.kyori.adventure.text.minimessage.MiniMessage +import net.kyori.adventure.text.serializer.gson.GsonComponentSerializer +import net.kyori.adventure.text.serializer.legacy.LegacyComponentSerializer +import okhttp3.internal.and +import org.slf4j.LoggerFactory +import java.io.ByteArrayOutputStream +import java.io.DataOutputStream +import kotlin.coroutines.CoroutineContext + +/** Manager class for previewing server status. */ +public class ServerStatusPreviewManager( + application: Application, +) : CoroutineScope { + + private val logger = LoggerFactory.getLogger(ServerStatusPreviewManager::class.java) + private val managerJob = SupervisorJob(application.coroutineContext.job) + override val coroutineContext: CoroutineContext = application.coroutineContext + managerJob + + init { + launch { + // Initialise the socket. + val serverSocket = aSocket(SelectorManager(Dispatchers.IO)).tcp().bind("127.0.0.1", 9002) + logger.info("Listening for pings at ${serverSocket.localAddress}") + + while (true) { + // Ensure we are active so that the socket is properly closed when the application ends. + ensureActive() + + val socket = serverSocket.accept() + logger.debug("Accepted socket connection from {}", socket.remoteAddress) + + launch { + try { + val receiveChannel = socket.openReadChannel() + val sendChannel = socket.openWriteChannel(autoFlush = true) + + // handshake + val handshakePacket = receiveChannel.readMcPacket() + val protocolVersion = handshakePacket.readVarInt() + val serverAddress = handshakePacket.readUtf8String() + val serverPort = handshakePacket.readShort() + val nextState = handshakePacket.readVarInt() + + if (nextState != 1) { + // send kick + sendChannel.writeMcPacket(0) { + it.writeString( + GsonComponentSerializer.gson() + .serialize(MiniMessage.miniMessage().deserialize("You cant join here!")) + ) + } + } else { + // send status response + sendChannel.writeMcPacket(0) { + it.writeString( + """{ + "version": { + "name": "${ + LegacyComponentSerializer.legacySection() + .serialize(MiniMessage.miniMessage().deserialize("MiniMessage")) + }", + "protocol": $protocolVersion + }, + "description": ${ + GsonComponentSerializer.gson().serialize( + MiniMessage.miniMessage().deserialize("MiniMessage is cool!") + ) + } + }""".trimIndent() + ) + } + } + + sendChannel.close() + } catch (e: Exception) { + logger.error("An unknown error occurred whilst responding to a ping from ${socket.remoteAddress}", e) + } + + socket.close() + } + } + } + } + + private suspend fun ByteWriteChannel.writeMcPacket(packetId: Int, consumer: (packet: DataOutputStream) -> Unit) { + val stream = ByteArrayOutputStream() + val packet = DataOutputStream(stream) + + consumer.invoke(packet) + + val data = stream.toByteArray() + writeVarInt(data.size + 1) + writeVarInt(packetId) + writeFully(data) + } + + private fun DataOutputStream.writeString(string: String) { + val bytes = string.toByteArray(Charsets.UTF_8) + writeVarInt(bytes.size) + write(bytes) + } + + private fun DataOutputStream.writeVarInt(int: Int) { + var value = int + while (true) { + if ((value and 0x7F.inv()) == 0) { + writeByte(value) + return + } + + writeByte((value and 0x7F) or 0x80) + + value = value ushr 7 + } + } + + private suspend fun ByteWriteChannel.writeVarInt(int: Int) { + var value = int + while (true) { + if ((value and 0x7F.inv()) == 0) { + writeByte(value) + return + } + + writeByte((value and 0x7F) or 0x80) + + value = value ushr 7 + } + } + + private suspend fun ByteReadChannel.readMcPacket(): ByteReadChannel { + val length = readVarInt() + val packetId = readVarInt() + val data = ByteArray(length) + readFully(data, 0, length) + return ByteReadChannel(data) + } + + private suspend fun ByteReadChannel.readVarInt(): Int { + var value = 0 + var position = 0 + var currentByte: Byte + + while (true) { + currentByte = readByte() + value = value or ((currentByte and 0x7F) shl position) + + if ((currentByte and 0x80) == 0) break + + position += 7 + + if (position >= 32) throw RuntimeException("VarInt is too big") + } + + return value + } + + private suspend fun ByteReadChannel.readUtf8String(): String { + val length = readVarInt() + val data = ByteArray(length) + readFully(data, 0, length) + return String(data) + } +} From fb32dffd17c4026ce5cc53711c22a249773ad06d Mon Sep 17 00:00:00 2001 From: MiniDigger | Martin Date: Thu, 3 Oct 2024 13:05:18 +0200 Subject: [PATCH 3/3] feat: finish backend --- .gitignore | 1 + docker-compose.yml | 4 ++ .../net/kyori/adventure/webui/Constants.kt | 5 +++ .../kyori/adventure/webui/jvm/Application.kt | 4 -- .../webui/jvm/minimessage/MiniMessage.kt | 18 ++++++++ .../preview/ServerStatusPreviewManager.kt | 44 +++++++++++++++++-- 6 files changed, 68 insertions(+), 8 deletions(-) diff --git a/.gitignore b/.gitignore index 2838374..1a5ff25 100644 --- a/.gitignore +++ b/.gitignore @@ -5,3 +5,4 @@ /run/ /*.iml /kotlin-js-store/ +/.kotlin/ diff --git a/docker-compose.yml b/docker-compose.yml index 0c1f31c..576df88 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -13,6 +13,10 @@ services: - "traefik.http.routers.adventure-webui.tls.certresolver=httpOnly" - "traefik.http.routers.adventure-webui.tls.domains[0].main=webui.adventure.kyori.net" - "traefik.http.routers.adventure-webui.tls.domains[1].main=webui.advntr.dev" + - "traefik.tcp.services.adventure-webui-mc.loadbalancer.server.port=25565" + - "traefik.tcp.routers.adventure-webui-mc.rule=HostSNI(`*`)" + - "traefik.tcp.routers.adventure-webui-mc.entrypoints=minecraft" + - "traefik.tcp.routers.adventure-webui-mc.tls.passthrough=true" networks: - web diff --git a/src/commonMain/kotlin/net/kyori/adventure/webui/Constants.kt b/src/commonMain/kotlin/net/kyori/adventure/webui/Constants.kt index d7e1096..e4e4e30 100644 --- a/src/commonMain/kotlin/net/kyori/adventure/webui/Constants.kt +++ b/src/commonMain/kotlin/net/kyori/adventure/webui/Constants.kt @@ -42,5 +42,10 @@ public const val PARAM_EDITOR_TOKEN: String = "token" /** Path for getting a short link for a MiniMessage input. */ public const val URL_MINI_SHORTEN: String = "/mini-shorten" +/** Path for getting a hostname for an in-game MiniMessage motd preview. */ +public const val URL_SETUP_MOTD_PREVIEW: String = "/setup-motd-preview" +/** Path for getting a hostname for an in-game MiniMessage kick preview. */ +public const val URL_SETUP_KICK_PREVIEW: String = "/setup-kick-preview" + /** Path for getting the configuration of this WebUI instance */ public const val URL_BUILD_INFO: String = "/build" diff --git a/src/jvmMain/kotlin/net/kyori/adventure/webui/jvm/Application.kt b/src/jvmMain/kotlin/net/kyori/adventure/webui/jvm/Application.kt index 0b0bd43..9464ddc 100644 --- a/src/jvmMain/kotlin/net/kyori/adventure/webui/jvm/Application.kt +++ b/src/jvmMain/kotlin/net/kyori/adventure/webui/jvm/Application.kt @@ -15,7 +15,6 @@ import io.ktor.server.websocket.WebSockets import io.ktor.server.websocket.pingPeriod import io.ktor.server.websocket.timeout import io.ktor.websocket.WebSocketDeflateExtension -import net.kyori.adventure.webui.jvm.minimessage.preview.ServerStatusPreviewManager import java.time.Duration public fun Application.main() { @@ -49,9 +48,6 @@ public fun Application.main() { trace { route -> this@main.log.debug(route.buildText()) } } } - - // Initialise the server status preview manager. - ServerStatusPreviewManager(this) } diff --git a/src/jvmMain/kotlin/net/kyori/adventure/webui/jvm/minimessage/MiniMessage.kt b/src/jvmMain/kotlin/net/kyori/adventure/webui/jvm/minimessage/MiniMessage.kt index 0d32b2d..be9dcf1 100644 --- a/src/jvmMain/kotlin/net/kyori/adventure/webui/jvm/minimessage/MiniMessage.kt +++ b/src/jvmMain/kotlin/net/kyori/adventure/webui/jvm/minimessage/MiniMessage.kt @@ -30,6 +30,8 @@ import net.kyori.adventure.webui.URL_MINI_SHORTEN import net.kyori.adventure.webui.URL_MINI_TO_HTML import net.kyori.adventure.webui.URL_MINI_TO_JSON import net.kyori.adventure.webui.URL_MINI_TO_TREE +import net.kyori.adventure.webui.URL_SETUP_KICK_PREVIEW +import net.kyori.adventure.webui.URL_SETUP_MOTD_PREVIEW import net.kyori.adventure.webui.jvm.appendComponent import net.kyori.adventure.webui.jvm.getConfigString import net.kyori.adventure.webui.jvm.minimessage.editor.installEditor @@ -42,6 +44,7 @@ import net.kyori.adventure.webui.jvm.minimessage.hook.INSERTION_RENDER_HOOK import net.kyori.adventure.webui.jvm.minimessage.hook.TEXT_COLOR_RENDER_HOOK import net.kyori.adventure.webui.jvm.minimessage.hook.TEXT_DECORATION_RENDER_HOOK import net.kyori.adventure.webui.jvm.minimessage.hook.TEXT_RENDER_HOOK +import net.kyori.adventure.webui.jvm.minimessage.preview.ServerStatusPreviewManager import net.kyori.adventure.webui.jvm.minimessage.storage.BytebinStorage import net.kyori.adventure.webui.tryDecodeFromString import net.kyori.adventure.webui.websocket.Call @@ -92,6 +95,9 @@ public fun Application.miniMessage() { BytebinStorage.BYTEBIN_INSTANCE = this.getConfigString("bytebinInstance") + // Initialise the server status preview manager. + val previewManager = ServerStatusPreviewManager(this) + routing { // define static path to resources static("") { @@ -199,6 +205,18 @@ public fun Application.miniMessage() { } } + post(URL_SETUP_MOTD_PREVIEW) { + val input = call.receiveText() + val hostname = previewManager.initializeMotdPreview(input) + call.respondText(hostname) + } + + post(URL_SETUP_KICK_PREVIEW) { + val input = call.receiveText() + val hostname = previewManager.initializeKickPreview(input) + call.respondText(hostname) + } + get(URL_BUILD_INFO) { val info = BuildInfo( startedAt = startedAt.toString(), diff --git a/src/jvmMain/kotlin/net/kyori/adventure/webui/jvm/minimessage/preview/ServerStatusPreviewManager.kt b/src/jvmMain/kotlin/net/kyori/adventure/webui/jvm/minimessage/preview/ServerStatusPreviewManager.kt index 20fc4f4..1512593 100644 --- a/src/jvmMain/kotlin/net/kyori/adventure/webui/jvm/minimessage/preview/ServerStatusPreviewManager.kt +++ b/src/jvmMain/kotlin/net/kyori/adventure/webui/jvm/minimessage/preview/ServerStatusPreviewManager.kt @@ -1,5 +1,6 @@ package net.kyori.adventure.webui.jvm.minimessage.preview +import io.github.reactivecircus.cache4k.Cache import io.ktor.network.selector.SelectorManager import io.ktor.network.sockets.aSocket import io.ktor.network.sockets.openReadChannel @@ -24,6 +25,7 @@ import org.slf4j.LoggerFactory import java.io.ByteArrayOutputStream import java.io.DataOutputStream import kotlin.coroutines.CoroutineContext +import kotlin.time.Duration.Companion.hours /** Manager class for previewing server status. */ public class ServerStatusPreviewManager( @@ -34,10 +36,13 @@ public class ServerStatusPreviewManager( private val managerJob = SupervisorJob(application.coroutineContext.job) override val coroutineContext: CoroutineContext = application.coroutineContext + managerJob + private val motdPreviews = Cache.Builder().expireAfterAccess(1.hours).build() + private val kickPreviews = Cache.Builder().expireAfterAccess(1.hours).build() + init { launch { // Initialise the socket. - val serverSocket = aSocket(SelectorManager(Dispatchers.IO)).tcp().bind("127.0.0.1", 9002) + val serverSocket = aSocket(SelectorManager(Dispatchers.IO)).tcp().bind("0.0.0.0", 25565) logger.info("Listening for pings at ${serverSocket.localAddress}") while (true) { @@ -64,7 +69,7 @@ public class ServerStatusPreviewManager( sendChannel.writeMcPacket(0) { it.writeString( GsonComponentSerializer.gson() - .serialize(MiniMessage.miniMessage().deserialize("You cant join here!")) + .serialize(MiniMessage.miniMessage().deserialize(lookupKickMessage(serverAddress))) ) } } else { @@ -77,11 +82,15 @@ public class ServerStatusPreviewManager( LegacyComponentSerializer.legacySection() .serialize(MiniMessage.miniMessage().deserialize("MiniMessage")) }", - "protocol": $protocolVersion + "protocol": 1 + }, + "players": { + "max": 0, + "online": 0 }, "description": ${ GsonComponentSerializer.gson().serialize( - MiniMessage.miniMessage().deserialize("MiniMessage is cool!") + MiniMessage.miniMessage().deserialize(lookupMotd(serverAddress)) ) } }""".trimIndent() @@ -100,6 +109,33 @@ public class ServerStatusPreviewManager( } } + private fun lookupKickMessage(serverAddress: String): String { + return kickPreviews.get(serverAddress.split("\\.")[0]) ?: "You cant join here!" + } + + private fun lookupMotd(serverAddress: String): String { + return motdPreviews.get(serverAddress.split("\\.")[0]) ?: "MiniMessage is cool!" + } + + public fun initializeKickPreview(input: String): String { + val key = generateRandomString() + kickPreviews.put(key, input) + return "$key.webui.advntr.dev" + } + + public fun initializeMotdPreview(input: String): String { + val key = generateRandomString() + motdPreviews.put(key, input) + return "$key.webui.advntr.dev" + } + + private fun generateRandomString(length: Int = 8): String { + val allowedChars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789" + return (1..length) + .map { allowedChars.random() } + .joinToString("") + } + private suspend fun ByteWriteChannel.writeMcPacket(packetId: Int, consumer: (packet: DataOutputStream) -> Unit) { val stream = ByteArrayOutputStream() val packet = DataOutputStream(stream)