From dec1bf633259f16e50a87df4af196dfed3490ad6 Mon Sep 17 00:00:00 2001 From: winlogon Date: Tue, 13 May 2025 10:15:27 +0200 Subject: [PATCH 01/30] chore: add tag release workflows file --- .github/workflows/release.yml | 47 +++++++++++++++++++++++++++++++++++ 1 file changed, 47 insertions(+) create mode 100644 .github/workflows/release.yml diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..f7e01b3 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,47 @@ +name: Release JAR + +on: + push: + tags: + - 'v*.*.*' + +permissions: + contents: write + +jobs: + release: + runs-on: ubuntu-latest + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set up JDK 21 + uses: actions/setup-java@v4 + with: + distribution: temurin + java-version: '21' + + - name: Cache Gradle dependencies + uses: actions/cache@v4 + with: + path: ~/.gradle/caches + key: ${{ runner.os }}-gradle-${{ hashFiles('**/*.gradle*','**/gradle-wrapper.properties') }} + restore-keys: | + ${{ runner.os }}-gradle- + + - name: Build plugin + # Pass the tag name into Gradle as "ver" + run: ./gradlew build -Pver=${{ github.ref_name }} + + - name: Run tests + run: ./gradlew test -Pver=${{ github.ref_name }} + + - name: Create GitHub Release + uses: softprops/action-gh-release@v2 + with: + tag_name: ${{ github.ref_name }} + draft: true + generate_release_notes: true + files: build/libs/*.jar + preserve_order: false From 0706973ddf7cf7f97d85296b7a829e4f77ba1148 Mon Sep 17 00:00:00 2001 From: winlogon Date: Tue, 13 May 2025 19:27:35 +0200 Subject: [PATCH 02/30] chore: change types to be more efficient ones --- src/main/kotlin/org/winlogon/minechat/MineChatServerPlugin.kt | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/main/kotlin/org/winlogon/minechat/MineChatServerPlugin.kt b/src/main/kotlin/org/winlogon/minechat/MineChatServerPlugin.kt index bb563ce..35f461f 100644 --- a/src/main/kotlin/org/winlogon/minechat/MineChatServerPlugin.kt +++ b/src/main/kotlin/org/winlogon/minechat/MineChatServerPlugin.kt @@ -39,7 +39,7 @@ data class Config( class MineChatServerPlugin : JavaPlugin() { private var serverSocket: ServerSocket? = null - private val connectedClients = CopyOnWriteArrayList() + private val connectedClients = ConcurrentLinkedQueue() private lateinit var linkCodeStorage: LinkCodeStorage private lateinit var clientStorage: ClientStorage private var isFolia = false @@ -48,7 +48,7 @@ class MineChatServerPlugin : JavaPlugin() { private var expiryCodeMs = 300_000 // 5 minutes private var serverThread: Thread? = null @Volatile private var isServerRunning = false - private val executorService = Executors.newCachedThreadPool() + private val executorService = Executors.newVirtualThreadPerTaskExecutor() val gson = Gson() val miniMessage = MiniMessage.miniMessage() From e6816668dbdcc40ed8215ad25df9ab629c089779 Mon Sep 17 00:00:00 2001 From: winlogon Date: Tue, 1 Jul 2025 00:08:18 +0200 Subject: [PATCH 03/30] chore: update to 1.21.6 and stop using mvn central --- build.gradle.kts | 4 ++-- src/main/kotlin/org/winlogon/minechat/MineChatLoader.kt | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/build.gradle.kts b/build.gradle.kts index 03f8160..6596737 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -72,13 +72,13 @@ repositories { } dependencies { - compileOnly("io.papermc.paper:paper-api:1.21.4-R0.1-SNAPSHOT") + compileOnly("io.papermc.paper:paper-api:1.21.6-R0.1-SNAPSHOT") compileOnly("net.kyori:adventure-text-serializer-plain:4.19.0") compileOnly("com.mojang:brigadier:1.1.8") compileOnly("com.github.ben-manes.caffeine:caffeine:3.2.0") testRuntimeOnly("org.junit.platform:junit-platform-launcher:1.11.4") - testImplementation("io.papermc.paper:paper-api:1.21.4-R0.1-SNAPSHOT") + testImplementation("io.papermc.paper:paper-api:1.21.6-R0.1-SNAPSHOT") testImplementation("org.junit.jupiter:junit-jupiter:5.11.4") testImplementation("org.jetbrains.kotlin:kotlin-test:2.1.10") } diff --git a/src/main/kotlin/org/winlogon/minechat/MineChatLoader.kt b/src/main/kotlin/org/winlogon/minechat/MineChatLoader.kt index 542bbc4..9fd6b3b 100644 --- a/src/main/kotlin/org/winlogon/minechat/MineChatLoader.kt +++ b/src/main/kotlin/org/winlogon/minechat/MineChatLoader.kt @@ -15,7 +15,7 @@ class MineChatLoader : PluginLoader { val resolver = MavenLibraryResolver().apply { addDependency(Dependency(DefaultArtifact("com.github.ben-manes.caffeine:caffeine:$caffeineVersion"), null)) addRepository( - RemoteRepository.Builder("maven-central", "default", "https://repo1.maven.org/maven2/") + RemoteRepository.Builder("maven-central", "default", MavenLibraryResolver.MAVEN_CENTRAL_DEFAULT_MIRROR) .build() ) } From 415129cab315550e7d9e077213586c46afbdf209 Mon Sep 17 00:00:00 2001 From: winlogon Date: Tue, 1 Jul 2025 03:00:35 +0200 Subject: [PATCH 04/30] chore: start splitting class into different files --- build.gradle.kts | 6 +- .../org/winlogon/minechat/ClientConnection.kt | 206 ++++++++++++ .../org/winlogon/minechat/ClientStorage.kt | 34 ++ .../org/winlogon/minechat/LinkCodeStorage.kt | 53 ++++ .../winlogon/minechat/MineChatServerPlugin.kt | 293 ------------------ 5 files changed, 295 insertions(+), 297 deletions(-) create mode 100644 src/main/kotlin/org/winlogon/minechat/ClientConnection.kt create mode 100644 src/main/kotlin/org/winlogon/minechat/ClientStorage.kt create mode 100644 src/main/kotlin/org/winlogon/minechat/LinkCodeStorage.kt diff --git a/build.gradle.kts b/build.gradle.kts index 6596737..568c27e 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -52,7 +52,6 @@ repositories { url = uri("https://repo.papermc.io/repository/maven-public/") content { includeModule("io.papermc.paper", "paper-api") - includeModule("io.papermc", "paperlib") includeModule("net.md-5", "bungeecord-chat") } } @@ -65,7 +64,7 @@ repositories { } maven { - url = uri("https://libraries.minecraft.net") + url = uri("https://maven.winlogon.org/releases") } mavenCentral() @@ -73,8 +72,7 @@ repositories { dependencies { compileOnly("io.papermc.paper:paper-api:1.21.6-R0.1-SNAPSHOT") - compileOnly("net.kyori:adventure-text-serializer-plain:4.19.0") - compileOnly("com.mojang:brigadier:1.1.8") + compileOnly("org.winlogon:asynccraftr:0.1.1") compileOnly("com.github.ben-manes.caffeine:caffeine:3.2.0") testRuntimeOnly("org.junit.platform:junit-platform-launcher:1.11.4") diff --git a/src/main/kotlin/org/winlogon/minechat/ClientConnection.kt b/src/main/kotlin/org/winlogon/minechat/ClientConnection.kt new file mode 100644 index 0000000..dcd8aec --- /dev/null +++ b/src/main/kotlin/org/winlogon/minechat/ClientConnection.kt @@ -0,0 +1,206 @@ +class ClientConnection( + private val socket: java.net.Socket, + private val plugin: MineChatServerPlugin, + private val gson: Gson, + private val miniMessage: MiniMessage +) : Runnable { + object ChatGradients { + val JOIN = Pair("#27AE60", "#2ECC71") + val LEAVE = Pair("#C0392B", "#E74C3C") + val AUTH = Pair("#8E44AD", "#9B59B6") + val INFO = Pair("#2980B9", "#3498DB") + } + + companion object { + const val MINECHAT_PREFIX_STRING = "&8[&3MineChat&8]" + val MINECHAT_PREFIX_COMPONENT: Component = LegacyComponentSerializer.legacyAmpersand().deserialize(MINECHAT_PREFIX_STRING) + } + + private val reader = socket.getInputStream().bufferedReader() + private val writer = socket.getOutputStream().bufferedWriter() + private var client: Client? = null + private var running = true + + private fun broadcastMinecraft(colors: Pair?, message: String) { + val formattedMessage = colors?.let { "$message" } ?: message + val finalMessage = miniMessage.deserialize(formattedMessage) + Bukkit.broadcast(formatPrefixed(finalMessage)) + } + + override fun run() { + try { + while (running) { + val line = reader.readLine() ?: break + val json = gson.fromJson(line, JsonObject::class.java) + when (json.get("type").asString) { + "AUTH" -> handleAuth(json.getAsJsonObject("payload")) + "CHAT" -> handleChat(json.getAsJsonObject("payload")) + "DISCONNECT" -> break + } + } + } catch (e: Exception) { + plugin.logger.warning("Client error: ${e.message}") + } finally { + client?.let { + broadcastMinecraft(ChatGradients.LEAVE, "${it.minecraftUsername} has left the chat.") + plugin.broadcastToClients( + gson.toJson( + mapOf( + "type" to "SYSTEM", + "payload" to mapOf( + "event" to "leave", + "username" to it.minecraftUsername, + "message" to "${it.minecraftUsername} has left the chat." + ) + ) + ) + ) + } + close() + plugin.removeClient(this) + } + } + + private fun handleAuth(payload: JsonObject) { + val clientUuid = payload.get("client_uuid").asString + val linkCode = payload.get("link_code").asString + + if (linkCode.isNotEmpty()) { + val link = plugin.getLinkCodeStorage().find(linkCode) + if (link != null && link.expiresAt > System.currentTimeMillis()) { + val client = Client(clientUuid, link.minecraftUuid, link.minecraftUsername) + plugin.getClientStorage().add(client) + plugin.getLinkCodeStorage().remove(link.code) + this.client = client + sendMessage( + gson.toJson( + mapOf( + "type" to "AUTH_ACK", + "payload" to mapOf( + "status" to "success", + "message" to "Linked to ${link.minecraftUsername}", + "minecraft_uuid" to link.minecraftUuid.toString(), + "username" to link.minecraftUsername + ) + ) + ) + ) + broadcastMinecraft(ChatGradients.AUTH, "${link.minecraftUsername} has successfully authenticated.") + plugin.broadcastToClients( + gson.toJson( + mapOf( + "type" to "SYSTEM", + "payload" to mapOf( + "event" to "join", + "username" to link.minecraftUsername, + "message" to "${link.minecraftUsername} has joined the chat." + ) + ) + ) + ) + } else { + sendMessage( + gson.toJson( + mapOf( + "type" to "AUTH_ACK", + "payload" to mapOf( + "status" to "failure", + "message" to "Invalid or expired link code" + ) + ) + ) + ) + } + } else { + val client = plugin.getClientStorage().find(clientUuid) + if (client != null) { + this.client = client + sendMessage( + gson.toJson( + mapOf( + "type" to "AUTH_ACK", + "payload" to mapOf( + "status" to "success", + "message" to "Welcome back, ${client.minecraftUsername}", + "minecraft_uuid" to client.minecraftUuid.toString(), + "username" to client.minecraftUsername + ) + ) + ) + ) + broadcastMinecraft(ChatGradients.JOIN,"${client.minecraftUsername} has joined the chat.") + plugin.broadcastToClients( + gson.toJson( + mapOf( + "type" to "SYSTEM", + "payload" to mapOf( + "event" to "join", + "username" to client.minecraftUsername, + "message" to "${client.minecraftUsername} has joined the chat." + ) + ) + ) + ) + } else { + sendMessage( + gson.toJson( + mapOf( + "type" to "AUTH_ACK", + "payload" to mapOf( + "status" to "failure", + "message" to "Client not registered" + ) + ) + ) + ) + } + } + } + + private fun handleChat(payload: JsonObject) { + client?.let { + val message = payload.get("message").asString + val usernamePlaceholder = Component.text(it.minecraftUsername, NamedTextColor.DARK_GREEN) + val messagePladeholder = Component.text(message) + val formattedMsg = miniMessage.deserialize( + ": ", + Placeholder.component("sender", usernamePlaceholder), + Placeholder.component("message", messagePladeholder) + ) + val finalMsg = formatPrefixed(formattedMsg) + Bukkit.broadcast(finalMsg) + plugin.broadcastToClients( + gson.toJson( + mapOf( + "type" to "BROADCAST", + "payload" to mapOf( + "from" to "[MineChat] ${it.minecraftUsername}", + "message" to message + ) + ) + ) + ) + } + } + + fun sendMessage(message: String) { + try { + writer.write(message) + writer.newLine() + writer.flush() + } catch (e: Exception) { + plugin.logger.warning("Error sending message: ${e.message}") + } + } + + fun close() { + running = false + socket.close() + } + + fun formatPrefixed(message: Component): Component { + return MINECHAT_PREFIX_COMPONENT + .append(Component.space()) + .append(message) + } +} diff --git a/src/main/kotlin/org/winlogon/minechat/ClientStorage.kt b/src/main/kotlin/org/winlogon/minechat/ClientStorage.kt new file mode 100644 index 0000000..6552451 --- /dev/null +++ b/src/main/kotlin/org/winlogon/minechat/ClientStorage.kt @@ -0,0 +1,34 @@ + +class ClientStorage(private val dataFolder: File, private val gson: Gson) { + private val file = File(dataFolder, "clients.json") + private val clientCache = Caffeine.newBuilder().build().asMap() + private var isDirty = AtomicBoolean(false) + + fun find(clientUuid: String): Client? = clientCache[clientUuid] + + fun add(client: Client) { + clientCache[client.clientUuid] = client + isDirty.set(true) + } + + fun load() { + if (!file.exists()) { + file.writeText("[]") + return + } + val json = file.readText() + if (json.isNotBlank()) { + val type = object : TypeToken>() {}.type + val clients: List = gson.fromJson(json, type) + clientCache.putAll(clients.associateBy { it.clientUuid }) + } + isDirty.set(false) + } + + fun save() { + if (!isDirty.get()) return + val clients = clientCache.values.toList() + file.writeText(gson.toJson(clients)) + isDirty.set(false) + } +} diff --git a/src/main/kotlin/org/winlogon/minechat/LinkCodeStorage.kt b/src/main/kotlin/org/winlogon/minechat/LinkCodeStorage.kt new file mode 100644 index 0000000..f736c77 --- /dev/null +++ b/src/main/kotlin/org/winlogon/minechat/LinkCodeStorage.kt @@ -0,0 +1,53 @@ +class LinkCodeStorage(private val dataFolder: File, private val gson: Gson) { + private val file = File(dataFolder, "link_codes.json") + private val linkCodeCache = Caffeine.newBuilder().build().asMap() + private var isDirty = AtomicBoolean(false) + + fun add(linkCode: LinkCode) { + linkCodeCache[linkCode.code] = linkCode + isDirty.set(true) + } + + fun find(code: String): LinkCode? = linkCodeCache[code] + + fun remove(code: String) { + linkCodeCache.remove(code) + isDirty.set(true) + } + + fun cleanupExpired() { + val now = System.currentTimeMillis() + var modified = false + val iterator = linkCodeCache.iterator() + while (iterator.hasNext()) { + val entry = iterator.next() + if (entry.value.expiresAt <= now) { + iterator.remove() + modified = true + } + } + if (modified) isDirty.set(true) + } + + fun load() { + if (!file.exists()) { + file.writeText("[]") + return + } + val json = file.readText() + if (json.isNotBlank()) { + val type = object : TypeToken>() {}.type + val codes: List = gson.fromJson(json, type) + linkCodeCache.putAll(codes.associateBy { it.code }) + } + isDirty.set(false) + } + + fun save() { + if (!isDirty.get()) return + val codes = linkCodeCache.values.toList() + file.writeText(gson.toJson(codes)) + isDirty.set(false) + } +} + diff --git a/src/main/kotlin/org/winlogon/minechat/MineChatServerPlugin.kt b/src/main/kotlin/org/winlogon/minechat/MineChatServerPlugin.kt index 35f461f..58255db 100644 --- a/src/main/kotlin/org/winlogon/minechat/MineChatServerPlugin.kt +++ b/src/main/kotlin/org/winlogon/minechat/MineChatServerPlugin.kt @@ -233,296 +233,3 @@ data class Client( val minecraftUsername: String ) -class LinkCodeStorage(private val dataFolder: File, private val gson: Gson) { - private val file = File(dataFolder, "link_codes.json") - private val linkCodeCache = Caffeine.newBuilder().build().asMap() - private var isDirty = AtomicBoolean(false) - - fun add(linkCode: LinkCode) { - linkCodeCache[linkCode.code] = linkCode - isDirty.set(true) - } - - fun find(code: String): LinkCode? = linkCodeCache[code] - - fun remove(code: String) { - linkCodeCache.remove(code) - isDirty.set(true) - } - - fun cleanupExpired() { - val now = System.currentTimeMillis() - var modified = false - val iterator = linkCodeCache.iterator() - while (iterator.hasNext()) { - val entry = iterator.next() - if (entry.value.expiresAt <= now) { - iterator.remove() - modified = true - } - } - if (modified) isDirty.set(true) - } - - fun load() { - if (!file.exists()) { - file.writeText("[]") - return - } - val json = file.readText() - if (json.isNotBlank()) { - val type = object : TypeToken>() {}.type - val codes: List = gson.fromJson(json, type) - linkCodeCache.putAll(codes.associateBy { it.code }) - } - isDirty.set(false) - } - - fun save() { - if (!isDirty.get()) return - val codes = linkCodeCache.values.toList() - file.writeText(gson.toJson(codes)) - isDirty.set(false) - } -} - -class ClientStorage(private val dataFolder: File, private val gson: Gson) { - private val file = File(dataFolder, "clients.json") - private val clientCache = Caffeine.newBuilder().build().asMap() - private var isDirty = AtomicBoolean(false) - - fun find(clientUuid: String): Client? = clientCache[clientUuid] - - fun add(client: Client) { - clientCache[client.clientUuid] = client - isDirty.set(true) - } - - fun load() { - if (!file.exists()) { - file.writeText("[]") - return - } - val json = file.readText() - if (json.isNotBlank()) { - val type = object : TypeToken>() {}.type - val clients: List = gson.fromJson(json, type) - clientCache.putAll(clients.associateBy { it.clientUuid }) - } - isDirty.set(false) - } - - fun save() { - if (!isDirty.get()) return - val clients = clientCache.values.toList() - file.writeText(gson.toJson(clients)) - isDirty.set(false) - } -} - -class ClientConnection( - private val socket: java.net.Socket, - private val plugin: MineChatServerPlugin, - private val gson: Gson, - private val miniMessage: MiniMessage -) : Runnable { - object ChatGradients { - val JOIN = Pair("#27AE60", "#2ECC71") - val LEAVE = Pair("#C0392B", "#E74C3C") - val AUTH = Pair("#8E44AD", "#9B59B6") - val INFO = Pair("#2980B9", "#3498DB") - } - - companion object { - const val MINECHAT_PREFIX_STRING = "&8[&3MineChat&8]" - val MINECHAT_PREFIX_COMPONENT: Component = LegacyComponentSerializer.legacyAmpersand().deserialize(MINECHAT_PREFIX_STRING) - } - - private val reader = socket.getInputStream().bufferedReader() - private val writer = socket.getOutputStream().bufferedWriter() - private var client: Client? = null - private var running = true - - private fun broadcastMinecraft(colors: Pair?, message: String) { - val formattedMessage = colors?.let { "$message" } ?: message - val finalMessage = miniMessage.deserialize(formattedMessage) - Bukkit.broadcast(formatPrefixed(finalMessage)) - } - - override fun run() { - try { - while (running) { - val line = reader.readLine() ?: break - val json = gson.fromJson(line, JsonObject::class.java) - when (json.get("type").asString) { - "AUTH" -> handleAuth(json.getAsJsonObject("payload")) - "CHAT" -> handleChat(json.getAsJsonObject("payload")) - "DISCONNECT" -> break - } - } - } catch (e: Exception) { - plugin.logger.warning("Client error: ${e.message}") - } finally { - client?.let { - broadcastMinecraft(ChatGradients.LEAVE, "${it.minecraftUsername} has left the chat.") - plugin.broadcastToClients( - gson.toJson( - mapOf( - "type" to "SYSTEM", - "payload" to mapOf( - "event" to "leave", - "username" to it.minecraftUsername, - "message" to "${it.minecraftUsername} has left the chat." - ) - ) - ) - ) - } - close() - plugin.removeClient(this) - } - } - - private fun handleAuth(payload: JsonObject) { - val clientUuid = payload.get("client_uuid").asString - val linkCode = payload.get("link_code").asString - - if (linkCode.isNotEmpty()) { - val link = plugin.getLinkCodeStorage().find(linkCode) - if (link != null && link.expiresAt > System.currentTimeMillis()) { - val client = Client(clientUuid, link.minecraftUuid, link.minecraftUsername) - plugin.getClientStorage().add(client) - plugin.getLinkCodeStorage().remove(link.code) - this.client = client - sendMessage( - gson.toJson( - mapOf( - "type" to "AUTH_ACK", - "payload" to mapOf( - "status" to "success", - "message" to "Linked to ${link.minecraftUsername}", - "minecraft_uuid" to link.minecraftUuid.toString(), - "username" to link.minecraftUsername - ) - ) - ) - ) - broadcastMinecraft(ChatGradients.AUTH, "${link.minecraftUsername} has successfully authenticated.") - plugin.broadcastToClients( - gson.toJson( - mapOf( - "type" to "SYSTEM", - "payload" to mapOf( - "event" to "join", - "username" to link.minecraftUsername, - "message" to "${link.minecraftUsername} has joined the chat." - ) - ) - ) - ) - } else { - sendMessage( - gson.toJson( - mapOf( - "type" to "AUTH_ACK", - "payload" to mapOf( - "status" to "failure", - "message" to "Invalid or expired link code" - ) - ) - ) - ) - } - } else { - val client = plugin.getClientStorage().find(clientUuid) - if (client != null) { - this.client = client - sendMessage( - gson.toJson( - mapOf( - "type" to "AUTH_ACK", - "payload" to mapOf( - "status" to "success", - "message" to "Welcome back, ${client.minecraftUsername}", - "minecraft_uuid" to client.minecraftUuid.toString(), - "username" to client.minecraftUsername - ) - ) - ) - ) - broadcastMinecraft(ChatGradients.JOIN,"${client.minecraftUsername} has joined the chat.") - plugin.broadcastToClients( - gson.toJson( - mapOf( - "type" to "SYSTEM", - "payload" to mapOf( - "event" to "join", - "username" to client.minecraftUsername, - "message" to "${client.minecraftUsername} has joined the chat." - ) - ) - ) - ) - } else { - sendMessage( - gson.toJson( - mapOf( - "type" to "AUTH_ACK", - "payload" to mapOf( - "status" to "failure", - "message" to "Client not registered" - ) - ) - ) - ) - } - } - } - - private fun handleChat(payload: JsonObject) { - client?.let { - val message = payload.get("message").asString - val usernamePlaceholder = Component.text(it.minecraftUsername, NamedTextColor.DARK_GREEN) - val messagePladeholder = Component.text(message) - val formattedMsg = miniMessage.deserialize( - ": ", - Placeholder.component("sender", usernamePlaceholder), - Placeholder.component("message", messagePladeholder) - ) - val finalMsg = formatPrefixed(formattedMsg) - Bukkit.broadcast(finalMsg) - plugin.broadcastToClients( - gson.toJson( - mapOf( - "type" to "BROADCAST", - "payload" to mapOf( - "from" to "[MineChat] ${it.minecraftUsername}", - "message" to message - ) - ) - ) - ) - } - } - - fun sendMessage(message: String) { - try { - writer.write(message) - writer.newLine() - writer.flush() - } catch (e: Exception) { - plugin.logger.warning("Error sending message: ${e.message}") - } - } - - fun close() { - running = false - socket.close() - } - - fun formatPrefixed(message: Component): Component { - return MINECHAT_PREFIX_COMPONENT - .append(Component.space()) - .append(message) - } -} From 4a30411b53c8902320f4d81a61118ea018199596 Mon Sep 17 00:00:00 2001 From: winlogon Date: Tue, 28 Oct 2025 03:17:35 +0100 Subject: [PATCH 05/30] refactor: split logic and use ObjectBox for integrated storage - Add ObjectBox and dependencies in build.gradle.kts and settings.gradle.kts - Replace JSON + in-memory caching with ObjectBox persistent storage for clients, bans and link codes - Implement UuidConverter for ObjectBox UUID support - Refactor ClientConnection to use CBOR + Zstd for communication - Update MineChatServerPlugin to use ObjectBox storage, adding: - Ban commands - TLS support for server socket - Removal of legacy Gson-based storage - Minor fixes and repository updates in build.gradle.kts --- build.gradle.kts | 22 +- settings.gradle.kts | 8 + src/main/kotlin/org/winlogon/minechat/Ban.kt | 17 ++ .../org/winlogon/minechat/BanStorage.kt | 38 +++ .../kotlin/org/winlogon/minechat/Client.kt | 15 ++ .../org/winlogon/minechat/ClientConnection.kt | 218 ++++++++++++------ .../org/winlogon/minechat/ClientStorage.kt | 46 ++-- .../kotlin/org/winlogon/minechat/LinkCode.kt | 16 ++ .../org/winlogon/minechat/LinkCodeStorage.kt | 64 ++--- .../winlogon/minechat/MineChatServerPlugin.kt | 191 +++++++++------ .../org/winlogon/minechat/UuidConverter.kt | 14 ++ 11 files changed, 435 insertions(+), 214 deletions(-) create mode 100644 src/main/kotlin/org/winlogon/minechat/Ban.kt create mode 100644 src/main/kotlin/org/winlogon/minechat/BanStorage.kt create mode 100644 src/main/kotlin/org/winlogon/minechat/Client.kt create mode 100644 src/main/kotlin/org/winlogon/minechat/LinkCode.kt create mode 100644 src/main/kotlin/org/winlogon/minechat/UuidConverter.kt diff --git a/build.gradle.kts b/build.gradle.kts index 568c27e..a6c2238 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -1,11 +1,23 @@ import java.text.SimpleDateFormat import java.util.* +buildscript { + repositories { + mavenCentral() + maven { url = uri("https://download.objectbox.io/maven") } + } + dependencies { + classpath("io.objectbox:objectbox-gradle-plugin:3.8.0") + } +} + plugins { id("com.gradleup.shadow") version "8.3.6" kotlin("jvm") version "2.1.10" } +apply(plugin = "io.objectbox") + group = "org.winlogon.minechat" fun getLatestGitTag(): String? { @@ -72,9 +84,15 @@ repositories { dependencies { compileOnly("io.papermc.paper:paper-api:1.21.6-R0.1-SNAPSHOT") - compileOnly("org.winlogon:asynccraftr:0.1.1") + compileOnly("org.winlogon:asynccraftr:0.1.0") compileOnly("com.github.ben-manes.caffeine:caffeine:3.2.0") - + compileOnly("com.google.code.gson:gson:2.11.0") + compileOnly("io.objectbox:objectbox-kotlin:3.8.0") + compileOnly("org.jetbrains.kotlin:kotlin-reflect:2.1.10") + compileOnly("com.fasterxml.jackson.dataformat:jackson-dataformat-cbor:2.17.1") + compileOnly("com.github.luben:zstd-jni:1.5.6-1") + compileOnly("com.fasterxml.jackson.module:jackson-module-kotlin:2.17.1") + testRuntimeOnly("org.junit.platform:junit-platform-launcher:1.11.4") testImplementation("io.papermc.paper:paper-api:1.21.6-R0.1-SNAPSHOT") testImplementation("org.junit.jupiter:junit-jupiter:5.11.4") diff --git a/settings.gradle.kts b/settings.gradle.kts index 7b1e251..ab0302f 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -6,4 +6,12 @@ * This project uses @Incubating APIs which are subject to change. */ +pluginManagement { + repositories { + gradlePluginPortal() + mavenCentral() + maven { url = uri("https://download.objectbox.io/maven") } + } +} + rootProject.name = "MineChat" diff --git a/src/main/kotlin/org/winlogon/minechat/Ban.kt b/src/main/kotlin/org/winlogon/minechat/Ban.kt new file mode 100644 index 0000000..2e7433f --- /dev/null +++ b/src/main/kotlin/org/winlogon/minechat/Ban.kt @@ -0,0 +1,17 @@ +package org.winlogon.minechat + +import io.objectbox.annotation.Convert +import io.objectbox.annotation.Entity +import io.objectbox.annotation.Id +import java.util.UUID + +@Entity +data class Ban( + @Id var id: Long = 0, + val clientUuid: String? = null, + @Convert(converter = UuidConverter::class, dbType = String::class) + val minecraftUuid: UUID? = null, + val minecraftUsername: String? = null, + val reason: String? = null, + val timestamp: Long = System.currentTimeMillis() +) diff --git a/src/main/kotlin/org/winlogon/minechat/BanStorage.kt b/src/main/kotlin/org/winlogon/minechat/BanStorage.kt new file mode 100644 index 0000000..687ab30 --- /dev/null +++ b/src/main/kotlin/org/winlogon/minechat/BanStorage.kt @@ -0,0 +1,38 @@ +package org.winlogon.minechat + +import io.objectbox.Box +import io.objectbox.BoxStore +import java.util.UUID + +class BanStorage(boxStore: BoxStore) { + private val banBox: Box = boxStore.boxFor(Ban::class.java) + + fun add(ban: Ban) { + banBox.put(ban) + } + + fun remove(clientUuid: String?, minecraftUsername: String?) { + if (clientUuid != null) { + banBox.query(Ban_.clientUuid.equal(clientUuid)).build().remove() + } + if (minecraftUsername != null) { + banBox.query(Ban_.minecraftUsername.equal(minecraftUsername)).build().remove() + } + } + + fun getBan(clientUuid: String?, minecraftUsername: String?): Ban? { + if (clientUuid != null) { + val ban = banBox.query(Ban_.clientUuid.equal(clientUuid)).build().findFirst() + if (ban != null) { + return ban + } + } + if (minecraftUsername != null) { + val ban = banBox.query(Ban_.minecraftUsername.equal(minecraftUsername)).build().findFirst() + if (ban != null) { + return ban + } + } + return null + } +} \ No newline at end of file diff --git a/src/main/kotlin/org/winlogon/minechat/Client.kt b/src/main/kotlin/org/winlogon/minechat/Client.kt new file mode 100644 index 0000000..963f37d --- /dev/null +++ b/src/main/kotlin/org/winlogon/minechat/Client.kt @@ -0,0 +1,15 @@ +package org.winlogon.minechat + +import io.objectbox.annotation.Convert +import io.objectbox.annotation.Entity +import io.objectbox.annotation.Id +import java.util.UUID + +@Entity +data class Client( + @Id var id: Long = 0, + var clientUuid: String = "", + @Convert(converter = UuidConverter::class, dbType = String::class) + var minecraftUuid: UUID? = null, + var minecraftUsername: String = "" +) \ No newline at end of file diff --git a/src/main/kotlin/org/winlogon/minechat/ClientConnection.kt b/src/main/kotlin/org/winlogon/minechat/ClientConnection.kt index dcd8aec..9b5f7ce 100644 --- a/src/main/kotlin/org/winlogon/minechat/ClientConnection.kt +++ b/src/main/kotlin/org/winlogon/minechat/ClientConnection.kt @@ -1,7 +1,26 @@ +package org.winlogon.minechat + +import com.fasterxml.jackson.databind.ObjectMapper +import com.fasterxml.jackson.dataformat.cbor.CBORFactory +import com.fasterxml.jackson.module.kotlin.KotlinModule +import com.github.luben.zstd.Zstd + +import net.kyori.adventure.text.Component +import net.kyori.adventure.text.format.NamedTextColor +import net.kyori.adventure.text.minimessage.MiniMessage +import net.kyori.adventure.text.minimessage.tag.resolver.Placeholder +import net.kyori.adventure.text.serializer.gson.GsonComponentSerializer +import net.kyori.adventure.text.serializer.legacy.LegacyComponentSerializer + +import org.bukkit.Bukkit + +import java.io.DataInputStream +import java.io.DataOutputStream +import java.net.Socket + class ClientConnection( - private val socket: java.net.Socket, + private val socket: Socket, private val plugin: MineChatServerPlugin, - private val gson: Gson, private val miniMessage: MiniMessage ) : Runnable { object ChatGradients { @@ -13,14 +32,18 @@ class ClientConnection( companion object { const val MINECHAT_PREFIX_STRING = "&8[&3MineChat&8]" - val MINECHAT_PREFIX_COMPONENT: Component = LegacyComponentSerializer.legacyAmpersand().deserialize(MINECHAT_PREFIX_STRING) + val MINECHAT_PREFIX_COMPONENT: Component = + LegacyComponentSerializer.legacyAmpersand().deserialize(MINECHAT_PREFIX_STRING) } - private val reader = socket.getInputStream().bufferedReader() - private val writer = socket.getOutputStream().bufferedWriter() + private val cborMapper = ObjectMapper(CBORFactory()).registerModule(KotlinModule.Builder().build()) + private val reader = DataInputStream(socket.getInputStream()) + private val writer = DataOutputStream(socket.getOutputStream()) private var client: Client? = null private var running = true + fun getClient(): Client? = client + private fun broadcastMinecraft(colors: Pair?, message: String) { val formattedMessage = colors?.let { "$message" } ?: message val finalMessage = miniMessage.deserialize(formattedMessage) @@ -30,11 +53,20 @@ class ClientConnection( override fun run() { try { while (running) { - val line = reader.readLine() ?: break - val json = gson.fromJson(line, JsonObject::class.java) - when (json.get("type").asString) { - "AUTH" -> handleAuth(json.getAsJsonObject("payload")) - "CHAT" -> handleChat(json.getAsJsonObject("payload")) + val decompressedLen = reader.readInt() + if (decompressedLen <= 0) continue + val compressedLen = reader.readInt() + if (compressedLen <= 0) continue + + val compressed = ByteArray(compressedLen) + reader.readFully(compressed) + + val decompressed = Zstd.decompress(compressed, decompressedLen) + val json = cborMapper.readValue(decompressed, Map::class.java) as Map + + when (json["type"] as String) { + "AUTH" -> handleAuth(json["payload"] as Map) + "CHAT" -> handleChat(json["payload"] as Map) "DISCONNECT" -> break } } @@ -44,14 +76,12 @@ class ClientConnection( client?.let { broadcastMinecraft(ChatGradients.LEAVE, "${it.minecraftUsername} has left the chat.") plugin.broadcastToClients( - gson.toJson( - mapOf( - "type" to "SYSTEM", - "payload" to mapOf( - "event" to "leave", - "username" to it.minecraftUsername, - "message" to "${it.minecraftUsername} has left the chat." - ) + mapOf( + "type" to "SYSTEM", + "payload" to mapOf( + "event" to "leave", + "username" to it.minecraftUsername, + "message" to "${it.minecraftUsername} has left the chat." ) ) ) @@ -61,19 +91,45 @@ class ClientConnection( } } - private fun handleAuth(payload: JsonObject) { - val clientUuid = payload.get("client_uuid").asString - val linkCode = payload.get("link_code").asString + private fun sendBannedMessage(ban: Ban) { + disconnect(ban.reason ?: "You are banned from MineChat.") + } + + fun disconnect(reason: String) { + sendMessage( + mapOf( + "type" to "DISCONNECT", + "payload" to mapOf("reason" to reason) + ) + ) + close() + } + + private fun handleAuth(payload: Map) { + val banStorage = plugin.getBanStorage() + val clientUuid = payload["client_uuid"] as String + val linkCode = payload["link_code"] as String + + var ban = banStorage.getBan(clientUuid, null) + if (ban != null) { + sendBannedMessage(ban) + return + } if (linkCode.isNotEmpty()) { val link = plugin.getLinkCodeStorage().find(linkCode) - if (link != null && link.expiresAt > System.currentTimeMillis()) { - val client = Client(clientUuid, link.minecraftUuid, link.minecraftUsername) - plugin.getClientStorage().add(client) - plugin.getLinkCodeStorage().remove(link.code) - this.client = client - sendMessage( - gson.toJson( + if (link != null) { + ban = banStorage.getBan(null, link.minecraftUsername) + if (ban != null) { + sendBannedMessage(ban) + return + } + if (link.expiresAt > System.currentTimeMillis()) { + val client = Client(clientUuid = clientUuid, minecraftUuid = link.minecraftUuid, minecraftUsername = link.minecraftUsername) + plugin.getClientStorage().add(client) + plugin.getLinkCodeStorage().remove(link.code) + this.client = client + sendMessage( mapOf( "type" to "AUTH_ACK", "payload" to mapOf( @@ -84,10 +140,11 @@ class ClientConnection( ) ) ) - ) - broadcastMinecraft(ChatGradients.AUTH, "${link.minecraftUsername} has successfully authenticated.") - plugin.broadcastToClients( - gson.toJson( + broadcastMinecraft( + ChatGradients.AUTH, + "${link.minecraftUsername} has successfully authenticated." + ) + plugin.broadcastToClients( mapOf( "type" to "SYSTEM", "payload" to mapOf( @@ -97,10 +154,8 @@ class ClientConnection( ) ) ) - ) - } else { - sendMessage( - gson.toJson( + } else { + sendMessage( mapOf( "type" to "AUTH_ACK", "payload" to mapOf( @@ -109,47 +164,56 @@ class ClientConnection( ) ) ) + } + } else { + sendMessage( + mapOf( + "type" to "AUTH_ACK", + "payload" to mapOf( + "status" to "failure", + "message" to "Invalid or expired link code" + ) + ) ) } } else { - val client = plugin.getClientStorage().find(clientUuid) + val client = plugin.getClientStorage().find(clientUuid, null) if (client != null) { + ban = banStorage.getBan(null, client.minecraftUsername) + if (ban != null) { + sendBannedMessage(ban) + return + } this.client = client sendMessage( - gson.toJson( - mapOf( - "type" to "AUTH_ACK", - "payload" to mapOf( - "status" to "success", - "message" to "Welcome back, ${client.minecraftUsername}", - "minecraft_uuid" to client.minecraftUuid.toString(), - "username" to client.minecraftUsername - ) + mapOf( + "type" to "AUTH_ACK", + "payload" to mapOf( + "status" to "success", + "message" to "Welcome back, ${client.minecraftUsername}", + "minecraft_uuid" to client.minecraftUuid.toString(), + "username" to client.minecraftUsername ) ) ) - broadcastMinecraft(ChatGradients.JOIN,"${client.minecraftUsername} has joined the chat.") + broadcastMinecraft(ChatGradients.JOIN, "${client.minecraftUsername} has joined the chat.") plugin.broadcastToClients( - gson.toJson( - mapOf( - "type" to "SYSTEM", - "payload" to mapOf( - "event" to "join", - "username" to client.minecraftUsername, - "message" to "${client.minecraftUsername} has joined the chat." - ) + mapOf( + "type" to "SYSTEM", + "payload" to mapOf( + "event" to "join", + "username" to client.minecraftUsername, + "message" to "${client.minecraftUsername} has joined the chat." ) ) ) } else { sendMessage( - gson.toJson( - mapOf( - "type" to "AUTH_ACK", - "payload" to mapOf( - "status" to "failure", - "message" to "Client not registered" - ) + mapOf( + "type" to "AUTH_ACK", + "payload" to mapOf( + "status" to "failure", + "message" to "Client not registered" ) ) ) @@ -157,36 +221,38 @@ class ClientConnection( } } - private fun handleChat(payload: JsonObject) { + private fun handleChat(payload: Map) { client?.let { - val message = payload.get("message").asString + val messageJson = cborMapper.writeValueAsString(payload["message"]) + val message = GsonComponentSerializer.gson().deserialize(messageJson) + val usernamePlaceholder = Component.text(it.minecraftUsername, NamedTextColor.DARK_GREEN) - val messagePladeholder = Component.text(message) val formattedMsg = miniMessage.deserialize( ": ", Placeholder.component("sender", usernamePlaceholder), - Placeholder.component("message", messagePladeholder) + Placeholder.component("message", message) ) val finalMsg = formatPrefixed(formattedMsg) Bukkit.broadcast(finalMsg) plugin.broadcastToClients( - gson.toJson( - mapOf( - "type" to "BROADCAST", - "payload" to mapOf( - "from" to "[MineChat] ${it.minecraftUsername}", - "message" to message - ) + mapOf( + "type" to "BROADCAST", + "payload" to mapOf( + "from" to "[MineChat] ${it.minecraftUsername}", + "message" to payload["message"]!! ) ) ) } } - fun sendMessage(message: String) { + fun sendMessage(message: Map) { try { - writer.write(message) - writer.newLine() + val serialized = cborMapper.writeValueAsBytes(message) + val compressed = Zstd.compress(serialized) + writer.writeInt(serialized.size) + writer.writeInt(compressed.size) + writer.write(compressed) writer.flush() } catch (e: Exception) { plugin.logger.warning("Error sending message: ${e.message}") diff --git a/src/main/kotlin/org/winlogon/minechat/ClientStorage.kt b/src/main/kotlin/org/winlogon/minechat/ClientStorage.kt index 6552451..a34d094 100644 --- a/src/main/kotlin/org/winlogon/minechat/ClientStorage.kt +++ b/src/main/kotlin/org/winlogon/minechat/ClientStorage.kt @@ -1,34 +1,30 @@ +package org.winlogon.minechat -class ClientStorage(private val dataFolder: File, private val gson: Gson) { - private val file = File(dataFolder, "clients.json") - private val clientCache = Caffeine.newBuilder().build().asMap() - private var isDirty = AtomicBoolean(false) +import io.objectbox.Box +import io.objectbox.BoxStore - fun find(clientUuid: String): Client? = clientCache[clientUuid] +class ClientStorage(boxStore: BoxStore) { + private val clientBox: Box = boxStore.boxFor(Client::class.java) - fun add(client: Client) { - clientCache[client.clientUuid] = client - isDirty.set(true) - } - - fun load() { - if (!file.exists()) { - file.writeText("[]") - return + fun find(clientUuid: String?, minecraftUsername: String?): Client? { + if (clientUuid != null) { + return clientBox.query(Client_.clientUuid.equal(clientUuid)).build().findFirst() } - val json = file.readText() - if (json.isNotBlank()) { - val type = object : TypeToken>() {}.type - val clients: List = gson.fromJson(json, type) - clientCache.putAll(clients.associateBy { it.clientUuid }) + if (minecraftUsername != null) { + return clientBox.query(Client_.minecraftUsername.equal(minecraftUsername)).build().findFirst() } - isDirty.set(false) + return null } - fun save() { - if (!isDirty.get()) return - val clients = clientCache.values.toList() - file.writeText(gson.toJson(clients)) - isDirty.set(false) + fun add(client: Client) { + // Check if a client with the same minecraft username already exists + val existing = find(null, client.minecraftUsername) + if (existing != null) { + // Update existing client's uuid + existing.clientUuid = client.clientUuid + clientBox.put(existing) + } else { + clientBox.put(client) + } } } diff --git a/src/main/kotlin/org/winlogon/minechat/LinkCode.kt b/src/main/kotlin/org/winlogon/minechat/LinkCode.kt new file mode 100644 index 0000000..cde1f04 --- /dev/null +++ b/src/main/kotlin/org/winlogon/minechat/LinkCode.kt @@ -0,0 +1,16 @@ +package org.winlogon.minechat + +import io.objectbox.annotation.Convert +import io.objectbox.annotation.Entity +import io.objectbox.annotation.Id +import java.util.UUID + +@Entity +data class LinkCode( + @Id var id: Long = 0, + val code: String, + @Convert(converter = UuidConverter::class, dbType = String::class) + val minecraftUuid: UUID, + val minecraftUsername: String, + val expiresAt: Long +) diff --git a/src/main/kotlin/org/winlogon/minechat/LinkCodeStorage.kt b/src/main/kotlin/org/winlogon/minechat/LinkCodeStorage.kt index f736c77..c813eae 100644 --- a/src/main/kotlin/org/winlogon/minechat/LinkCodeStorage.kt +++ b/src/main/kotlin/org/winlogon/minechat/LinkCodeStorage.kt @@ -1,53 +1,39 @@ -class LinkCodeStorage(private val dataFolder: File, private val gson: Gson) { - private val file = File(dataFolder, "link_codes.json") - private val linkCodeCache = Caffeine.newBuilder().build().asMap() - private var isDirty = AtomicBoolean(false) +package org.winlogon.minechat + +import io.objectbox.Box +import io.objectbox.BoxStore +import java.util.concurrent.Executors +import java.util.concurrent.TimeUnit + +class LinkCodeStorage(private val boxStore: BoxStore) { + private val linkCodeBox: Box = boxStore.boxFor(LinkCode::class.java) + private val scheduler = Executors.newSingleThreadScheduledExecutor() + + init { + // Schedule cleanup of expired link codes every minute + scheduler.scheduleAtFixedRate({ + cleanupExpired() + }, 0, 1, TimeUnit.MINUTES) + } fun add(linkCode: LinkCode) { - linkCodeCache[linkCode.code] = linkCode - isDirty.set(true) + linkCodeBox.put(linkCode) } - fun find(code: String): LinkCode? = linkCodeCache[code] + fun find(code: String): LinkCode? { + return linkCodeBox.query(LinkCode_.code.equal(code)).build().findFirst() + } fun remove(code: String) { - linkCodeCache.remove(code) - isDirty.set(true) + linkCodeBox.query(LinkCode_.code.equal(code)).build().remove() } fun cleanupExpired() { val now = System.currentTimeMillis() - var modified = false - val iterator = linkCodeCache.iterator() - while (iterator.hasNext()) { - val entry = iterator.next() - if (entry.value.expiresAt <= now) { - iterator.remove() - modified = true - } - } - if (modified) isDirty.set(true) - } - - fun load() { - if (!file.exists()) { - file.writeText("[]") - return - } - val json = file.readText() - if (json.isNotBlank()) { - val type = object : TypeToken>() {}.type - val codes: List = gson.fromJson(json, type) - linkCodeCache.putAll(codes.associateBy { it.code }) - } - isDirty.set(false) + linkCodeBox.query(LinkCode_.expiresAt.less(now)).build().remove() } - fun save() { - if (!isDirty.get()) return - val codes = linkCodeCache.values.toList() - file.writeText(gson.toJson(codes)) - isDirty.set(false) + fun close() { + scheduler.shutdown() } } - diff --git a/src/main/kotlin/org/winlogon/minechat/MineChatServerPlugin.kt b/src/main/kotlin/org/winlogon/minechat/MineChatServerPlugin.kt index 58255db..7c710c3 100644 --- a/src/main/kotlin/org/winlogon/minechat/MineChatServerPlugin.kt +++ b/src/main/kotlin/org/winlogon/minechat/MineChatServerPlugin.kt @@ -1,47 +1,40 @@ package org.winlogon.minechat -import com.github.benmanes.caffeine.cache.Caffeine -import com.google.gson.Gson -import com.google.gson.JsonObject -import com.google.gson.reflect.TypeToken import com.mojang.brigadier.Command +import com.mojang.brigadier.arguments.StringArgumentType -import org.bukkit.Bukkit -import org.bukkit.entity.Player -import org.bukkit.event.EventHandler -import org.bukkit.event.Listener -import org.bukkit.plugin.java.JavaPlugin - -import io.papermc.paper.command.brigadier.BasicCommand -import io.papermc.paper.command.brigadier.CommandSourceStack +import io.objectbox.BoxStore import io.papermc.paper.command.brigadier.Commands import io.papermc.paper.event.player.AsyncChatEvent import io.papermc.paper.plugin.lifecycle.event.types.LifecycleEvents -import io.papermc.paper.threadedregions.scheduler.AsyncScheduler import net.kyori.adventure.text.Component import net.kyori.adventure.text.format.NamedTextColor import net.kyori.adventure.text.minimessage.MiniMessage import net.kyori.adventure.text.minimessage.tag.resolver.Placeholder -import net.kyori.adventure.text.serializer.legacy.LegacyComponentSerializer import net.kyori.adventure.text.serializer.plain.PlainTextComponentSerializer +import org.bukkit.entity.Player +import org.bukkit.event.EventHandler +import org.bukkit.event.Listener +import org.bukkit.plugin.java.JavaPlugin + import java.io.File import java.net.ServerSocket -import java.util.* -import java.util.concurrent.* -import java.util.concurrent.atomic.AtomicBoolean -import kotlin.concurrent.schedule - -data class Config( - val port: Int -) +import java.security.KeyStore +import java.util.concurrent.ConcurrentLinkedQueue +import java.util.concurrent.Executors +import java.util.concurrent.TimeUnit +import javax.net.ssl.KeyManagerFactory +import javax.net.ssl.SSLContext class MineChatServerPlugin : JavaPlugin() { private var serverSocket: ServerSocket? = null private val connectedClients = ConcurrentLinkedQueue() private lateinit var linkCodeStorage: LinkCodeStorage private lateinit var clientStorage: ClientStorage + private lateinit var banStorage: BanStorage + private lateinit var boxStore: BoxStore private var isFolia = false private var port: Int = 25575 @@ -49,7 +42,6 @@ class MineChatServerPlugin : JavaPlugin() { private var serverThread: Thread? = null @Volatile private var isServerRunning = false private val executorService = Executors.newVirtualThreadPerTaskExecutor() - val gson = Gson() val miniMessage = MiniMessage.miniMessage() private fun generateLinkCode(): String { @@ -57,6 +49,10 @@ class MineChatServerPlugin : JavaPlugin() { return (1..6).map { chars.random() }.joinToString("") } + fun getClientConnection(username: String): ClientConnection? { + return connectedClients.find { it.getClient()?.minecraftUsername == username } + } + fun generateAndSendLinkCode(player: Player) { val code = generateLinkCode() @@ -71,40 +67,87 @@ class MineChatServerPlugin : JavaPlugin() { val codeComponent = Component.text(code, NamedTextColor.DARK_AQUA) val timeComponent = Component.text("${expiryCodeMs / 60000} minutes", NamedTextColor.DARK_GREEN) player.sendRichMessage( - "Your link code is: . Use it in the client within .", + "Your link code is: . Use it in the client within ", Placeholder.component("code", codeComponent), - Placeholder.component("expiry_time", timeComponent) + Placeholder.component("deadline", timeComponent)) ) } fun registerCommands() { val linkCommand = Commands.literal("link") - .requires { sender -> sender.getExecutor() is Player } + .requires { sender -> sender.executor is Player } .executes { ctx -> - val sender = ctx.source.sender - generateAndSendLinkCode(sender as Player) + val sender = ctx.source.sender as Player + generateAndSendLinkCode(sender) Command.SINGLE_SUCCESS } .build() val reloadCommand = Commands.literal("mchatreload") - .requires { sender -> sender.getSender().hasPermission("minechat.reload") } + .requires { sender -> sender.sender.hasPermission("minechat.reload") } .executes { ctx -> - val sender = ctx.source.sender reloadConfig() port = config.getInt("port", 25575) expiryCodeMs = config.getInt("expiry-code-minutes", 5) * 60_000 - linkCodeStorage.load() - clientStorage.load() - sender.sendRichMessage("MineChat config and storage reloaded.") + ctx.source.sender.sendMessage(Component.text("MineChat config reloaded.").color(NamedTextColor.GREEN)) Command.SINGLE_SUCCESS } .build() - this.getLifecycleManager().registerEventHandler(LifecycleEvents.COMMANDS) { event -> + val banCommand = Commands.literal("minechat-ban") + .requires { sender -> sender.sender.hasPermission("minechat.ban") } + .then(Commands.argument("player", StringArgumentType.word()) + .executes { ctx -> + val playerName = StringArgumentType.getString(ctx, "player") + val client = clientStorage.find(null, playerName) + if (client == null) { + ctx.source.sender.sendMessage(Component.text("Player not found.").color(NamedTextColor.RED)) + return@executes 0 + } + val ban = Ban(minecraftUsername = playerName, reason = "Banned by an operator.") + banStorage.add(ban) + ctx.source.sender.sendMessage(Component.text("Banned $playerName from MineChat.").color(NamedTextColor.GREEN)) + Command.SINGLE_SUCCESS + } + ) + .build() + + val unbanCommand = Commands.literal("minechat-unban") + .requires { sender -> sender.sender.hasPermission("minechat.unban") } + .then(Commands.argument("player", StringArgumentType.word()) + .executes { ctx -> + val playerName = StringArgumentType.getString(ctx, "player") + banStorage.remove(null, playerName) + ctx.source.sender.sendMessage(Component.text("Unbanned $playerName from MineChat.").color(NamedTextColor.GREEN)) + Command.SINGLE_SUCCESS + } + ) + .build() + + val kickCommand = Commands.literal("minechat-kick") + .requires { sender -> sender.sender.hasPermission("minechat.kick") } + .then(Commands.argument("player", StringArgumentType.word()) + .executes { ctx -> + val playerName = StringArgumentType.getString(ctx, "player") + val clientConnection = getClientConnection(playerName) + if (clientConnection == null) { + ctx.source.sender.sendMessage(Component.text("Player not found or not connected via MineChat.").color(NamedTextColor.RED)) + return@executes 0 + } + clientConnection.disconnect("Kicked by an operator.") + ctx.source.sender.sendMessage(Component.text("Kicked $playerName from MineChat.").color(NamedTextColor.GREEN)) + Command.SINGLE_SUCCESS + } + ) + .build() + + this.lifecycleManager.registerEventHandler(LifecycleEvents.COMMANDS) { event -> val registrar = event.registrar() - registrar.register(linkCommand, "Link your Minecraft account to the server") - registrar.register(reloadCommand, "Reload MineChat's configuration") + registrar.register(linkCommand) + registrar.register(reloadCommand) + registrar.register(banCommand) + registrar.register(unbanCommand) + registrar.register(kickCommand) } } @@ -124,27 +167,45 @@ class MineChatServerPlugin : JavaPlugin() { dataFolder.mkdirs() - linkCodeStorage = LinkCodeStorage(dataFolder, gson) - clientStorage = ClientStorage(dataFolder, gson) - linkCodeStorage.load() - clientStorage.load() + boxStore = MyObjectBox.builder().directory(dataFolder).build() + linkCodeStorage = LinkCodeStorage(boxStore) + clientStorage = ClientStorage(boxStore) + banStorage = BanStorage(boxStore) registerCommands() - serverSocket = ServerSocket(port) - logger.info("Starting MineChat server on port $port") + val tlsEnabled = config.getBoolean("tls.enabled", false) - val saveTask = Runnable { - linkCodeStorage.cleanupExpired() - linkCodeStorage.save() - clientStorage.save() - } + if (tlsEnabled) { + val keystoreFile = File(dataFolder, config.getString("tls.keystore", "keystore.jks")) + val keystorePassword = config.getString("tls.keystore-password", "password")?.toCharArray() + + if (!keystoreFile.exists()) { + logger.severe("Keystore file not found at ${keystoreFile.absolutePath}. Disabling TLS.") + serverSocket = ServerSocket(port) + } else { + try { + val keyStore = KeyStore.getInstance("JKS") + keyStore.load(keystoreFile.inputStream(), keystorePassword) - if (isFolia) { - val scheduler = server.getAsyncScheduler() - scheduler.runAtFixedRate(this, { _ -> saveTask.run() }, 1, 1, TimeUnit.MINUTES) + val keyManagerFactory = KeyManagerFactory.getInstance(KeyManagerFactory.getDefaultAlgorithm()) + keyManagerFactory.init(keyStore, keystorePassword) + + val sslContext = SSLContext.getInstance("TLS") + sslContext.init(keyManagerFactory.keyManagers, null, null) + + val sslServerSocketFactory = sslContext.serverSocketFactory + serverSocket = sslServerSocketFactory.createServerSocket(port) + logger.info("MineChat server started with TLS on port $port") + } catch (e: Exception) { + logger.severe("Failed to initialize TLS: ${e.message}. Falling back to plain socket.") + e.printStackTrace() + serverSocket = ServerSocket(port) + } + } } else { - server.scheduler.runTaskTimer(this, saveTask, 0, 20 * 60) + serverSocket = ServerSocket(port) + logger.info("Starting MineChat server on port $port") } isServerRunning = true @@ -155,7 +216,7 @@ class MineChatServerPlugin : JavaPlugin() { val socket = serverSocket?.accept() if (socket != null) { logger.info("Client connected: ${socket.inetAddress}") - val connection = ClientConnection(socket, this, gson, miniMessage) + val connection = ClientConnection(socket, this, miniMessage) connectedClients.add(connection) executorService.submit(connection) } @@ -176,10 +237,10 @@ class MineChatServerPlugin : JavaPlugin() { "type" to "BROADCAST", "payload" to mapOf( "from" to event.player.name, - "message" to plainMsg + "message" to mapOf("text" to plainMsg) ) ) - broadcastToClients(gson.toJson(message)) + broadcastToClients(message) } }, this) } @@ -188,15 +249,14 @@ class MineChatServerPlugin : JavaPlugin() { isServerRunning = false serverThread?.interrupt() serverSocket?.close() - connectedClients.forEach { it.close() } + connectedClients.forEach { it.disconnect("Server is shutting down.") } executorService.shutdownNow() try { executorService.awaitTermination(10, TimeUnit.SECONDS) } catch (e: InterruptedException) { Thread.currentThread().interrupt() } - linkCodeStorage.save() - clientStorage.save() + boxStore.close() try { serverThread?.join() } catch (e: InterruptedException) { @@ -204,7 +264,7 @@ class MineChatServerPlugin : JavaPlugin() { } } - fun broadcastToClients(message: String) { + fun broadcastToClients(message: Map) { connectedClients.forEach { client -> try { client.sendMessage(message) @@ -217,19 +277,6 @@ class MineChatServerPlugin : JavaPlugin() { fun getLinkCodeStorage(): LinkCodeStorage = linkCodeStorage fun getClientStorage(): ClientStorage = clientStorage + fun getBanStorage(): BanStorage = banStorage fun removeClient(client: ClientConnection) = connectedClients.remove(client) } - -data class LinkCode( - val code: String, - val minecraftUuid: UUID, - val minecraftUsername: String, - val expiresAt: Long -) - -data class Client( - val clientUuid: String, - val minecraftUuid: UUID, - val minecraftUsername: String -) - diff --git a/src/main/kotlin/org/winlogon/minechat/UuidConverter.kt b/src/main/kotlin/org/winlogon/minechat/UuidConverter.kt new file mode 100644 index 0000000..5cbd83e --- /dev/null +++ b/src/main/kotlin/org/winlogon/minechat/UuidConverter.kt @@ -0,0 +1,14 @@ +package org.winlogon.minechat + +import io.objectbox.converter.PropertyConverter +import java.util.UUID + +class UuidConverter : PropertyConverter { + override fun convertToEntityProperty(databaseValue: String?): UUID? { + return databaseValue?.let { UUID.fromString(it) } + } + + override fun convertToDatabaseValue(entityProperty: UUID?): String? { + return entityProperty?.toString() + } +} From 11347309d3209e19adce007964a8ba13b2f3ea03 Mon Sep 17 00:00:00 2001 From: winlogon Date: Tue, 28 Oct 2025 03:20:13 +0100 Subject: [PATCH 06/30] im told to keep this file by objectbox so ill do it --- objectbox-models/default.json | 120 ++++++++++++++++++++++++++++++++++ 1 file changed, 120 insertions(+) create mode 100644 objectbox-models/default.json diff --git a/objectbox-models/default.json b/objectbox-models/default.json new file mode 100644 index 0000000..73b361a --- /dev/null +++ b/objectbox-models/default.json @@ -0,0 +1,120 @@ +{ + "_note1": "KEEP THIS FILE! Check it into a version control system (VCS) like git.", + "_note2": "ObjectBox manages crucial IDs for your object model. See docs for details.", + "_note3": "If you have VCS merge conflicts, you must resolve them according to ObjectBox docs.", + "entities": [ + { + "id": "1:3958641807978990121", + "lastPropertyId": "5:885808597397071819", + "name": "LinkCode", + "properties": [ + { + "id": "1:8578775582616557464", + "name": "id", + "type": 6, + "flags": 1 + }, + { + "id": "2:5211271274410806493", + "name": "code", + "type": 9 + }, + { + "id": "3:8525598388342322678", + "name": "minecraftUuid", + "type": 9 + }, + { + "id": "4:1268060968951354113", + "name": "minecraftUsername", + "type": 9 + }, + { + "id": "5:885808597397071819", + "name": "expiresAt", + "type": 6 + } + ], + "relations": [] + }, + { + "id": "2:6624657809806865546", + "lastPropertyId": "6:5571998185572009749", + "name": "Ban", + "properties": [ + { + "id": "1:8712240317965336909", + "name": "id", + "type": 6, + "flags": 1 + }, + { + "id": "2:6941306988557758874", + "name": "clientUuid", + "type": 9 + }, + { + "id": "3:6976374136811816221", + "name": "minecraftUuid", + "type": 9 + }, + { + "id": "4:6711419724093205395", + "name": "minecraftUsername", + "type": 9 + }, + { + "id": "5:1077476954252784310", + "name": "reason", + "type": 9 + }, + { + "id": "6:5571998185572009749", + "name": "timestamp", + "type": 6 + } + ], + "relations": [] + }, + { + "id": "3:2018865171595602336", + "lastPropertyId": "4:516704796771639505", + "name": "Client", + "properties": [ + { + "id": "1:1003009403954781106", + "name": "id", + "type": 6, + "flags": 1 + }, + { + "id": "2:1169755582415132442", + "name": "clientUuid", + "type": 9 + }, + { + "id": "3:7655496526742702577", + "name": "minecraftUuid", + "type": 9 + }, + { + "id": "4:516704796771639505", + "name": "minecraftUsername", + "type": 9 + } + ], + "relations": [] + } + ], + "lastEntityId": "3:2018865171595602336", + "lastIndexId": "0:0", + "lastRelationId": "0:0", + "lastSequenceId": "0:0", + "modelVersion": 5, + "modelVersionParserMinimum": 5, + "retiredEntityUids": [], + "retiredIndexUids": [], + "retiredPropertyUids": [], + "retiredRelationUids": [], + "version": 1 +} \ No newline at end of file From b963ebd9b6b4bbbe1315e72df9f8828f008ede2b Mon Sep 17 00:00:00 2001 From: winlogon Date: Tue, 28 Oct 2025 03:26:19 +0100 Subject: [PATCH 07/30] fix: *sigh* how did I let this happen --- src/main/kotlin/org/winlogon/minechat/MineChatServerPlugin.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/kotlin/org/winlogon/minechat/MineChatServerPlugin.kt b/src/main/kotlin/org/winlogon/minechat/MineChatServerPlugin.kt index 7c710c3..f269006 100644 --- a/src/main/kotlin/org/winlogon/minechat/MineChatServerPlugin.kt +++ b/src/main/kotlin/org/winlogon/minechat/MineChatServerPlugin.kt @@ -69,7 +69,7 @@ class MineChatServerPlugin : JavaPlugin() { player.sendRichMessage( "Your link code is: . Use it in the client within ", Placeholder.component("code", codeComponent), - Placeholder.component("deadline", timeComponent)) + Placeholder.component("deadline", timeComponent) ) } From 3699a391f6a27c5667c5054947584627e0ee5b88 Mon Sep 17 00:00:00 2001 From: winlogon Date: Tue, 28 Oct 2025 03:38:16 +0100 Subject: [PATCH 08/30] feat: add kotlinx.serialization for config --- build.gradle.kts | 3 ++ gradle/libs.versions.toml | 4 ++ .../org/winlogon/minechat/MineChatConfig.kt | 11 ++++++ .../winlogon/minechat/MineChatServerPlugin.kt | 39 ++++++++++++------- 4 files changed, 42 insertions(+), 15 deletions(-) create mode 100644 src/main/kotlin/org/winlogon/minechat/MineChatConfig.kt diff --git a/build.gradle.kts b/build.gradle.kts index a6c2238..6f532ac 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -14,6 +14,7 @@ buildscript { plugins { id("com.gradleup.shadow") version "8.3.6" kotlin("jvm") version "2.1.10" + kotlin("plugin.serialization") version "2.1.10" } apply(plugin = "io.objectbox") @@ -92,6 +93,8 @@ dependencies { compileOnly("com.fasterxml.jackson.dataformat:jackson-dataformat-cbor:2.17.1") compileOnly("com.github.luben:zstd-jni:1.5.6-1") compileOnly("com.fasterxml.jackson.module:jackson-module-kotlin:2.17.1") + implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:" + libs.versions.kotlinx.serialization.json.get()) + implementation("com.charleskorn.kaml:kaml:" + libs.versions.kaml.get()) testRuntimeOnly("org.junit.platform:junit-platform-launcher:1.11.4") testImplementation("io.papermc.paper:paper-api:1.21.6-R0.1-SNAPSHOT") diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 4ac3234..8067894 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,2 +1,6 @@ # This file was generated by the Gradle 'init' task. # https://docs.gradle.org/current/userguide/platforms.html#sub::toml-dependencies-format + +[versions] +kotlinx-serialization-json = "1.9.0" +kaml = "0.102.0" \ No newline at end of file diff --git a/src/main/kotlin/org/winlogon/minechat/MineChatConfig.kt b/src/main/kotlin/org/winlogon/minechat/MineChatConfig.kt new file mode 100644 index 0000000..72a60f5 --- /dev/null +++ b/src/main/kotlin/org/winlogon/minechat/MineChatConfig.kt @@ -0,0 +1,11 @@ +package org.winlogon.minechat + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +data class MineChatConfig( + val port: Int = 25575, + @SerialName("expiry-code-minutes") + val expiryCodeMinutes: Int = 5 +) diff --git a/src/main/kotlin/org/winlogon/minechat/MineChatServerPlugin.kt b/src/main/kotlin/org/winlogon/minechat/MineChatServerPlugin.kt index f269006..3a9d759 100644 --- a/src/main/kotlin/org/winlogon/minechat/MineChatServerPlugin.kt +++ b/src/main/kotlin/org/winlogon/minechat/MineChatServerPlugin.kt @@ -28,6 +28,9 @@ import java.util.concurrent.TimeUnit import javax.net.ssl.KeyManagerFactory import javax.net.ssl.SSLContext +import com.charleskorn.kaml.Yaml +import kotlinx.serialization.decodeFromString + class MineChatServerPlugin : JavaPlugin() { private var serverSocket: ServerSocket? = null private val connectedClients = ConcurrentLinkedQueue() @@ -37,13 +40,22 @@ class MineChatServerPlugin : JavaPlugin() { private lateinit var boxStore: BoxStore private var isFolia = false - private var port: Int = 25575 - private var expiryCodeMs = 300_000 // 5 minutes + private lateinit var mineChatConfig: MineChatConfig private var serverThread: Thread? = null @Volatile private var isServerRunning = false private val executorService = Executors.newVirtualThreadPerTaskExecutor() val miniMessage = MiniMessage.miniMessage() + private fun loadConfig(): MineChatConfig { + val configFile = java.io.File(dataFolder, "config.yml") + return try { + Yaml.default.decodeFromString(configFile.readText()) + } catch (e: Exception) { + logger.severe("Failed to load config.yml: ${e.message}. Using default config.") + MineChatConfig() + } + } + private fun generateLinkCode(): String { val chars = ('A'..'Z') + ('0'..'9') return (1..6).map { chars.random() }.joinToString("") @@ -60,12 +72,12 @@ class MineChatServerPlugin : JavaPlugin() { code = code, minecraftUuid = player.uniqueId, minecraftUsername = player.name, - expiresAt = System.currentTimeMillis() + expiryCodeMs + expiresAt = System.currentTimeMillis() + (mineChatConfig.expiryCodeMinutes * 60_000L) ) linkCodeStorage.add(link) val codeComponent = Component.text(code, NamedTextColor.DARK_AQUA) - val timeComponent = Component.text("${expiryCodeMs / 60000} minutes", NamedTextColor.DARK_GREEN) + val timeComponent = Component.text("${mineChatConfig.expiryCodeMinutes} minutes", NamedTextColor.DARK_GREEN) player.sendRichMessage( "Your link code is: . Use it in the client within ", Placeholder.component("code", codeComponent), @@ -87,8 +99,7 @@ class MineChatServerPlugin : JavaPlugin() { .requires { sender -> sender.sender.hasPermission("minechat.reload") } .executes { ctx -> reloadConfig() - port = config.getInt("port", 25575) - expiryCodeMs = config.getInt("expiry-code-minutes", 5) * 60_000 + mineChatConfig = loadConfig() ctx.source.sender.sendMessage(Component.text("MineChat config reloaded.").color(NamedTextColor.GREEN)) Command.SINGLE_SUCCESS } @@ -161,9 +172,7 @@ class MineChatServerPlugin : JavaPlugin() { saveResource("config.yml", false) reloadConfig() - - port = config.getInt("port", 25575) - expiryCodeMs = config.getInt("expiry-code-minutes", 5) * 60_000 + mineChatConfig = loadConfig() dataFolder.mkdirs() @@ -182,7 +191,7 @@ class MineChatServerPlugin : JavaPlugin() { if (!keystoreFile.exists()) { logger.severe("Keystore file not found at ${keystoreFile.absolutePath}. Disabling TLS.") - serverSocket = ServerSocket(port) + serverSocket = ServerSocket(mineChatConfig.port) } else { try { val keyStore = KeyStore.getInstance("JKS") @@ -195,17 +204,17 @@ class MineChatServerPlugin : JavaPlugin() { sslContext.init(keyManagerFactory.keyManagers, null, null) val sslServerSocketFactory = sslContext.serverSocketFactory - serverSocket = sslServerSocketFactory.createServerSocket(port) - logger.info("MineChat server started with TLS on port $port") + serverSocket = sslServerSocketFactory.createServerSocket(mineChatConfig.port) + logger.info("MineChat server started with TLS on port ${mineChatConfig.port}") } catch (e: Exception) { logger.severe("Failed to initialize TLS: ${e.message}. Falling back to plain socket.") e.printStackTrace() - serverSocket = ServerSocket(port) + serverSocket = ServerSocket(mineChatConfig.port) } } } else { - serverSocket = ServerSocket(port) - logger.info("Starting MineChat server on port $port") + serverSocket = ServerSocket(mineChatConfig.port) + logger.info("Starting MineChat server on port ${mineChatConfig.port}") } isServerRunning = true From 96deaa3e9474fbd31572614d5b5ef205d63831e2 Mon Sep 17 00:00:00 2001 From: winlogon Date: Tue, 23 Dec 2025 08:06:01 +0100 Subject: [PATCH 09/30] chore: implement TLS configuration properly + add Caffeine Changes include following the MineChat v1.0 specification --- build.gradle.kts | 19 +- gradle/libs.versions.toml | 6 +- objectbox-models/default.json | 7 +- objectbox-models/default.json.bak | 120 ++++++++++ .../kotlin/org/winlogon/minechat/Client.kt | 5 +- .../org/winlogon/minechat/ClientConnection.kt | 226 +++++++++--------- .../org/winlogon/minechat/ClientStorage.kt | 31 ++- .../org/winlogon/minechat/LinkCodeStorage.kt | 14 +- .../org/winlogon/minechat/MineChatConfig.kt | 11 +- .../org/winlogon/minechat/MineChatLoader.kt | 3 +- .../winlogon/minechat/MineChatServerPlugin.kt | 84 ++++--- .../kotlin/org/winlogon/minechat/Protocol.kt | 72 ++++++ src/main/resources/config.yml | 10 + 13 files changed, 432 insertions(+), 176 deletions(-) create mode 100644 objectbox-models/default.json.bak create mode 100644 src/main/kotlin/org/winlogon/minechat/Protocol.kt diff --git a/build.gradle.kts b/build.gradle.kts index 6f532ac..74dc97f 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -84,18 +84,23 @@ repositories { } dependencies { - compileOnly("io.papermc.paper:paper-api:1.21.6-R0.1-SNAPSHOT") - compileOnly("org.winlogon:asynccraftr:0.1.0") compileOnly("com.github.ben-manes.caffeine:caffeine:3.2.0") - compileOnly("com.google.code.gson:gson:2.11.0") + compileOnly("com.github.luben:zstd-jni:1.5.6-1") + // idt this is needed + // compileOnly("com.google.code.gson:gson:2.11.0") compileOnly("io.objectbox:objectbox-kotlin:3.8.0") + compileOnly("io.papermc.paper:paper-api:1.21.6-R0.1-SNAPSHOT") + // is this needed? compileOnly("org.jetbrains.kotlin:kotlin-reflect:2.1.10") - compileOnly("com.fasterxml.jackson.dataformat:jackson-dataformat-cbor:2.17.1") - compileOnly("com.github.luben:zstd-jni:1.5.6-1") - compileOnly("com.fasterxml.jackson.module:jackson-module-kotlin:2.17.1") - implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:" + libs.versions.kotlinx.serialization.json.get()) + compileOnly("org.winlogon:asynccraftr:0.1.0") implementation("com.charleskorn.kaml:kaml:" + libs.versions.kaml.get()) + // Jackson + implementation("com.fasterxml.jackson.dataformat:jackson-dataformat-cbor:2.17.1") + implementation("com.fasterxml.jackson.module:jackson-module-kotlin:2.17.1") + + implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:" + libs.versions.kotlinx.serialization.json.get()) + testRuntimeOnly("org.junit.platform:junit-platform-launcher:1.11.4") testImplementation("io.papermc.paper:paper-api:1.21.6-R0.1-SNAPSHOT") testImplementation("org.junit.jupiter:junit-jupiter:5.11.4") diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 8067894..6401bb1 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,6 +1,4 @@ -# This file was generated by the Gradle 'init' task. -# https://docs.gradle.org/current/userguide/platforms.html#sub::toml-dependencies-format - [versions] kotlinx-serialization-json = "1.9.0" -kaml = "0.102.0" \ No newline at end of file +kaml = "0.102.0" +caffeine = "3.1.8" diff --git a/objectbox-models/default.json b/objectbox-models/default.json index 73b361a..abd62da 100644 --- a/objectbox-models/default.json +++ b/objectbox-models/default.json @@ -78,7 +78,7 @@ }, { "id": "3:2018865171595602336", - "lastPropertyId": "4:516704796771639505", + "lastPropertyId": "5:6963362824776171892", "name": "Client", "properties": [ { @@ -101,6 +101,11 @@ "id": "4:516704796771639505", "name": "minecraftUsername", "type": 9 + }, + { + "id": "5:6963362824776171892", + "name": "supportsComponents", + "type": 1 } ], "relations": [] diff --git a/objectbox-models/default.json.bak b/objectbox-models/default.json.bak new file mode 100644 index 0000000..73b361a --- /dev/null +++ b/objectbox-models/default.json.bak @@ -0,0 +1,120 @@ +{ + "_note1": "KEEP THIS FILE! Check it into a version control system (VCS) like git.", + "_note2": "ObjectBox manages crucial IDs for your object model. See docs for details.", + "_note3": "If you have VCS merge conflicts, you must resolve them according to ObjectBox docs.", + "entities": [ + { + "id": "1:3958641807978990121", + "lastPropertyId": "5:885808597397071819", + "name": "LinkCode", + "properties": [ + { + "id": "1:8578775582616557464", + "name": "id", + "type": 6, + "flags": 1 + }, + { + "id": "2:5211271274410806493", + "name": "code", + "type": 9 + }, + { + "id": "3:8525598388342322678", + "name": "minecraftUuid", + "type": 9 + }, + { + "id": "4:1268060968951354113", + "name": "minecraftUsername", + "type": 9 + }, + { + "id": "5:885808597397071819", + "name": "expiresAt", + "type": 6 + } + ], + "relations": [] + }, + { + "id": "2:6624657809806865546", + "lastPropertyId": "6:5571998185572009749", + "name": "Ban", + "properties": [ + { + "id": "1:8712240317965336909", + "name": "id", + "type": 6, + "flags": 1 + }, + { + "id": "2:6941306988557758874", + "name": "clientUuid", + "type": 9 + }, + { + "id": "3:6976374136811816221", + "name": "minecraftUuid", + "type": 9 + }, + { + "id": "4:6711419724093205395", + "name": "minecraftUsername", + "type": 9 + }, + { + "id": "5:1077476954252784310", + "name": "reason", + "type": 9 + }, + { + "id": "6:5571998185572009749", + "name": "timestamp", + "type": 6 + } + ], + "relations": [] + }, + { + "id": "3:2018865171595602336", + "lastPropertyId": "4:516704796771639505", + "name": "Client", + "properties": [ + { + "id": "1:1003009403954781106", + "name": "id", + "type": 6, + "flags": 1 + }, + { + "id": "2:1169755582415132442", + "name": "clientUuid", + "type": 9 + }, + { + "id": "3:7655496526742702577", + "name": "minecraftUuid", + "type": 9 + }, + { + "id": "4:516704796771639505", + "name": "minecraftUsername", + "type": 9 + } + ], + "relations": [] + } + ], + "lastEntityId": "3:2018865171595602336", + "lastIndexId": "0:0", + "lastRelationId": "0:0", + "lastSequenceId": "0:0", + "modelVersion": 5, + "modelVersionParserMinimum": 5, + "retiredEntityUids": [], + "retiredIndexUids": [], + "retiredPropertyUids": [], + "retiredRelationUids": [], + "version": 1 +} \ No newline at end of file diff --git a/src/main/kotlin/org/winlogon/minechat/Client.kt b/src/main/kotlin/org/winlogon/minechat/Client.kt index 963f37d..d3f0e79 100644 --- a/src/main/kotlin/org/winlogon/minechat/Client.kt +++ b/src/main/kotlin/org/winlogon/minechat/Client.kt @@ -11,5 +11,6 @@ data class Client( var clientUuid: String = "", @Convert(converter = UuidConverter::class, dbType = String::class) var minecraftUuid: UUID? = null, - var minecraftUsername: String = "" -) \ No newline at end of file + var minecraftUsername: String = "", + var supportsComponents: Boolean = false +) diff --git a/src/main/kotlin/org/winlogon/minechat/ClientConnection.kt b/src/main/kotlin/org/winlogon/minechat/ClientConnection.kt index 9b5f7ce..e536f84 100644 --- a/src/main/kotlin/org/winlogon/minechat/ClientConnection.kt +++ b/src/main/kotlin/org/winlogon/minechat/ClientConnection.kt @@ -13,7 +13,9 @@ import net.kyori.adventure.text.serializer.gson.GsonComponentSerializer import net.kyori.adventure.text.serializer.legacy.LegacyComponentSerializer import org.bukkit.Bukkit +import org.bukkit.plugin.java.JavaPlugin.getPlugin +import java.util.logging.Logger import java.io.DataInputStream import java.io.DataOutputStream import java.net.Socket @@ -23,6 +25,7 @@ class ClientConnection( private val plugin: MineChatServerPlugin, private val miniMessage: MiniMessage ) : Runnable { + private val logger = plugin.logger object ChatGradients { val JOIN = Pair("#27AE60", "#2ECC71") val LEAVE = Pair("#C0392B", "#E74C3C") @@ -32,8 +35,7 @@ class ClientConnection( companion object { const val MINECHAT_PREFIX_STRING = "&8[&3MineChat&8]" - val MINECHAT_PREFIX_COMPONENT: Component = - LegacyComponentSerializer.legacyAmpersand().deserialize(MINECHAT_PREFIX_STRING) + val MINECHAT_PREFIX_COMPONENT: Component = LegacyComponentSerializer.legacyAmpersand().deserialize(MINECHAT_PREFIX_STRING) } private val cborMapper = ObjectMapper(CBORFactory()).registerModule(KotlinModule.Builder().build()) @@ -52,22 +54,62 @@ class ClientConnection( override fun run() { try { - while (running) { - val decompressedLen = reader.readInt() - if (decompressedLen <= 0) continue - val compressedLen = reader.readInt() - if (compressedLen <= 0) continue + while (running) { + val decompressedLen = reader.readInt() + if (decompressedLen <= 0) { + logger.warning("Received non-positive decompressed length: $decompressedLen. Terminating connection.") + break // Terminate connection + } + logger.fine("Received decompressedLen: $decompressedLen") + + val compressedLen = reader.readInt() + if (compressedLen <= 0) { + logger.warning("Received non-positive compressed length: $compressedLen. Terminating connection.") + break // Terminate connection + } + logger.fine("Received compressedLen: $compressedLen") + val compressed = ByteArray(compressedLen) reader.readFully(compressed) val decompressed = Zstd.decompress(compressed, decompressedLen) - val json = cborMapper.readValue(decompressed, Map::class.java) as Map + if (decompressed.size != decompressedLen) { + logger.warning("Decompressed size mismatch. Expected $decompressedLen, got ${decompressed.size}. Terminating connection.") + break // Terminate connection + } + - when (json["type"] as String) { - "AUTH" -> handleAuth(json["payload"] as Map) - "CHAT" -> handleChat(json["payload"] as Map) - "DISCONNECT" -> break + val mineChatPacket = cborMapper.readValue(decompressed, MineChatPacket::class.java) + logger.fine("Received MineChatPacket: $mineChatPacket") + + when (mineChatPacket.packetType) { + PacketTypes.LINK -> { + val payload = cborMapper.convertValue(mineChatPacket.payload, LinkPayload::class.java) + logger.fine("Received LINK message: $payload") + handleAuth(payload) + } + PacketTypes.CAPABILITIES -> { + val payload = cborMapper.convertValue(mineChatPacket.payload, CapabilitiesPayload::class.java) + logger.fine("Received CAPABILITIES message: $payload") + handleCapabilities(payload) + } + PacketTypes.CHAT_MESSAGE -> { + val payload = cborMapper.convertValue(mineChatPacket.payload, ChatMessagePayload::class.java) + logger.fine("Received CHAT_MESSAGE message: $payload") + handleChat(payload) + } + PacketTypes.PING -> { + val payload = cborMapper.convertValue(mineChatPacket.payload, PingPayload::class.java) + logger.fine("Received PING message: $payload") + handlePing(payload) + } + PacketTypes.PONG -> { + val payload = cborMapper.convertValue(mineChatPacket.payload, PongPayload::class.java) + logger.fine("Received PONG message: $payload") + handlePong(payload) + } + else -> plugin.logger.warning("Unknown packet type: ${mineChatPacket.packetType}") } } } catch (e: Exception) { @@ -75,16 +117,6 @@ class ClientConnection( } finally { client?.let { broadcastMinecraft(ChatGradients.LEAVE, "${it.minecraftUsername} has left the chat.") - plugin.broadcastToClients( - mapOf( - "type" to "SYSTEM", - "payload" to mapOf( - "event" to "leave", - "username" to it.minecraftUsername, - "message" to "${it.minecraftUsername} has left the chat." - ) - ) - ) } close() plugin.removeClient(this) @@ -92,23 +124,23 @@ class ClientConnection( } private fun sendBannedMessage(ban: Ban) { + logger.fine("Sending banned message to client: $ban") disconnect(ban.reason ?: "You are banned from MineChat.") } fun disconnect(reason: String) { - sendMessage( - mapOf( - "type" to "DISCONNECT", - "payload" to mapOf("reason" to reason) - ) - ) + sendMessage(PacketTypes.DISCONNECT, DisconnectPayload(reason)) close() } - private fun handleAuth(payload: Map) { + private fun handleAuth(payload: LinkPayload) { + + logger.fine("Handling auth with payload: $payload") + + val banStorage = plugin.getBanStorage() - val clientUuid = payload["client_uuid"] as String - val linkCode = payload["link_code"] as String + val clientUuid = payload.clientUuid + val linkCode = payload.linkingCode var ban = banStorage.getBan(clientUuid, null) if (ban != null) { @@ -129,53 +161,21 @@ class ClientConnection( plugin.getClientStorage().add(client) plugin.getLinkCodeStorage().remove(link.code) this.client = client - sendMessage( - mapOf( - "type" to "AUTH_ACK", - "payload" to mapOf( - "status" to "success", - "message" to "Linked to ${link.minecraftUsername}", - "minecraft_uuid" to link.minecraftUuid.toString(), - "username" to link.minecraftUsername - ) - ) + val linkOkPayload = LinkOkPayload( + minecraftUuid = link.minecraftUuid.toString() ) + sendMessage(PacketTypes.LINK_OK, linkOkPayload) + sendMessage(PacketTypes.AUTH_OK, AuthOkPayload()) broadcastMinecraft( ChatGradients.AUTH, "${link.minecraftUsername} has successfully authenticated." ) - plugin.broadcastToClients( - mapOf( - "type" to "SYSTEM", - "payload" to mapOf( - "event" to "join", - "username" to link.minecraftUsername, - "message" to "${link.minecraftUsername} has joined the chat." - ) - ) - ) + } else { - sendMessage( - mapOf( - "type" to "AUTH_ACK", - "payload" to mapOf( - "status" to "failure", - "message" to "Invalid or expired link code" - ) - ) - ) + disconnect("Invalid or expired link code") } } else { - sendMessage( - mapOf( - "type" to "AUTH_ACK", - "payload" to mapOf( - "status" to "failure", - "message" to "Invalid or expired link code" - ) - ) - ) - } + disconnect("Invalid or expired link code") } } else { val client = plugin.getClientStorage().find(clientUuid, null) if (client != null) { @@ -185,47 +185,21 @@ class ClientConnection( return } this.client = client - sendMessage( - mapOf( - "type" to "AUTH_ACK", - "payload" to mapOf( - "status" to "success", - "message" to "Welcome back, ${client.minecraftUsername}", - "minecraft_uuid" to client.minecraftUuid.toString(), - "username" to client.minecraftUsername - ) - ) - ) - broadcastMinecraft(ChatGradients.JOIN, "${client.minecraftUsername} has joined the chat.") - plugin.broadcastToClients( - mapOf( - "type" to "SYSTEM", - "payload" to mapOf( - "event" to "join", - "username" to client.minecraftUsername, - "message" to "${client.minecraftUsername} has joined the chat." - ) - ) + sendMessage(PacketTypes.AUTH_OK, AuthOkPayload()) + broadcastMinecraft( + ChatGradients.JOIN, + "${client.minecraftUsername} has joined the chat." ) } else { - sendMessage( - mapOf( - "type" to "AUTH_ACK", - "payload" to mapOf( - "status" to "failure", - "message" to "Client not registered" - ) - ) - ) + disconnect("Client not registered") } } } - private fun handleChat(payload: Map) { + private fun handleChat(payload: ChatMessagePayload) { client?.let { - val messageJson = cborMapper.writeValueAsString(payload["message"]) - val message = GsonComponentSerializer.gson().deserialize(messageJson) - + // Check payload.format here if needed. Assuming "commonmark" for now. + val message = miniMessage.deserialize(payload.content) // Deserialize the content string to an Adventure Component val usernamePlaceholder = Component.text(it.minecraftUsername, NamedTextColor.DARK_GREEN) val formattedMsg = miniMessage.deserialize( ": ", @@ -234,21 +208,45 @@ class ClientConnection( ) val finalMsg = formatPrefixed(formattedMsg) Bukkit.broadcast(finalMsg) - plugin.broadcastToClients( - mapOf( - "type" to "BROADCAST", - "payload" to mapOf( - "from" to "[MineChat] ${it.minecraftUsername}", - "message" to payload["message"]!! - ) - ) + val chatMessagePayload = ChatMessagePayload( + format = "commonmark", + content = miniMessage.serialize(message) ) + plugin.broadcastToClients(PacketTypes.CHAT_MESSAGE, chatMessagePayload) } } - fun sendMessage(message: Map) { + private fun handleCapabilities(payload: CapabilitiesPayload) { + client?.let { + it.supportsComponents = payload.supportsComponents + plugin.getClientStorage().add(it) // Update the client in storage + logger.fine("Client ${it.minecraftUsername} updated with capabilities: supportsComponents=${it.supportsComponents}") + } ?: run { + logger.warning("Received CAPABILITIES packet before client was authenticated. Disconnecting.") + disconnect("Received CAPABILITIES before authentication.") + } + } + + private fun handlePing(payload: PingPayload) { + // Respond with a PONG packet, echoing the timestamp + sendMessage(PacketTypes.PONG, PongPayload(payload.timestampMs)) + logger.fine("Responded to PING from client with timestamp ${payload.timestampMs}") + } + + private fun handlePong(payload: PongPayload) { + // For now, just log that a PONG was received. + // In a more advanced implementation, this would be used for RTT calculation. + logger.fine("Received PONG from client with timestamp ${payload.timestampMs}") + } + + fun sendMessage(packetType: Int, payload: Any) { + logger.fine("Sending packet type $packetType with payload: $payload") try { - val serialized = cborMapper.writeValueAsBytes(message) + // Convert the payload object to a Map + val payloadMap = cborMapper.convertValue(payload, Map::class.java) as Map + val mineChatPacket = MineChatPacket(packetType, payloadMap) + + val serialized = cborMapper.writeValueAsBytes(mineChatPacket) val compressed = Zstd.compress(serialized) writer.writeInt(serialized.size) writer.writeInt(compressed.size) diff --git a/src/main/kotlin/org/winlogon/minechat/ClientStorage.kt b/src/main/kotlin/org/winlogon/minechat/ClientStorage.kt index a34d094..10f9b7d 100644 --- a/src/main/kotlin/org/winlogon/minechat/ClientStorage.kt +++ b/src/main/kotlin/org/winlogon/minechat/ClientStorage.kt @@ -1,17 +1,28 @@ package org.winlogon.minechat +import com.github.benmanes.caffeine.cache.Cache +import com.github.benmanes.caffeine.cache.Caffeine import io.objectbox.Box import io.objectbox.BoxStore class ClientStorage(boxStore: BoxStore) { private val clientBox: Box = boxStore.boxFor(Client::class.java) + private val clientCache: Cache = Caffeine.newBuilder().build() fun find(clientUuid: String?, minecraftUsername: String?): Client? { if (clientUuid != null) { - return clientBox.query(Client_.clientUuid.equal(clientUuid)).build().findFirst() + return clientCache.getIfPresent(clientUuid) ?: run { + val client = clientBox.query(Client_.clientUuid.equal(clientUuid)).build().findFirst() + client?.let { clientCache.put(clientUuid, it) } + client + } } if (minecraftUsername != null) { - return clientBox.query(Client_.minecraftUsername.equal(minecraftUsername)).build().findFirst() + return clientCache.asMap().values.find { it.minecraftUsername == minecraftUsername } ?: run { + val client = clientBox.query(Client_.minecraftUsername.equal(minecraftUsername)).build().findFirst() + client?.let { clientCache.put(it.clientUuid, it) } + client + } } return null } @@ -23,8 +34,24 @@ class ClientStorage(boxStore: BoxStore) { // Update existing client's uuid existing.clientUuid = client.clientUuid clientBox.put(existing) + clientCache.put(existing.clientUuid, existing) } else { clientBox.put(client) + clientCache.put(client.clientUuid, client) + } + } + + fun remove(clientUuid: String?, minecraftUsername: String?) { + if (clientUuid != null) { + clientCache.invalidate(clientUuid) + clientBox.query(Client_.clientUuid.equal(clientUuid)).build().remove() + } + if (minecraftUsername != null) { + val client = find(null, minecraftUsername) + if (client != null) { + clientCache.invalidate(client.clientUuid) + clientBox.remove(client.id) + } } } } diff --git a/src/main/kotlin/org/winlogon/minechat/LinkCodeStorage.kt b/src/main/kotlin/org/winlogon/minechat/LinkCodeStorage.kt index c813eae..b6ddb24 100644 --- a/src/main/kotlin/org/winlogon/minechat/LinkCodeStorage.kt +++ b/src/main/kotlin/org/winlogon/minechat/LinkCodeStorage.kt @@ -1,5 +1,7 @@ package org.winlogon.minechat +import com.github.benmanes.caffeine.cache.Cache +import com.github.benmanes.caffeine.cache.Caffeine import io.objectbox.Box import io.objectbox.BoxStore import java.util.concurrent.Executors @@ -8,6 +10,9 @@ import java.util.concurrent.TimeUnit class LinkCodeStorage(private val boxStore: BoxStore) { private val linkCodeBox: Box = boxStore.boxFor(LinkCode::class.java) private val scheduler = Executors.newSingleThreadScheduledExecutor() + private val linkCodeCache: Cache = Caffeine.newBuilder() + .expireAfterWrite(5, TimeUnit.MINUTES) + .build() init { // Schedule cleanup of expired link codes every minute @@ -18,19 +23,26 @@ class LinkCodeStorage(private val boxStore: BoxStore) { fun add(linkCode: LinkCode) { linkCodeBox.put(linkCode) + linkCodeCache.put(linkCode.code, linkCode) } fun find(code: String): LinkCode? { - return linkCodeBox.query(LinkCode_.code.equal(code)).build().findFirst() + return linkCodeCache.getIfPresent(code) ?: run { + val linkCode = linkCodeBox.query(LinkCode_.code.equal(code)).build().findFirst() + linkCode?.let { linkCodeCache.put(code, it) } + linkCode + } } fun remove(code: String) { + linkCodeCache.invalidate(code) linkCodeBox.query(LinkCode_.code.equal(code)).build().remove() } fun cleanupExpired() { val now = System.currentTimeMillis() linkCodeBox.query(LinkCode_.expiresAt.less(now)).build().remove() + linkCodeCache.asMap().values.removeIf { it.expiresAt < now } } fun close() { diff --git a/src/main/kotlin/org/winlogon/minechat/MineChatConfig.kt b/src/main/kotlin/org/winlogon/minechat/MineChatConfig.kt index 72a60f5..371a212 100644 --- a/src/main/kotlin/org/winlogon/minechat/MineChatConfig.kt +++ b/src/main/kotlin/org/winlogon/minechat/MineChatConfig.kt @@ -3,9 +3,18 @@ package org.winlogon.minechat import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable +@Serializable +data class TlsConfig( + val enabled: Boolean = false, + val keystore: String = "keystore.jks", + @SerialName("keystore-password") + val keystorePassword: String = "password" +) + @Serializable data class MineChatConfig( val port: Int = 25575, @SerialName("expiry-code-minutes") - val expiryCodeMinutes: Int = 5 + val expiryCodeMinutes: Int = 5, + val tls: TlsConfig = TlsConfig() ) diff --git a/src/main/kotlin/org/winlogon/minechat/MineChatLoader.kt b/src/main/kotlin/org/winlogon/minechat/MineChatLoader.kt index 9fd6b3b..ecd9e69 100644 --- a/src/main/kotlin/org/winlogon/minechat/MineChatLoader.kt +++ b/src/main/kotlin/org/winlogon/minechat/MineChatLoader.kt @@ -3,6 +3,7 @@ package org.winlogon.minechat import io.papermc.paper.plugin.loader.PluginClasspathBuilder import io.papermc.paper.plugin.loader.PluginLoader import io.papermc.paper.plugin.loader.library.impl.MavenLibraryResolver + import org.eclipse.aether.artifact.DefaultArtifact import org.eclipse.aether.graph.Dependency import org.eclipse.aether.repository.RemoteRepository @@ -11,7 +12,7 @@ import java.nio.file.Path class MineChatLoader : PluginLoader { override fun classloader(classpathBuilder: PluginClasspathBuilder) { - val caffeineVersion = "3.2.0"; + val caffeineVersion = "3.2.0" val resolver = MavenLibraryResolver().apply { addDependency(Dependency(DefaultArtifact("com.github.ben-manes.caffeine:caffeine:$caffeineVersion"), null)) addRepository( diff --git a/src/main/kotlin/org/winlogon/minechat/MineChatServerPlugin.kt b/src/main/kotlin/org/winlogon/minechat/MineChatServerPlugin.kt index 3a9d759..7ce61da 100644 --- a/src/main/kotlin/org/winlogon/minechat/MineChatServerPlugin.kt +++ b/src/main/kotlin/org/winlogon/minechat/MineChatServerPlugin.kt @@ -2,6 +2,7 @@ package org.winlogon.minechat import com.mojang.brigadier.Command import com.mojang.brigadier.arguments.StringArgumentType +import com.charleskorn.kaml.Yaml import io.objectbox.BoxStore import io.papermc.paper.command.brigadier.Commands @@ -21,17 +22,18 @@ import org.bukkit.plugin.java.JavaPlugin import java.io.File import java.net.ServerSocket -import java.security.KeyStore import java.util.concurrent.ConcurrentLinkedQueue import java.util.concurrent.Executors import java.util.concurrent.TimeUnit import javax.net.ssl.KeyManagerFactory import javax.net.ssl.SSLContext +import java.security.KeyStore +import java.util.logging.Logger -import com.charleskorn.kaml.Yaml import kotlinx.serialization.decodeFromString class MineChatServerPlugin : JavaPlugin() { + private val logger: Logger = super.getLogger() private var serverSocket: ServerSocket? = null private val connectedClients = ConcurrentLinkedQueue() private lateinit var linkCodeStorage: LinkCodeStorage @@ -47,7 +49,7 @@ class MineChatServerPlugin : JavaPlugin() { val miniMessage = MiniMessage.miniMessage() private fun loadConfig(): MineChatConfig { - val configFile = java.io.File(dataFolder, "config.yml") + val configFile = File(dataFolder, "config.yml") return try { Yaml.default.decodeFromString(configFile.readText()) } catch (e: Exception) { @@ -183,40 +185,39 @@ class MineChatServerPlugin : JavaPlugin() { registerCommands() - val tlsEnabled = config.getBoolean("tls.enabled", false) + if (!mineChatConfig.tls.enabled) { + logger.severe("MineChat server cannot start: TLS is disabled in config.yml. TLS is mandatory as per specification.") + return + } - if (tlsEnabled) { - val keystoreFile = File(dataFolder, config.getString("tls.keystore", "keystore.jks")) - val keystorePassword = config.getString("tls.keystore-password", "password")?.toCharArray() + val keystoreFile = File(dataFolder, mineChatConfig.tls.keystore) + val keystorePassword = mineChatConfig.tls.keystorePassword.toCharArray() - if (!keystoreFile.exists()) { - logger.severe("Keystore file not found at ${keystoreFile.absolutePath}. Disabling TLS.") - serverSocket = ServerSocket(mineChatConfig.port) - } else { - try { - val keyStore = KeyStore.getInstance("JKS") - keyStore.load(keystoreFile.inputStream(), keystorePassword) + if (!keystoreFile.exists()) { + logger.severe("MineChat server cannot start: Keystore file not found at ${keystoreFile.absolutePath}. TLS is mandatory as per specification.") + return + } + + try { + val keyStore = java.security.KeyStore.getInstance("JKS") + keyStore.load(keystoreFile.inputStream(), keystorePassword) - val keyManagerFactory = KeyManagerFactory.getInstance(KeyManagerFactory.getDefaultAlgorithm()) - keyManagerFactory.init(keyStore, keystorePassword) + val keyManagerFactory = KeyManagerFactory.getInstance(KeyManagerFactory.getDefaultAlgorithm()) + keyManagerFactory.init(keyStore, keystorePassword) - val sslContext = SSLContext.getInstance("TLS") - sslContext.init(keyManagerFactory.keyManagers, null, null) + val sslContext = SSLContext.getInstance("TLS") + sslContext.init(keyManagerFactory.keyManagers, null, null) - val sslServerSocketFactory = sslContext.serverSocketFactory - serverSocket = sslServerSocketFactory.createServerSocket(mineChatConfig.port) - logger.info("MineChat server started with TLS on port ${mineChatConfig.port}") - } catch (e: Exception) { - logger.severe("Failed to initialize TLS: ${e.message}. Falling back to plain socket.") - e.printStackTrace() - serverSocket = ServerSocket(mineChatConfig.port) - } - } - } else { - serverSocket = ServerSocket(mineChatConfig.port) - logger.info("Starting MineChat server on port ${mineChatConfig.port}") + val sslServerSocketFactory = sslContext.serverSocketFactory + serverSocket = sslServerSocketFactory.createServerSocket(mineChatConfig.port) + logger.info("MineChat server started with TLS on port ${mineChatConfig.port}") + } catch (e: Exception) { + logger.severe("MineChat server cannot start: Failed to initialize TLS: ${e.message}. TLS is mandatory as per specification.") + e.printStackTrace() + return } + isServerRunning = true serverThread = Thread { @@ -241,20 +242,18 @@ class MineChatServerPlugin : JavaPlugin() { server.pluginManager.registerEvents(object : Listener { @EventHandler fun onChat(event: AsyncChatEvent) { - val plainMsg = PlainTextComponentSerializer.plainText().serialize(event.message()) - val message = mapOf( - "type" to "BROADCAST", - "payload" to mapOf( - "from" to event.player.name, - "message" to mapOf("text" to plainMsg) - ) - ) - broadcastToClients(message) - } + val plainMsg = PlainTextComponentSerializer.plainText().serialize(event.message()) + // For broadcasting chat from Minecraft to MineChat clients, we will use CHAT_MESSAGE + val chatMessagePayload = ChatMessagePayload( + format = "commonmark", // Assuming commonmark as the format for now + content = plainMsg // The plain text message from Minecraft + ) + broadcastToClients(PacketTypes.CHAT_MESSAGE, chatMessagePayload) } }, this) } override fun onDisable() { + logger.info("Disabling MineChatServerPlugin") isServerRunning = false serverThread?.interrupt() serverSocket?.close() @@ -273,17 +272,16 @@ class MineChatServerPlugin : JavaPlugin() { } } - fun broadcastToClients(message: Map) { + fun broadcastToClients(packetType: Int, payload: Any) { connectedClients.forEach { client -> try { - client.sendMessage(message) + client.sendMessage(packetType, payload) } catch (e: Exception) { logger.warning("Error sending message to client: ${e.message}") connectedClients.remove(client) } } } - fun getLinkCodeStorage(): LinkCodeStorage = linkCodeStorage fun getClientStorage(): ClientStorage = clientStorage fun getBanStorage(): BanStorage = banStorage diff --git a/src/main/kotlin/org/winlogon/minechat/Protocol.kt b/src/main/kotlin/org/winlogon/minechat/Protocol.kt new file mode 100644 index 0000000..d9b15f6 --- /dev/null +++ b/src/main/kotlin/org/winlogon/minechat/Protocol.kt @@ -0,0 +1,72 @@ +package org.winlogon.minechat + +import com.fasterxml.jackson.annotation.JsonCreator +import com.fasterxml.jackson.annotation.JsonProperty +import com.fasterxml.jackson.databind.annotation.JsonSerialize + +// Packet Type IDs as defined in the spec +object PacketTypes { + const val LINK = 0x01 + const val LINK_OK = 0x02 + const val CAPABILITIES = 0x03 + const val AUTH_OK = 0x04 + const val CHAT_MESSAGE = 0x05 + const val PING = 0x06 + const val PONG = 0x07 + const val MODERATION = 0x08 + + // Custom/implementation-private packet types (0x80-0xFF) + const val DISCONNECT = 0x80 +} + +/** + * Represents the common packet envelope as defined by the MineChat Protocol. + * { + * 0: packet_type (int), + * 1: payload (map) + * } + */ +data class MineChatPacket @JsonCreator constructor( + @JsonProperty("0") val packetType: Int, + @JsonProperty("1") val payload: Map // Payload fields use integer keys +) + +// Payload data classes +data class LinkPayload @JsonCreator constructor( + @JsonProperty("0") val linkingCode: String, + @JsonProperty("1") val clientUuid: String +) + +data class LinkOkPayload @JsonCreator constructor( + @JsonProperty("0") val minecraftUuid: String +) + +data class CapabilitiesPayload @JsonCreator constructor( + @JsonProperty("0") val supportsComponents: Boolean +) + +class AuthOkPayload + +data class ChatMessagePayload @JsonCreator constructor( + @JsonProperty("0") val format: String, + @JsonProperty("1") val content: String +) + +data class PingPayload @JsonCreator constructor( + @JsonProperty("0") val timestampMs: Long +) + +data class PongPayload @JsonCreator constructor( + @JsonProperty("0") val timestampMs: Long +) + +data class ModerationPayload @JsonCreator constructor( + @JsonProperty("0") val action: Int, + @JsonProperty("1") val scope: Int, + @JsonProperty("2") val reason: String?, // Optional + @JsonProperty("3") val durationSeconds: Int? // Optional +) + +data class DisconnectPayload @JsonCreator constructor( + @JsonProperty("0") val reason: String +) diff --git a/src/main/resources/config.yml b/src/main/resources/config.yml index d8587c1..347c5b0 100644 --- a/src/main/resources/config.yml +++ b/src/main/resources/config.yml @@ -7,3 +7,13 @@ port: 25575 # The time in minutes before a link code expires. # Default: 5 expiry-code-minutes: 5 + +# TLS settings for the server. +tls: + # Enable or disable TLS. + # If enabled, you must provide a keystore. + enabled: false + # The name of the keystore file in the plugin's data folder. + keystore: "keystore.jks" + # The password for the keystore. + keystore-password: "password" From 69a2a97a80b9c08495a95dd64ed6b72884923b26 Mon Sep 17 00:00:00 2001 From: winlogon Date: Tue, 23 Dec 2025 08:09:03 +0100 Subject: [PATCH 10/30] chore: separate imports correctly --- src/main/kotlin/org/winlogon/minechat/Ban.kt | 1 + src/main/kotlin/org/winlogon/minechat/BanStorage.kt | 3 ++- src/main/kotlin/org/winlogon/minechat/Client.kt | 1 + src/main/kotlin/org/winlogon/minechat/ClientStorage.kt | 1 + src/main/kotlin/org/winlogon/minechat/LinkCode.kt | 1 + src/main/kotlin/org/winlogon/minechat/LinkCodeStorage.kt | 2 ++ 6 files changed, 8 insertions(+), 1 deletion(-) diff --git a/src/main/kotlin/org/winlogon/minechat/Ban.kt b/src/main/kotlin/org/winlogon/minechat/Ban.kt index 2e7433f..b78fb37 100644 --- a/src/main/kotlin/org/winlogon/minechat/Ban.kt +++ b/src/main/kotlin/org/winlogon/minechat/Ban.kt @@ -3,6 +3,7 @@ package org.winlogon.minechat import io.objectbox.annotation.Convert import io.objectbox.annotation.Entity import io.objectbox.annotation.Id + import java.util.UUID @Entity diff --git a/src/main/kotlin/org/winlogon/minechat/BanStorage.kt b/src/main/kotlin/org/winlogon/minechat/BanStorage.kt index 687ab30..01f9d87 100644 --- a/src/main/kotlin/org/winlogon/minechat/BanStorage.kt +++ b/src/main/kotlin/org/winlogon/minechat/BanStorage.kt @@ -2,6 +2,7 @@ package org.winlogon.minechat import io.objectbox.Box import io.objectbox.BoxStore + import java.util.UUID class BanStorage(boxStore: BoxStore) { @@ -35,4 +36,4 @@ class BanStorage(boxStore: BoxStore) { } return null } -} \ No newline at end of file +} diff --git a/src/main/kotlin/org/winlogon/minechat/Client.kt b/src/main/kotlin/org/winlogon/minechat/Client.kt index d3f0e79..41e74ab 100644 --- a/src/main/kotlin/org/winlogon/minechat/Client.kt +++ b/src/main/kotlin/org/winlogon/minechat/Client.kt @@ -3,6 +3,7 @@ package org.winlogon.minechat import io.objectbox.annotation.Convert import io.objectbox.annotation.Entity import io.objectbox.annotation.Id + import java.util.UUID @Entity diff --git a/src/main/kotlin/org/winlogon/minechat/ClientStorage.kt b/src/main/kotlin/org/winlogon/minechat/ClientStorage.kt index 10f9b7d..406c8da 100644 --- a/src/main/kotlin/org/winlogon/minechat/ClientStorage.kt +++ b/src/main/kotlin/org/winlogon/minechat/ClientStorage.kt @@ -2,6 +2,7 @@ package org.winlogon.minechat import com.github.benmanes.caffeine.cache.Cache import com.github.benmanes.caffeine.cache.Caffeine + import io.objectbox.Box import io.objectbox.BoxStore diff --git a/src/main/kotlin/org/winlogon/minechat/LinkCode.kt b/src/main/kotlin/org/winlogon/minechat/LinkCode.kt index cde1f04..87f096c 100644 --- a/src/main/kotlin/org/winlogon/minechat/LinkCode.kt +++ b/src/main/kotlin/org/winlogon/minechat/LinkCode.kt @@ -3,6 +3,7 @@ package org.winlogon.minechat import io.objectbox.annotation.Convert import io.objectbox.annotation.Entity import io.objectbox.annotation.Id + import java.util.UUID @Entity diff --git a/src/main/kotlin/org/winlogon/minechat/LinkCodeStorage.kt b/src/main/kotlin/org/winlogon/minechat/LinkCodeStorage.kt index b6ddb24..46a7a1f 100644 --- a/src/main/kotlin/org/winlogon/minechat/LinkCodeStorage.kt +++ b/src/main/kotlin/org/winlogon/minechat/LinkCodeStorage.kt @@ -2,8 +2,10 @@ package org.winlogon.minechat import com.github.benmanes.caffeine.cache.Cache import com.github.benmanes.caffeine.cache.Caffeine + import io.objectbox.Box import io.objectbox.BoxStore + import java.util.concurrent.Executors import java.util.concurrent.TimeUnit From 053af6cc5dc95ee544e3158a49283f392ac4dd02 Mon Sep 17 00:00:00 2001 From: winlogon Date: Tue, 23 Dec 2025 09:57:19 +0100 Subject: [PATCH 11/30] chore: update Gradle to 9.2.0 and plugins used --- build.gradle.kts | 12 +++++++++--- gradle/wrapper/gradle-wrapper.jar | Bin 43583 -> 45633 bytes gradle/wrapper/gradle-wrapper.properties | 2 +- gradlew | 9 +++------ gradlew.bat | 3 +-- 5 files changed, 14 insertions(+), 12 deletions(-) diff --git a/build.gradle.kts b/build.gradle.kts index 74dc97f..7c62912 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -12,9 +12,9 @@ buildscript { } plugins { - id("com.gradleup.shadow") version "8.3.6" - kotlin("jvm") version "2.1.10" - kotlin("plugin.serialization") version "2.1.10" + id("com.gradleup.shadow") version "9.3.0" + kotlin("jvm") version "2.3.0" + kotlin("plugin.serialization") version "2.3.0" } apply(plugin = "io.objectbox") @@ -155,3 +155,9 @@ tasks.register("release") { } } } + +tasks.withType().configureEach { + compilerOptions { + freeCompilerArgs.add("-Xannotation-default-target=param-property") + } +} diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar index a4b76b9530d66f5e68d973ea569d8e19de379189..f8e1ee3125fe0768e9a76ee977ac089eb657005e 100644 GIT binary patch delta 37538 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 }= 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 F{{RkpxF!Gq diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index cea7a79..bad7c24 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -1,6 +1,6 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-8.12-bin.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-9.2.0-bin.zip networkTimeout=10000 validateDistributionUrl=true zipStoreBase=GRADLE_USER_HOME diff --git a/gradlew b/gradlew index f3b75f3..adff685 100755 --- a/gradlew +++ b/gradlew @@ -1,7 +1,7 @@ #!/bin/sh # -# Copyright © 2015-2021 the original authors. +# Copyright © 2015 the original authors. # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. @@ -114,7 +114,6 @@ case "$( uname )" in #( NONSTOP* ) nonstop=true ;; esac -CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar # Determine the Java command to use to start the JVM. @@ -172,7 +171,6 @@ fi # For Cygwin or MSYS, switch paths to Windows format before running java if "$cygwin" || "$msys" ; then APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) - CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" ) JAVACMD=$( cygpath --unix "$JAVACMD" ) @@ -205,15 +203,14 @@ fi DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' # Collect all arguments for the java command: -# * DEFAULT_JVM_OPTS, JAVA_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, # and any embedded shellness will be escaped. # * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be # treated as '${Hostname}' itself on the command line. set -- \ "-Dorg.gradle.appname=$APP_BASE_NAME" \ - -classpath "$CLASSPATH" \ - org.gradle.wrapper.GradleWrapperMain \ + -jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \ "$@" # Stop when "xargs" is not available. diff --git a/gradlew.bat b/gradlew.bat index 9d21a21..c4bdd3a 100644 --- a/gradlew.bat +++ b/gradlew.bat @@ -70,11 +70,10 @@ goto fail :execute @rem Setup the command line -set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar @rem Execute Gradle -"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %* +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* :end @rem End local scope for the variables with windows NT shell From 54f4eaac923ed2970f214b5fa4c937e13c79c436 Mon Sep 17 00:00:00 2001 From: winlogon Date: Tue, 23 Dec 2025 09:58:15 +0100 Subject: [PATCH 12/30] refactor: split logic in the main class for modularity --- .../minechat/MineChatCommandRegister.kt | 122 +++++++++++ .../minechat/MineChatPluginServices.kt | 21 ++ .../winlogon/minechat/MineChatServerPlugin.kt | 200 +++++------------- 3 files changed, 197 insertions(+), 146 deletions(-) create mode 100644 src/main/kotlin/org/winlogon/minechat/MineChatCommandRegister.kt create mode 100644 src/main/kotlin/org/winlogon/minechat/MineChatPluginServices.kt diff --git a/src/main/kotlin/org/winlogon/minechat/MineChatCommandRegister.kt b/src/main/kotlin/org/winlogon/minechat/MineChatCommandRegister.kt new file mode 100644 index 0000000..5047469 --- /dev/null +++ b/src/main/kotlin/org/winlogon/minechat/MineChatCommandRegister.kt @@ -0,0 +1,122 @@ +package org.winlogon.minechat + +import com.mojang.brigadier.Command +import com.mojang.brigadier.arguments.StringArgumentType + +import io.papermc.paper.command.brigadier.Commands +import io.papermc.paper.plugin.lifecycle.event.types.LifecycleEvents + +import net.kyori.adventure.text.Component +import net.kyori.adventure.text.format.NamedTextColor +import net.kyori.adventure.text.minimessage.tag.resolver.Placeholder + +import org.bukkit.entity.Player +import org.bukkit.event.Listener + +class MineChatCommandRegister(private val services: MineChatPluginServices) : Listener { + fun registerCommands() { + val linkCommand = Commands.literal("link") + .requires { sender -> sender.executor is Player } + .executes { ctx -> + val sender = ctx.source.sender as Player + this.generateAndSendLinkCode(sender) + Command.SINGLE_SUCCESS + } + .build() + + val reloadCommand = Commands.literal("mchatreload") + .requires { sender -> sender.sender.hasPermission(services.permissions["reload"]!!) } + .executes { ctx -> + services.reloadConfigAndDependencies() + ctx.source.sender.sendMessage(Component.text("MineChat config reloaded.").color(NamedTextColor.GREEN)) + Command.SINGLE_SUCCESS + } + .build() + + val banCommand = Commands.literal("minechat-ban") + .requires { sender -> sender.sender.hasPermission(services.permissions["ban"]!!) } + .then(Commands.argument("player", StringArgumentType.word()) + .executes { ctx -> + val playerName = StringArgumentType.getString(ctx, "player") + val client = services.clientStorage.find(null, playerName) + if (client == null) { + ctx.source.sender.sendMessage(Component.text("Player not found.").color(NamedTextColor.RED)) + return@executes 0 + } + val ban = Ban(minecraftUsername = playerName, reason = "Banned by an operator.") + services.banStorage.add(ban) + ctx.source.sender.sendMessage(Component.text("Banned $playerName from MineChat.").color(NamedTextColor.GREEN)) + Command.SINGLE_SUCCESS + } + ) + .build() + + val unbanCommand = Commands.literal("minechat-unban") + .requires { sender -> sender.sender.hasPermission("minechat.unban") } + .then(Commands.argument("player", StringArgumentType.word()) + .executes { ctx -> + val playerName = StringArgumentType.getString(ctx, "player") + services.banStorage.remove(null, playerName) + ctx.source.sender.sendMessage(Component.text("Unbanned $playerName from MineChat.").color(NamedTextColor.GREEN)) + Command.SINGLE_SUCCESS + } + ) + .build() + + val kickCommand = Commands.literal("minechat-kick") + .requires { sender -> sender.sender.hasPermission("minechat.kick") } + .then(Commands.argument("player", StringArgumentType.word()) + .executes { ctx -> + val playerName = StringArgumentType.getString(ctx, "player") + val clientConnection = this.getClientConnection(playerName) + if (clientConnection == null) { + ctx.source.sender.sendMessage(Component.text("Player not found or not connected via MineChat.").color(NamedTextColor.RED)) + return@executes 0 + } + clientConnection.disconnect("Kicked by an operator.") + ctx.source.sender.sendMessage(Component.text("Kicked $playerName from MineChat.").color(NamedTextColor.GREEN)) + Command.SINGLE_SUCCESS + } + ) + .build() + + services.pluginInstance.lifecycleManager.registerEventHandler(LifecycleEvents.COMMANDS) { event -> + val registrar = event.registrar() + registrar.register(linkCommand) + registrar.register(reloadCommand) + registrar.register(banCommand) + registrar.register(unbanCommand) + registrar.register(kickCommand) + } + } + + + fun generateAndSendLinkCode(player: Player) { + val code = services.generateRandomLinkCode() + + val link = LinkCode( + code = code, + minecraftUuid = player.uniqueId, + minecraftUsername = player.name, + expiresAt = System.currentTimeMillis() + (services.mineChatConfig.expiryCodeMinutes * 60_000L) + ) + services.linkCodeStorage.add(link) + + val codeComponent = Component.text(code, NamedTextColor.DARK_AQUA) + val timeComponent = Component.text("${services.mineChatConfig.expiryCodeMinutes} minutes", NamedTextColor.DARK_GREEN) + player.sendRichMessage( + "Your link code is: . Use it in the client within ", + Placeholder.component("code", codeComponent), + Placeholder.component("deadline", timeComponent) + ) + } + + + fun getClientConnection(username: String): ClientConnection? { + return services.connectedClients.find { it.getClient()?.minecraftUsername == username } + } + + + + +} diff --git a/src/main/kotlin/org/winlogon/minechat/MineChatPluginServices.kt b/src/main/kotlin/org/winlogon/minechat/MineChatPluginServices.kt new file mode 100644 index 0000000..12d1b1f --- /dev/null +++ b/src/main/kotlin/org/winlogon/minechat/MineChatPluginServices.kt @@ -0,0 +1,21 @@ +package org.winlogon.minechat + +import net.kyori.adventure.text.minimessage.MiniMessage +import org.bukkit.permissions.Permission +import org.bukkit.plugin.java.JavaPlugin +import java.util.concurrent.ConcurrentLinkedQueue +import java.util.logging.Logger + +interface MineChatPluginServices { + val pluginInstance: JavaPlugin // To allow CommandRegister to use lifecycleManager + val linkCodeStorage: LinkCodeStorage + val clientStorage: ClientStorage + val banStorage: BanStorage + val mineChatConfig: MineChatConfig + val permissions: Map + val miniMessage: MiniMessage + val connectedClients: ConcurrentLinkedQueue + + fun reloadConfigAndDependencies() + fun generateRandomLinkCode(): String +} diff --git a/src/main/kotlin/org/winlogon/minechat/MineChatServerPlugin.kt b/src/main/kotlin/org/winlogon/minechat/MineChatServerPlugin.kt index 7ce61da..185beb0 100644 --- a/src/main/kotlin/org/winlogon/minechat/MineChatServerPlugin.kt +++ b/src/main/kotlin/org/winlogon/minechat/MineChatServerPlugin.kt @@ -1,180 +1,90 @@ package org.winlogon.minechat -import com.mojang.brigadier.Command -import com.mojang.brigadier.arguments.StringArgumentType import com.charleskorn.kaml.Yaml import io.objectbox.BoxStore -import io.papermc.paper.command.brigadier.Commands import io.papermc.paper.event.player.AsyncChatEvent import io.papermc.paper.plugin.lifecycle.event.types.LifecycleEvents import net.kyori.adventure.text.Component import net.kyori.adventure.text.format.NamedTextColor import net.kyori.adventure.text.minimessage.MiniMessage -import net.kyori.adventure.text.minimessage.tag.resolver.Placeholder import net.kyori.adventure.text.serializer.plain.PlainTextComponentSerializer -import org.bukkit.entity.Player import org.bukkit.event.EventHandler import org.bukkit.event.Listener +import org.bukkit.permissions.Permission import org.bukkit.plugin.java.JavaPlugin +import org.bukkit.Bukkit import java.io.File import java.net.ServerSocket +import java.security.KeyStore import java.util.concurrent.ConcurrentLinkedQueue import java.util.concurrent.Executors import java.util.concurrent.TimeUnit +import java.util.logging.Logger import javax.net.ssl.KeyManagerFactory import javax.net.ssl.SSLContext -import java.security.KeyStore -import java.util.logging.Logger import kotlinx.serialization.decodeFromString -class MineChatServerPlugin : JavaPlugin() { - private val logger: Logger = super.getLogger() +class MineChatServerPlugin : JavaPlugin(), MineChatPluginServices { private var serverSocket: ServerSocket? = null - private val connectedClients = ConcurrentLinkedQueue() - private lateinit var linkCodeStorage: LinkCodeStorage - private lateinit var clientStorage: ClientStorage - private lateinit var banStorage: BanStorage + private var isFolia: Boolean = false private lateinit var boxStore: BoxStore - private var isFolia = false - - private lateinit var mineChatConfig: MineChatConfig + @Volatile private var isServerRunning: Boolean = false private var serverThread: Thread? = null - @Volatile private var isServerRunning = false - private val executorService = Executors.newVirtualThreadPerTaskExecutor() - val miniMessage = MiniMessage.miniMessage() + private val executorService = Executors.newCachedThreadPool() + + override val connectedClients = ConcurrentLinkedQueue() + val loggerProvider: PluginLoggerProvider = PluginLoggerProvider(this) + override lateinit var linkCodeStorage: LinkCodeStorage + override lateinit var clientStorage: ClientStorage + override lateinit var banStorage: BanStorage + override lateinit var mineChatConfig: MineChatConfig + override lateinit var permissions: Map + override val miniMessage = MiniMessage.miniMessage() + override val pluginInstance: JavaPlugin + get() = this private fun loadConfig(): MineChatConfig { val configFile = File(dataFolder, "config.yml") return try { Yaml.default.decodeFromString(configFile.readText()) } catch (e: Exception) { - logger.severe("Failed to load config.yml: ${e.message}. Using default config.") + loggerProvider.logger.severe("Failed to load config.yml: ${e.message}. Using default config.") MineChatConfig() } } - private fun generateLinkCode(): String { - val chars = ('A'..'Z') + ('0'..'9') - return (1..6).map { chars.random() }.joinToString("") + override fun reloadConfigAndDependencies() { + saveResource("config.yml", false) + reloadConfig() + mineChatConfig = loadConfig() } - fun getClientConnection(username: String): ClientConnection? { - return connectedClients.find { it.getClient()?.minecraftUsername == username } + override fun generateRandomLinkCode(): String { + val chars = ('A'..'Z') + ('0'..'9') + return (1..6).map { chars.random() }.joinToString("") } - fun generateAndSendLinkCode(player: Player) { - val code = generateLinkCode() - - val link = LinkCode( - code = code, - minecraftUuid = player.uniqueId, - minecraftUsername = player.name, - expiresAt = System.currentTimeMillis() + (mineChatConfig.expiryCodeMinutes * 60_000L) - ) - linkCodeStorage.add(link) - - val codeComponent = Component.text(code, NamedTextColor.DARK_AQUA) - val timeComponent = Component.text("${mineChatConfig.expiryCodeMinutes} minutes", NamedTextColor.DARK_GREEN) - player.sendRichMessage( - "Your link code is: . Use it in the client within ", - Placeholder.component("code", codeComponent), - Placeholder.component("deadline", timeComponent) + override fun onLoad() { + permissions = mapOf( + "reload" to Permission("minechat.reload"), + "ban" to Permission("minechat.ban"), ) - } - - fun registerCommands() { - val linkCommand = Commands.literal("link") - .requires { sender -> sender.executor is Player } - .executes { ctx -> - val sender = ctx.source.sender as Player - generateAndSendLinkCode(sender) - Command.SINGLE_SUCCESS - } - .build() - - val reloadCommand = Commands.literal("mchatreload") - .requires { sender -> sender.sender.hasPermission("minechat.reload") } - .executes { ctx -> - reloadConfig() - mineChatConfig = loadConfig() - ctx.source.sender.sendMessage(Component.text("MineChat config reloaded.").color(NamedTextColor.GREEN)) - Command.SINGLE_SUCCESS - } - .build() - - val banCommand = Commands.literal("minechat-ban") - .requires { sender -> sender.sender.hasPermission("minechat.ban") } - .then(Commands.argument("player", StringArgumentType.word()) - .executes { ctx -> - val playerName = StringArgumentType.getString(ctx, "player") - val client = clientStorage.find(null, playerName) - if (client == null) { - ctx.source.sender.sendMessage(Component.text("Player not found.").color(NamedTextColor.RED)) - return@executes 0 - } - val ban = Ban(minecraftUsername = playerName, reason = "Banned by an operator.") - banStorage.add(ban) - ctx.source.sender.sendMessage(Component.text("Banned $playerName from MineChat.").color(NamedTextColor.GREEN)) - Command.SINGLE_SUCCESS - } - ) - .build() - - val unbanCommand = Commands.literal("minechat-unban") - .requires { sender -> sender.sender.hasPermission("minechat.unban") } - .then(Commands.argument("player", StringArgumentType.word()) - .executes { ctx -> - val playerName = StringArgumentType.getString(ctx, "player") - banStorage.remove(null, playerName) - ctx.source.sender.sendMessage(Component.text("Unbanned $playerName from MineChat.").color(NamedTextColor.GREEN)) - Command.SINGLE_SUCCESS - } - ) - .build() - - val kickCommand = Commands.literal("minechat-kick") - .requires { sender -> sender.sender.hasPermission("minechat.kick") } - .then(Commands.argument("player", StringArgumentType.word()) - .executes { ctx -> - val playerName = StringArgumentType.getString(ctx, "player") - val clientConnection = getClientConnection(playerName) - if (clientConnection == null) { - ctx.source.sender.sendMessage(Component.text("Player not found or not connected via MineChat.").color(NamedTextColor.RED)) - return@executes 0 - } - clientConnection.disconnect("Kicked by an operator.") - ctx.source.sender.sendMessage(Component.text("Kicked $playerName from MineChat.").color(NamedTextColor.GREEN)) - Command.SINGLE_SUCCESS - } - ) - .build() - - this.lifecycleManager.registerEventHandler(LifecycleEvents.COMMANDS) { event -> - val registrar = event.registrar() - registrar.register(linkCommand) - registrar.register(reloadCommand) - registrar.register(banCommand) - registrar.register(unbanCommand) - registrar.register(kickCommand) - } + Bukkit.getPluginManager().addPermissions(permissions.values.toList()) } override fun onEnable() { isFolia = try { Class.forName("io.papermc.paper.threadedregions.RegionizedServer") true - } catch (e: ClassNotFoundException) { + } catch (ignored: ClassNotFoundException) { false } - - saveResource("config.yml", false) - reloadConfig() - mineChatConfig = loadConfig() + reloadConfigAndDependencies() dataFolder.mkdirs() @@ -183,10 +93,10 @@ class MineChatServerPlugin : JavaPlugin() { clientStorage = ClientStorage(boxStore) banStorage = BanStorage(boxStore) - registerCommands() + MineChatCommandRegister(this).registerCommands() if (!mineChatConfig.tls.enabled) { - logger.severe("MineChat server cannot start: TLS is disabled in config.yml. TLS is mandatory as per specification.") + loggerProvider.logger.severe("MineChat server cannot start: TLS is disabled in config.yml. TLS is mandatory as per specification.") return } @@ -194,7 +104,7 @@ class MineChatServerPlugin : JavaPlugin() { val keystorePassword = mineChatConfig.tls.keystorePassword.toCharArray() if (!keystoreFile.exists()) { - logger.severe("MineChat server cannot start: Keystore file not found at ${keystoreFile.absolutePath}. TLS is mandatory as per specification.") + loggerProvider.logger.severe("MineChat server cannot start: Keystore file not found at ${keystoreFile.absolutePath}. TLS is mandatory as per specification.") return } @@ -210,14 +120,13 @@ class MineChatServerPlugin : JavaPlugin() { val sslServerSocketFactory = sslContext.serverSocketFactory serverSocket = sslServerSocketFactory.createServerSocket(mineChatConfig.port) - logger.info("MineChat server started with TLS on port ${mineChatConfig.port}") + loggerProvider.logger.info("MineChat server started with TLS on port ${mineChatConfig.port}") } catch (e: Exception) { - logger.severe("MineChat server cannot start: Failed to initialize TLS: ${e.message}. TLS is mandatory as per specification.") + loggerProvider.logger.severe("MineChat server cannot start: Failed to initialize TLS: ${e.message}. TLS is mandatory as per specification.") e.printStackTrace() return } - isServerRunning = true serverThread = Thread { @@ -225,35 +134,36 @@ class MineChatServerPlugin : JavaPlugin() { try { val socket = serverSocket?.accept() if (socket != null) { - logger.info("Client connected: ${socket.inetAddress}") + loggerProvider.logger.info("Client connected: ${socket.inetAddress}") val connection = ClientConnection(socket, this, miniMessage) connectedClients.add(connection) executorService.submit(connection) } } catch (e: Exception) { if (!isServerRunning) break - logger.warning("Error accepting client: ${e.message}") + loggerProvider.logger.warning("Error accepting client: ${e.message}") } } - logger.info("MineChat server socket thread stopped.") + loggerProvider.logger.info("MineChat server socket thread stopped.") } serverThread?.start() server.pluginManager.registerEvents(object : Listener { @EventHandler fun onChat(event: AsyncChatEvent) { - val plainMsg = PlainTextComponentSerializer.plainText().serialize(event.message()) - // For broadcasting chat from Minecraft to MineChat clients, we will use CHAT_MESSAGE - val chatMessagePayload = ChatMessagePayload( - format = "commonmark", // Assuming commonmark as the format for now - content = plainMsg // The plain text message from Minecraft - ) - broadcastToClients(PacketTypes.CHAT_MESSAGE, chatMessagePayload) } + val plainMsg = PlainTextComponentSerializer.plainText().serialize(event.message()) + // TODO: actually format message as CommonMark + val chatMessagePayload = ChatMessagePayload( + format = "commonmark", + content = plainMsg + ) + broadcastToClients(PacketTypes.CHAT_MESSAGE, chatMessagePayload) + } }, this) } override fun onDisable() { - logger.info("Disabling MineChatServerPlugin") + loggerProvider.logger.info("Disabling MineChatServerPlugin") isServerRunning = false serverThread?.interrupt() serverSocket?.close() @@ -277,13 +187,11 @@ class MineChatServerPlugin : JavaPlugin() { try { client.sendMessage(packetType, payload) } catch (e: Exception) { - logger.warning("Error sending message to client: ${e.message}") + loggerProvider.logger.warning("Error sending message to client: ${e.message}") connectedClients.remove(client) } } } - fun getLinkCodeStorage(): LinkCodeStorage = linkCodeStorage - fun getClientStorage(): ClientStorage = clientStorage - fun getBanStorage(): BanStorage = banStorage - fun removeClient(client: ClientConnection) = connectedClients.remove(client) + + public fun removeClient(client: ClientConnection) = connectedClients.remove(client) } From 19facefd1fe58441c1c631f60ef02f3a453ab4b5 Mon Sep 17 00:00:00 2001 From: winlogon Date: Tue, 23 Dec 2025 09:58:59 +0100 Subject: [PATCH 13/30] refactor: move logger to an utility class --- .../org/winlogon/minechat/ClientConnection.kt | 21 ++++++++++--------- .../minechat/MineChatPluginServices.kt | 2 +- .../winlogon/minechat/PluginLoggerProvider.kt | 8 +++++++ .../org/winlogon/minechat/UuidConverter.kt | 1 + 4 files changed, 21 insertions(+), 11 deletions(-) create mode 100644 src/main/kotlin/org/winlogon/minechat/PluginLoggerProvider.kt diff --git a/src/main/kotlin/org/winlogon/minechat/ClientConnection.kt b/src/main/kotlin/org/winlogon/minechat/ClientConnection.kt index e536f84..9d48fc5 100644 --- a/src/main/kotlin/org/winlogon/minechat/ClientConnection.kt +++ b/src/main/kotlin/org/winlogon/minechat/ClientConnection.kt @@ -109,11 +109,11 @@ class ClientConnection( logger.fine("Received PONG message: $payload") handlePong(payload) } - else -> plugin.logger.warning("Unknown packet type: ${mineChatPacket.packetType}") + else -> plugin.loggerProvider.logger.warning("Unknown packet type: ${mineChatPacket.packetType}") } } } catch (e: Exception) { - plugin.logger.warning("Client error: ${e.message}") + plugin.loggerProvider.logger.warning("Client error: ${e.message}") } finally { client?.let { broadcastMinecraft(ChatGradients.LEAVE, "${it.minecraftUsername} has left the chat.") @@ -138,18 +138,18 @@ class ClientConnection( logger.fine("Handling auth with payload: $payload") - val banStorage = plugin.getBanStorage() + val banStorage = plugin.banStorage val clientUuid = payload.clientUuid val linkCode = payload.linkingCode - var ban = banStorage.getBan(clientUuid, null) + var ban: Ban? = banStorage.getBan(clientUuid, null) if (ban != null) { sendBannedMessage(ban) return } if (linkCode.isNotEmpty()) { - val link = plugin.getLinkCodeStorage().find(linkCode) + val link = plugin.linkCodeStorage.find(linkCode) if (link != null) { ban = banStorage.getBan(null, link.minecraftUsername) if (ban != null) { @@ -158,8 +158,8 @@ class ClientConnection( } if (link.expiresAt > System.currentTimeMillis()) { val client = Client(clientUuid = clientUuid, minecraftUuid = link.minecraftUuid, minecraftUsername = link.minecraftUsername) - plugin.getClientStorage().add(client) - plugin.getLinkCodeStorage().remove(link.code) + plugin.clientStorage.add(client) + plugin.linkCodeStorage.remove(link.code) this.client = client val linkOkPayload = LinkOkPayload( minecraftUuid = link.minecraftUuid.toString() @@ -177,7 +177,7 @@ class ClientConnection( } else { disconnect("Invalid or expired link code") } } else { - val client = plugin.getClientStorage().find(clientUuid, null) + val client = plugin.clientStorage.find(clientUuid, null) if (client != null) { ban = banStorage.getBan(null, client.minecraftUsername) if (ban != null) { @@ -219,7 +219,7 @@ class ClientConnection( private fun handleCapabilities(payload: CapabilitiesPayload) { client?.let { it.supportsComponents = payload.supportsComponents - plugin.getClientStorage().add(it) // Update the client in storage + plugin.clientStorage.add(it) // Update the client in storage logger.fine("Client ${it.minecraftUsername} updated with capabilities: supportsComponents=${it.supportsComponents}") } ?: run { logger.warning("Received CAPABILITIES packet before client was authenticated. Disconnecting.") @@ -239,6 +239,7 @@ class ClientConnection( logger.fine("Received PONG from client with timestamp ${payload.timestampMs}") } + @Suppress("UNCHECKED_CAST") fun sendMessage(packetType: Int, payload: Any) { logger.fine("Sending packet type $packetType with payload: $payload") try { @@ -253,7 +254,7 @@ class ClientConnection( writer.write(compressed) writer.flush() } catch (e: Exception) { - plugin.logger.warning("Error sending message: ${e.message}") + plugin.loggerProvider.logger.warning("Error sending message: ${e.message}") } } diff --git a/src/main/kotlin/org/winlogon/minechat/MineChatPluginServices.kt b/src/main/kotlin/org/winlogon/minechat/MineChatPluginServices.kt index 12d1b1f..80f0457 100644 --- a/src/main/kotlin/org/winlogon/minechat/MineChatPluginServices.kt +++ b/src/main/kotlin/org/winlogon/minechat/MineChatPluginServices.kt @@ -7,7 +7,7 @@ import java.util.concurrent.ConcurrentLinkedQueue import java.util.logging.Logger interface MineChatPluginServices { - val pluginInstance: JavaPlugin // To allow CommandRegister to use lifecycleManager + val pluginInstance: JavaPlugin val linkCodeStorage: LinkCodeStorage val clientStorage: ClientStorage val banStorage: BanStorage diff --git a/src/main/kotlin/org/winlogon/minechat/PluginLoggerProvider.kt b/src/main/kotlin/org/winlogon/minechat/PluginLoggerProvider.kt new file mode 100644 index 0000000..bf334da --- /dev/null +++ b/src/main/kotlin/org/winlogon/minechat/PluginLoggerProvider.kt @@ -0,0 +1,8 @@ +package org.winlogon.minechat + +import org.bukkit.plugin.java.JavaPlugin +import java.util.logging.Logger + +class PluginLoggerProvider(plugin: JavaPlugin) { + val logger: Logger = plugin.logger +} diff --git a/src/main/kotlin/org/winlogon/minechat/UuidConverter.kt b/src/main/kotlin/org/winlogon/minechat/UuidConverter.kt index 5cbd83e..68b2445 100644 --- a/src/main/kotlin/org/winlogon/minechat/UuidConverter.kt +++ b/src/main/kotlin/org/winlogon/minechat/UuidConverter.kt @@ -1,6 +1,7 @@ package org.winlogon.minechat import io.objectbox.converter.PropertyConverter + import java.util.UUID class UuidConverter : PropertyConverter { From db2cc6162462233959d51bbef2efe94bc6fea2b9 Mon Sep 17 00:00:00 2001 From: winlogon Date: Tue, 23 Dec 2025 10:35:07 +0100 Subject: [PATCH 14/30] chore: add permission descriptions --- src/main/kotlin/org/winlogon/minechat/MineChatServerPlugin.kt | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/main/kotlin/org/winlogon/minechat/MineChatServerPlugin.kt b/src/main/kotlin/org/winlogon/minechat/MineChatServerPlugin.kt index 185beb0..740e9e5 100644 --- a/src/main/kotlin/org/winlogon/minechat/MineChatServerPlugin.kt +++ b/src/main/kotlin/org/winlogon/minechat/MineChatServerPlugin.kt @@ -71,8 +71,8 @@ class MineChatServerPlugin : JavaPlugin(), MineChatPluginServices { override fun onLoad() { permissions = mapOf( - "reload" to Permission("minechat.reload"), - "ban" to Permission("minechat.ban"), + "reload" to Permission("minechat.reload", "Reloads the MineChat configuration."), + "ban" to Permission("minechat.ban", "Bans a player from the MineChat server."), ) Bukkit.getPluginManager().addPermissions(permissions.values.toList()) } From 98136af16aa8d1c1a7de9f1288119c81cf05204b Mon Sep 17 00:00:00 2001 From: winlogon Date: Tue, 3 Feb 2026 05:08:09 +0100 Subject: [PATCH 15/30] chore: remove unused import directives --- src/main/kotlin/org/winlogon/minechat/BanStorage.kt | 2 -- src/main/kotlin/org/winlogon/minechat/ClientConnection.kt | 3 --- .../kotlin/org/winlogon/minechat/MineChatPluginServices.kt | 1 - 3 files changed, 6 deletions(-) diff --git a/src/main/kotlin/org/winlogon/minechat/BanStorage.kt b/src/main/kotlin/org/winlogon/minechat/BanStorage.kt index 01f9d87..3fadb7b 100644 --- a/src/main/kotlin/org/winlogon/minechat/BanStorage.kt +++ b/src/main/kotlin/org/winlogon/minechat/BanStorage.kt @@ -3,8 +3,6 @@ package org.winlogon.minechat import io.objectbox.Box import io.objectbox.BoxStore -import java.util.UUID - class BanStorage(boxStore: BoxStore) { private val banBox: Box = boxStore.boxFor(Ban::class.java) diff --git a/src/main/kotlin/org/winlogon/minechat/ClientConnection.kt b/src/main/kotlin/org/winlogon/minechat/ClientConnection.kt index 9d48fc5..2ad7e25 100644 --- a/src/main/kotlin/org/winlogon/minechat/ClientConnection.kt +++ b/src/main/kotlin/org/winlogon/minechat/ClientConnection.kt @@ -9,13 +9,10 @@ import net.kyori.adventure.text.Component import net.kyori.adventure.text.format.NamedTextColor import net.kyori.adventure.text.minimessage.MiniMessage import net.kyori.adventure.text.minimessage.tag.resolver.Placeholder -import net.kyori.adventure.text.serializer.gson.GsonComponentSerializer import net.kyori.adventure.text.serializer.legacy.LegacyComponentSerializer import org.bukkit.Bukkit -import org.bukkit.plugin.java.JavaPlugin.getPlugin -import java.util.logging.Logger import java.io.DataInputStream import java.io.DataOutputStream import java.net.Socket diff --git a/src/main/kotlin/org/winlogon/minechat/MineChatPluginServices.kt b/src/main/kotlin/org/winlogon/minechat/MineChatPluginServices.kt index 80f0457..417b6ce 100644 --- a/src/main/kotlin/org/winlogon/minechat/MineChatPluginServices.kt +++ b/src/main/kotlin/org/winlogon/minechat/MineChatPluginServices.kt @@ -4,7 +4,6 @@ import net.kyori.adventure.text.minimessage.MiniMessage import org.bukkit.permissions.Permission import org.bukkit.plugin.java.JavaPlugin import java.util.concurrent.ConcurrentLinkedQueue -import java.util.logging.Logger interface MineChatPluginServices { val pluginInstance: JavaPlugin From da8dcf2af3c35748b358b717adeb6895d9e70a95 Mon Sep 17 00:00:00 2001 From: winlogon Date: Wed, 4 Feb 2026 04:49:14 +0100 Subject: [PATCH 16/30] refactor!: move related classes into their own modules --- .../org/winlogon/minechat/ClientConnection.kt | 272 +++++++++++------- .../minechat/MineChatCommandRegister.kt | 8 +- .../org/winlogon/minechat/MineChatConfig.kt | 2 +- .../org/winlogon/minechat/MineChatLoader.kt | 2 - .../minechat/MineChatPluginServices.kt | 3 + .../winlogon/minechat/MineChatServerPlugin.kt | 36 +-- .../kotlin/org/winlogon/minechat/Protocol.kt | 1 - .../winlogon/minechat/{ => entities}/Ban.kt | 4 +- .../minechat/{ => entities}/Client.kt | 4 +- .../minechat/{ => entities}/LinkCode.kt | 4 +- .../minechat/{ => storage}/BanStorage.kt | 4 +- .../minechat/{ => storage}/ClientStorage.kt | 4 +- .../minechat/{ => storage}/LinkCodeStorage.kt | 4 +- .../minechat/{ => storage}/UuidConverter.kt | 2 +- 14 files changed, 216 insertions(+), 134 deletions(-) rename src/main/kotlin/org/winlogon/minechat/{ => entities}/Ban.kt (83%) rename src/main/kotlin/org/winlogon/minechat/{ => entities}/Client.kt (82%) rename src/main/kotlin/org/winlogon/minechat/{ => entities}/LinkCode.kt (80%) rename src/main/kotlin/org/winlogon/minechat/{ => storage}/BanStorage.kt (89%) rename src/main/kotlin/org/winlogon/minechat/{ => storage}/ClientStorage.kt (94%) rename src/main/kotlin/org/winlogon/minechat/{ => storage}/LinkCodeStorage.kt (92%) rename src/main/kotlin/org/winlogon/minechat/{ => storage}/UuidConverter.kt (91%) diff --git a/src/main/kotlin/org/winlogon/minechat/ClientConnection.kt b/src/main/kotlin/org/winlogon/minechat/ClientConnection.kt index 2ad7e25..da73fba 100644 --- a/src/main/kotlin/org/winlogon/minechat/ClientConnection.kt +++ b/src/main/kotlin/org/winlogon/minechat/ClientConnection.kt @@ -12,10 +12,17 @@ import net.kyori.adventure.text.minimessage.tag.resolver.Placeholder import net.kyori.adventure.text.serializer.legacy.LegacyComponentSerializer import org.bukkit.Bukkit +import org.winlogon.minechat.entities.Ban +import org.winlogon.minechat.entities.Client import java.io.DataInputStream import java.io.DataOutputStream import java.net.Socket +import java.net.SocketTimeoutException +import java.util.concurrent.Executors +import java.util.concurrent.ScheduledExecutorService +import java.util.concurrent.TimeUnit +import java.util.concurrent.atomic.AtomicBoolean class ClientConnection( private val socket: Socket, @@ -40,6 +47,17 @@ class ClientConnection( private val writer = DataOutputStream(socket.getOutputStream()) private var client: Client? = null private var running = true + + // Keep-alive timeout tracking + private var lastPacketTime = System.currentTimeMillis() + + /** 15 seconds as per spec */ + private val keepAliveTimeout = 15000L + + /** Send PING every 10 seconds */ + private val pingInterval = 10000L + private val scheduledExecutor: ScheduledExecutorService = Executors.newSingleThreadScheduledExecutor() + private val disconnected = AtomicBoolean(false) fun getClient(): Client? = client @@ -51,62 +69,100 @@ class ClientConnection( override fun run() { try { - while (running) { - val decompressedLen = reader.readInt() - if (decompressedLen <= 0) { - logger.warning("Received non-positive decompressed length: $decompressedLen. Terminating connection.") - break // Terminate connection - } - logger.fine("Received decompressedLen: $decompressedLen") - - val compressedLen = reader.readInt() - if (compressedLen <= 0) { - logger.warning("Received non-positive compressed length: $compressedLen. Terminating connection.") - break // Terminate connection - } - logger.fine("Received compressedLen: $compressedLen") + // Schedule periodic PING packets for keep-alive + scheduledExecutor.scheduleAtFixedRate({ + if (running && !disconnected.get()) { + val currentTime = System.currentTimeMillis() + if (currentTime - lastPacketTime > pingInterval) { + try { + sendMessage(PacketTypes.PING, PingPayload(System.currentTimeMillis())) + logger.fine("Sent PING for keep-alive") + } catch (e: Exception) { + logger.warning("Failed to send PING: ${e.message}") + disconnected.set(true) + } + } + } + }, pingInterval, pingInterval, TimeUnit.MILLISECONDS) - - val compressed = ByteArray(compressedLen) - reader.readFully(compressed) - - val decompressed = Zstd.decompress(compressed, decompressedLen) - if (decompressed.size != decompressedLen) { - logger.warning("Decompressed size mismatch. Expected $decompressedLen, got ${decompressed.size}. Terminating connection.") - break // Terminate connection + while (running) { + // Check for keep-alive timeout before reading next packet + val currentTime = System.currentTimeMillis() + if (currentTime - lastPacketTime > keepAliveTimeout) { + logger.info("Client connection timed out after ${keepAliveTimeout}ms of inactivity") + break } + + // Set socket timeout to avoid blocking indefinitely + socket.soTimeout = 1000 // 1 second timeout for reads + + try { + val decompressedLen = reader.readInt() + if (decompressedLen <= 0) { + logger.warning("Received non-positive decompressed length: $decompressedLen. Terminating connection.") + break // Terminate connection + } + logger.fine("Received decompressedLen: $decompressedLen") + val compressedLen = reader.readInt() + if (compressedLen <= 0) { + logger.warning("Received non-positive compressed length: $compressedLen. Terminating connection.") + break // Terminate connection + } + logger.fine("Received compressedLen: $compressedLen") + + // Update last packet time after successful read + lastPacketTime = currentTime - val mineChatPacket = cborMapper.readValue(decompressed, MineChatPacket::class.java) - logger.fine("Received MineChatPacket: $mineChatPacket") + val compressed = ByteArray(compressedLen) + reader.readFully(compressed) - when (mineChatPacket.packetType) { - PacketTypes.LINK -> { - val payload = cborMapper.convertValue(mineChatPacket.payload, LinkPayload::class.java) - logger.fine("Received LINK message: $payload") - handleAuth(payload) - } - PacketTypes.CAPABILITIES -> { - val payload = cborMapper.convertValue(mineChatPacket.payload, CapabilitiesPayload::class.java) - logger.fine("Received CAPABILITIES message: $payload") - handleCapabilities(payload) - } - PacketTypes.CHAT_MESSAGE -> { - val payload = cborMapper.convertValue(mineChatPacket.payload, ChatMessagePayload::class.java) - logger.fine("Received CHAT_MESSAGE message: $payload") - handleChat(payload) + val decompressed = Zstd.decompress(compressed, decompressedLen) + if (decompressed.size != decompressedLen) { + logger.warning("Decompressed size mismatch. Expected $decompressedLen, got ${decompressed.size}. Terminating connection.") + break // Terminate connection } - PacketTypes.PING -> { - val payload = cborMapper.convertValue(mineChatPacket.payload, PingPayload::class.java) - logger.fine("Received PING message: $payload") - handlePing(payload) - } - PacketTypes.PONG -> { - val payload = cborMapper.convertValue(mineChatPacket.payload, PongPayload::class.java) - logger.fine("Received PONG message: $payload") - handlePong(payload) + + val mineChatPacket = cborMapper.readValue(decompressed, MineChatPacket::class.java) + logger.fine("Received MineChatPacket: $mineChatPacket") + + // Update last packet time for any received packet + lastPacketTime = System.currentTimeMillis() + + when (mineChatPacket.packetType) { + PacketTypes.LINK -> { + val payload = cborMapper.convertValue(mineChatPacket.payload, LinkPayload::class.java) + logger.fine("Received LINK message: $payload") + handleAuth(payload) + } + PacketTypes.CAPABILITIES -> { + val payload = cborMapper.convertValue(mineChatPacket.payload, CapabilitiesPayload::class.java) + logger.fine("Received CAPABILITIES message: $payload") + handleCapabilities(payload) + } + PacketTypes.CHAT_MESSAGE -> { + val payload = cborMapper.convertValue(mineChatPacket.payload, ChatMessagePayload::class.java) + logger.fine("Received CHAT_MESSAGE message: $payload") + handleChat(payload) + } + PacketTypes.PING -> { + val payload = cborMapper.convertValue(mineChatPacket.payload, PingPayload::class.java) + logger.fine("Received PING message: $payload") + handlePing(payload) + } + PacketTypes.PONG -> { + val payload = cborMapper.convertValue(mineChatPacket.payload, PongPayload::class.java) + logger.fine("Received PONG message: $payload") + handlePong(payload) + } + else -> plugin.loggerProvider.logger.warning("Unknown packet type: ${mineChatPacket.packetType}") } - else -> plugin.loggerProvider.logger.warning("Unknown packet type: ${mineChatPacket.packetType}") + } catch (_: SocketTimeoutException) { + // This is expected due to keep-alive timeout checking, continue loop + continue + } catch (e: Exception) { + plugin.loggerProvider.logger.warning("Client error: ${e.message}") + break } } } catch (e: Exception) { @@ -130,67 +186,74 @@ class ClientConnection( close() } - private fun handleAuth(payload: LinkPayload) { + private fun handleAuth(payload: LinkPayload) { + logger.fine("Handling auth with payload: $payload") - logger.fine("Handling auth with payload: $payload") - - val banStorage = plugin.banStorage val clientUuid = payload.clientUuid val linkCode = payload.linkingCode - var ban: Ban? = banStorage.getBan(clientUuid, null) - if (ban != null) { - sendBannedMessage(ban) + // Check ban by client UUID first + banStorage.getBan(clientUuid, null)?.let { + sendBannedMessage(it) return } if (linkCode.isNotEmpty()) { - val link = plugin.linkCodeStorage.find(linkCode) - if (link != null) { - ban = banStorage.getBan(null, link.minecraftUsername) - if (ban != null) { - sendBannedMessage(ban) - return - } - if (link.expiresAt > System.currentTimeMillis()) { - val client = Client(clientUuid = clientUuid, minecraftUuid = link.minecraftUuid, minecraftUsername = link.minecraftUsername) - plugin.clientStorage.add(client) - plugin.linkCodeStorage.remove(link.code) - this.client = client - val linkOkPayload = LinkOkPayload( - minecraftUuid = link.minecraftUuid.toString() - ) - sendMessage(PacketTypes.LINK_OK, linkOkPayload) - sendMessage(PacketTypes.AUTH_OK, AuthOkPayload()) - broadcastMinecraft( - ChatGradients.AUTH, - "${link.minecraftUsername} has successfully authenticated." - ) - - } else { - disconnect("Invalid or expired link code") - } - } else { - disconnect("Invalid or expired link code") } - } else { - val client = plugin.clientStorage.find(clientUuid, null) - if (client != null) { - ban = banStorage.getBan(null, client.minecraftUsername) - if (ban != null) { - sendBannedMessage(ban) - return - } - this.client = client - sendMessage(PacketTypes.AUTH_OK, AuthOkPayload()) - broadcastMinecraft( - ChatGradients.JOIN, - "${client.minecraftUsername} has joined the chat." - ) - } else { - disconnect("Client not registered") - } + handleLinkAuth(clientUuid, linkCode) + return } + + handleExistingClientAuth(clientUuid) + } + + private fun handleLinkAuth(clientUuid: String, linkCode: String) { + val link = plugin.linkCodeStorage.find(linkCode) + ?: return disconnect("Invalid or expired link code") + + // Check ban by Minecraft username + plugin.banStorage.getBan(null, link.minecraftUsername)?.let { + sendBannedMessage(it) + return + } + + if (link.expiresAt <= System.currentTimeMillis()) { + disconnect("Invalid or expired link code") + return + } + + val client = Client( + clientUuid = clientUuid, + minecraftUuid = link.minecraftUuid, + minecraftUsername = link.minecraftUsername + ) + + plugin.clientStorage.add(client) + plugin.linkCodeStorage.remove(link.code) + this.client = client + + sendMessage( + PacketTypes.LINK_OK, + LinkOkPayload(minecraftUuid = link.minecraftUuid.toString()) + ) + sendMessage(PacketTypes.AUTH_OK, AuthOkPayload()) + + broadcastMinecraft(ChatGradients.AUTH, "${link.minecraftUsername} has successfully authenticated.") + } + + private fun handleExistingClientAuth(clientUuid: String) { + val client = plugin.clientStorage.find(clientUuid, null) + ?: return disconnect("Client not registered") + + plugin.banStorage.getBan(null, client.minecraftUsername)?.let { + sendBannedMessage(it) + return + } + + this.client = client + sendMessage(PacketTypes.AUTH_OK, AuthOkPayload()) + + broadcastMinecraft(ChatGradients.JOIN, "${client.minecraftUsername} has joined the chat.") } private fun handleChat(payload: ChatMessagePayload) { @@ -246,6 +309,13 @@ class ClientConnection( val serialized = cborMapper.writeValueAsBytes(mineChatPacket) val compressed = Zstd.compress(serialized) + + // Validate sizes are positive (per spec requirement) + if (serialized.size > Int.MAX_VALUE || compressed.size > Int.MAX_VALUE) { + throw IllegalArgumentException("Packet too large") + } + + // DataOutputStream already uses big-endian signed integers by default writer.writeInt(serialized.size) writer.writeInt(compressed.size) writer.write(compressed) @@ -257,6 +327,8 @@ class ClientConnection( fun close() { running = false + disconnected.set(true) + scheduledExecutor.shutdown() socket.close() } diff --git a/src/main/kotlin/org/winlogon/minechat/MineChatCommandRegister.kt b/src/main/kotlin/org/winlogon/minechat/MineChatCommandRegister.kt index 5047469..55aa1b5 100644 --- a/src/main/kotlin/org/winlogon/minechat/MineChatCommandRegister.kt +++ b/src/main/kotlin/org/winlogon/minechat/MineChatCommandRegister.kt @@ -12,6 +12,8 @@ import net.kyori.adventure.text.minimessage.tag.resolver.Placeholder import org.bukkit.entity.Player import org.bukkit.event.Listener +import org.winlogon.minechat.entities.Ban +import org.winlogon.minechat.entities.LinkCode class MineChatCommandRegister(private val services: MineChatPluginServices) : Listener { fun registerCommands() { @@ -90,7 +92,6 @@ class MineChatCommandRegister(private val services: MineChatPluginServices) : Li } } - fun generateAndSendLinkCode(player: Player) { val code = services.generateRandomLinkCode() @@ -111,12 +112,7 @@ class MineChatCommandRegister(private val services: MineChatPluginServices) : Li ) } - fun getClientConnection(username: String): ClientConnection? { return services.connectedClients.find { it.getClient()?.minecraftUsername == username } } - - - - } diff --git a/src/main/kotlin/org/winlogon/minechat/MineChatConfig.kt b/src/main/kotlin/org/winlogon/minechat/MineChatConfig.kt index 371a212..355a986 100644 --- a/src/main/kotlin/org/winlogon/minechat/MineChatConfig.kt +++ b/src/main/kotlin/org/winlogon/minechat/MineChatConfig.kt @@ -5,7 +5,7 @@ import kotlinx.serialization.Serializable @Serializable data class TlsConfig( - val enabled: Boolean = false, + val enabled: Boolean = true, val keystore: String = "keystore.jks", @SerialName("keystore-password") val keystorePassword: String = "password" diff --git a/src/main/kotlin/org/winlogon/minechat/MineChatLoader.kt b/src/main/kotlin/org/winlogon/minechat/MineChatLoader.kt index ecd9e69..5a22c07 100644 --- a/src/main/kotlin/org/winlogon/minechat/MineChatLoader.kt +++ b/src/main/kotlin/org/winlogon/minechat/MineChatLoader.kt @@ -8,8 +8,6 @@ import org.eclipse.aether.artifact.DefaultArtifact import org.eclipse.aether.graph.Dependency import org.eclipse.aether.repository.RemoteRepository -import java.nio.file.Path - class MineChatLoader : PluginLoader { override fun classloader(classpathBuilder: PluginClasspathBuilder) { val caffeineVersion = "3.2.0" diff --git a/src/main/kotlin/org/winlogon/minechat/MineChatPluginServices.kt b/src/main/kotlin/org/winlogon/minechat/MineChatPluginServices.kt index 417b6ce..69d8ebc 100644 --- a/src/main/kotlin/org/winlogon/minechat/MineChatPluginServices.kt +++ b/src/main/kotlin/org/winlogon/minechat/MineChatPluginServices.kt @@ -3,6 +3,9 @@ package org.winlogon.minechat import net.kyori.adventure.text.minimessage.MiniMessage import org.bukkit.permissions.Permission import org.bukkit.plugin.java.JavaPlugin +import org.winlogon.minechat.storage.BanStorage +import org.winlogon.minechat.storage.ClientStorage +import org.winlogon.minechat.storage.LinkCodeStorage import java.util.concurrent.ConcurrentLinkedQueue interface MineChatPluginServices { diff --git a/src/main/kotlin/org/winlogon/minechat/MineChatServerPlugin.kt b/src/main/kotlin/org/winlogon/minechat/MineChatServerPlugin.kt index 740e9e5..5ed2caf 100644 --- a/src/main/kotlin/org/winlogon/minechat/MineChatServerPlugin.kt +++ b/src/main/kotlin/org/winlogon/minechat/MineChatServerPlugin.kt @@ -4,10 +4,11 @@ import com.charleskorn.kaml.Yaml import io.objectbox.BoxStore import io.papermc.paper.event.player.AsyncChatEvent -import io.papermc.paper.plugin.lifecycle.event.types.LifecycleEvents +import org.winlogon.minechat.storage.BanStorage +import org.winlogon.minechat.storage.ClientStorage +import org.winlogon.minechat.storage.LinkCodeStorage +import org.winlogon.minechat.entities.MyObjectBox -import net.kyori.adventure.text.Component -import net.kyori.adventure.text.format.NamedTextColor import net.kyori.adventure.text.minimessage.MiniMessage import net.kyori.adventure.text.serializer.plain.PlainTextComponentSerializer @@ -23,7 +24,6 @@ import java.security.KeyStore import java.util.concurrent.ConcurrentLinkedQueue import java.util.concurrent.Executors import java.util.concurrent.TimeUnit -import java.util.logging.Logger import javax.net.ssl.KeyManagerFactory import javax.net.ssl.SSLContext @@ -38,7 +38,7 @@ class MineChatServerPlugin : JavaPlugin(), MineChatPluginServices { private val executorService = Executors.newCachedThreadPool() override val connectedClients = ConcurrentLinkedQueue() - val loggerProvider: PluginLoggerProvider = PluginLoggerProvider(this) + lateinit var loggerProvider: PluginLoggerProvider override lateinit var linkCodeStorage: LinkCodeStorage override lateinit var clientStorage: ClientStorage override lateinit var banStorage: BanStorage @@ -70,24 +70,26 @@ class MineChatServerPlugin : JavaPlugin(), MineChatPluginServices { } override fun onLoad() { - permissions = mapOf( - "reload" to Permission("minechat.reload", "Reloads the MineChat configuration."), - "ban" to Permission("minechat.ban", "Bans a player from the MineChat server."), - ) - Bukkit.getPluginManager().addPermissions(permissions.values.toList()) - } - - override fun onEnable() { isFolia = try { Class.forName("io.papermc.paper.threadedregions.RegionizedServer") true - } catch (ignored: ClassNotFoundException) { + } catch (_: ClassNotFoundException) { false } - reloadConfigAndDependencies() + loggerProvider = PluginLoggerProvider(this) + + reloadConfigAndDependencies() dataFolder.mkdirs() + permissions = mapOf( + "reload" to Permission("minechat.reload", "Reloads the MineChat configuration."), + "ban" to Permission("minechat.ban", "Bans a player from the MineChat server."), + ) + Bukkit.getPluginManager().addPermissions(permissions.values.toList()) + } + + override fun onEnable() { boxStore = MyObjectBox.builder().directory(dataFolder).build() linkCodeStorage = LinkCodeStorage(boxStore) clientStorage = ClientStorage(boxStore) @@ -109,7 +111,7 @@ class MineChatServerPlugin : JavaPlugin(), MineChatPluginServices { } try { - val keyStore = java.security.KeyStore.getInstance("JKS") + val keyStore = KeyStore.getInstance("JKS") keyStore.load(keystoreFile.inputStream(), keystorePassword) val keyManagerFactory = KeyManagerFactory.getInstance(KeyManagerFactory.getDefaultAlgorithm()) @@ -193,5 +195,5 @@ class MineChatServerPlugin : JavaPlugin(), MineChatPluginServices { } } - public fun removeClient(client: ClientConnection) = connectedClients.remove(client) + fun removeClient(client: ClientConnection) = connectedClients.remove(client) } diff --git a/src/main/kotlin/org/winlogon/minechat/Protocol.kt b/src/main/kotlin/org/winlogon/minechat/Protocol.kt index d9b15f6..5b54064 100644 --- a/src/main/kotlin/org/winlogon/minechat/Protocol.kt +++ b/src/main/kotlin/org/winlogon/minechat/Protocol.kt @@ -2,7 +2,6 @@ package org.winlogon.minechat import com.fasterxml.jackson.annotation.JsonCreator import com.fasterxml.jackson.annotation.JsonProperty -import com.fasterxml.jackson.databind.annotation.JsonSerialize // Packet Type IDs as defined in the spec object PacketTypes { diff --git a/src/main/kotlin/org/winlogon/minechat/Ban.kt b/src/main/kotlin/org/winlogon/minechat/entities/Ban.kt similarity index 83% rename from src/main/kotlin/org/winlogon/minechat/Ban.kt rename to src/main/kotlin/org/winlogon/minechat/entities/Ban.kt index b78fb37..da3dfab 100644 --- a/src/main/kotlin/org/winlogon/minechat/Ban.kt +++ b/src/main/kotlin/org/winlogon/minechat/entities/Ban.kt @@ -1,9 +1,11 @@ -package org.winlogon.minechat +package org.winlogon.minechat.entities import io.objectbox.annotation.Convert import io.objectbox.annotation.Entity import io.objectbox.annotation.Id +import org.winlogon.minechat.storage.UuidConverter + import java.util.UUID @Entity diff --git a/src/main/kotlin/org/winlogon/minechat/Client.kt b/src/main/kotlin/org/winlogon/minechat/entities/Client.kt similarity index 82% rename from src/main/kotlin/org/winlogon/minechat/Client.kt rename to src/main/kotlin/org/winlogon/minechat/entities/Client.kt index 41e74ab..a70bc6d 100644 --- a/src/main/kotlin/org/winlogon/minechat/Client.kt +++ b/src/main/kotlin/org/winlogon/minechat/entities/Client.kt @@ -1,9 +1,11 @@ -package org.winlogon.minechat +package org.winlogon.minechat.entities import io.objectbox.annotation.Convert import io.objectbox.annotation.Entity import io.objectbox.annotation.Id +import org.winlogon.minechat.storage.UuidConverter + import java.util.UUID @Entity diff --git a/src/main/kotlin/org/winlogon/minechat/LinkCode.kt b/src/main/kotlin/org/winlogon/minechat/entities/LinkCode.kt similarity index 80% rename from src/main/kotlin/org/winlogon/minechat/LinkCode.kt rename to src/main/kotlin/org/winlogon/minechat/entities/LinkCode.kt index 87f096c..5441ce1 100644 --- a/src/main/kotlin/org/winlogon/minechat/LinkCode.kt +++ b/src/main/kotlin/org/winlogon/minechat/entities/LinkCode.kt @@ -1,9 +1,11 @@ -package org.winlogon.minechat +package org.winlogon.minechat.entities import io.objectbox.annotation.Convert import io.objectbox.annotation.Entity import io.objectbox.annotation.Id +import org.winlogon.minechat.storage.UuidConverter + import java.util.UUID @Entity diff --git a/src/main/kotlin/org/winlogon/minechat/BanStorage.kt b/src/main/kotlin/org/winlogon/minechat/storage/BanStorage.kt similarity index 89% rename from src/main/kotlin/org/winlogon/minechat/BanStorage.kt rename to src/main/kotlin/org/winlogon/minechat/storage/BanStorage.kt index 3fadb7b..361a198 100644 --- a/src/main/kotlin/org/winlogon/minechat/BanStorage.kt +++ b/src/main/kotlin/org/winlogon/minechat/storage/BanStorage.kt @@ -1,7 +1,9 @@ -package org.winlogon.minechat +package org.winlogon.minechat.storage import io.objectbox.Box import io.objectbox.BoxStore +import org.winlogon.minechat.entities.Ban +import org.winlogon.minechat.entities.Ban_ class BanStorage(boxStore: BoxStore) { private val banBox: Box = boxStore.boxFor(Ban::class.java) diff --git a/src/main/kotlin/org/winlogon/minechat/ClientStorage.kt b/src/main/kotlin/org/winlogon/minechat/storage/ClientStorage.kt similarity index 94% rename from src/main/kotlin/org/winlogon/minechat/ClientStorage.kt rename to src/main/kotlin/org/winlogon/minechat/storage/ClientStorage.kt index 406c8da..7bfb09b 100644 --- a/src/main/kotlin/org/winlogon/minechat/ClientStorage.kt +++ b/src/main/kotlin/org/winlogon/minechat/storage/ClientStorage.kt @@ -1,10 +1,12 @@ -package org.winlogon.minechat +package org.winlogon.minechat.storage import com.github.benmanes.caffeine.cache.Cache import com.github.benmanes.caffeine.cache.Caffeine import io.objectbox.Box import io.objectbox.BoxStore +import org.winlogon.minechat.entities.Client +import org.winlogon.minechat.entities.Client_ class ClientStorage(boxStore: BoxStore) { private val clientBox: Box = boxStore.boxFor(Client::class.java) diff --git a/src/main/kotlin/org/winlogon/minechat/LinkCodeStorage.kt b/src/main/kotlin/org/winlogon/minechat/storage/LinkCodeStorage.kt similarity index 92% rename from src/main/kotlin/org/winlogon/minechat/LinkCodeStorage.kt rename to src/main/kotlin/org/winlogon/minechat/storage/LinkCodeStorage.kt index 46a7a1f..3f49f91 100644 --- a/src/main/kotlin/org/winlogon/minechat/LinkCodeStorage.kt +++ b/src/main/kotlin/org/winlogon/minechat/storage/LinkCodeStorage.kt @@ -1,10 +1,12 @@ -package org.winlogon.minechat +package org.winlogon.minechat.storage import com.github.benmanes.caffeine.cache.Cache import com.github.benmanes.caffeine.cache.Caffeine import io.objectbox.Box import io.objectbox.BoxStore +import org.winlogon.minechat.entities.LinkCode +import org.winlogon.minechat.entities.LinkCode_ import java.util.concurrent.Executors import java.util.concurrent.TimeUnit diff --git a/src/main/kotlin/org/winlogon/minechat/UuidConverter.kt b/src/main/kotlin/org/winlogon/minechat/storage/UuidConverter.kt similarity index 91% rename from src/main/kotlin/org/winlogon/minechat/UuidConverter.kt rename to src/main/kotlin/org/winlogon/minechat/storage/UuidConverter.kt index 68b2445..795b42e 100644 --- a/src/main/kotlin/org/winlogon/minechat/UuidConverter.kt +++ b/src/main/kotlin/org/winlogon/minechat/storage/UuidConverter.kt @@ -1,4 +1,4 @@ -package org.winlogon.minechat +package org.winlogon.minechat.storage import io.objectbox.converter.PropertyConverter From c8775131f0f3476c45ce11a1da53a5e138bd5570 Mon Sep 17 00:00:00 2001 From: winlogon Date: Mon, 9 Mar 2026 10:38:16 +0100 Subject: [PATCH 17/30] Modify comment for handlePong method Updated comment to indicate future RTT calculation implementation. --- src/main/kotlin/org/winlogon/minechat/ClientConnection.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/kotlin/org/winlogon/minechat/ClientConnection.kt b/src/main/kotlin/org/winlogon/minechat/ClientConnection.kt index da73fba..bc43e52 100644 --- a/src/main/kotlin/org/winlogon/minechat/ClientConnection.kt +++ b/src/main/kotlin/org/winlogon/minechat/ClientConnection.kt @@ -295,7 +295,7 @@ class ClientConnection( private fun handlePong(payload: PongPayload) { // For now, just log that a PONG was received. - // In a more advanced implementation, this would be used for RTT calculation. + // TODO: use this for RTT calculation logger.fine("Received PONG from client with timestamp ${payload.timestampMs}") } From 6705c22a204506c9a333b8a49155f3e41d42638e Mon Sep 17 00:00:00 2001 From: winlogon Date: Mon, 9 Mar 2026 10:43:22 +0100 Subject: [PATCH 18/30] Improve error messages for TLS configuration --- src/main/kotlin/org/winlogon/minechat/MineChatServerPlugin.kt | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/main/kotlin/org/winlogon/minechat/MineChatServerPlugin.kt b/src/main/kotlin/org/winlogon/minechat/MineChatServerPlugin.kt index 5ed2caf..589651f 100644 --- a/src/main/kotlin/org/winlogon/minechat/MineChatServerPlugin.kt +++ b/src/main/kotlin/org/winlogon/minechat/MineChatServerPlugin.kt @@ -98,7 +98,7 @@ class MineChatServerPlugin : JavaPlugin(), MineChatPluginServices { MineChatCommandRegister(this).registerCommands() if (!mineChatConfig.tls.enabled) { - loggerProvider.logger.severe("MineChat server cannot start: TLS is disabled in config.yml. TLS is mandatory as per specification.") + loggerProvider.logger.severe("MineChat server cannot start: TLS is disabled in config.yml, but it is mandatory.") return } @@ -106,7 +106,7 @@ class MineChatServerPlugin : JavaPlugin(), MineChatPluginServices { val keystorePassword = mineChatConfig.tls.keystorePassword.toCharArray() if (!keystoreFile.exists()) { - loggerProvider.logger.severe("MineChat server cannot start: Keystore file not found at ${keystoreFile.absolutePath}. TLS is mandatory as per specification.") + loggerProvider.logger.severe("MineChat server cannot start: TLS is mandatory, but no keystore file was found at ${keystoreFile.absolutePath}.") return } From 133c729429bb35f156b63109f2c861d788d7a724 Mon Sep 17 00:00:00 2001 From: winlogon Date: Wed, 11 Mar 2026 02:42:50 +0100 Subject: [PATCH 19/30] feat(protocol): migrate to kotlinx CBOR serialization Replace Jackson CBOR with kotlinx.serialization CBOR to simplify packet encoding/decoding and reduce runtime reflection. The protocol envelope now stores raw CBOR payload bytes, allowing payloads to be decoded only when needed and improving efficiency. Summary of changes: - Replace Jackson CBOR with kotlinx.serialization CBOR - Add kotlinx-serialization-cbor dependency - Convert protocol models to @Serializable - Store packet payloads as raw CBOR byte arrays - Update client packet encode/decode logic - Add generic sendMessage with reified serialization - Implement plugin-level broadcast serialization - Enforce LINK_OK -> CAPABILITIES -> AUTH_OK auth flow - Track client RTT via PING/PONG handling - Add moderation packets for ban and kick events - Add basic Markdown serializer for chat content - Broadcast moderation and admin reload events - Improve error handling and socket shutdown safety --- build.gradle.kts | 1 + gradle/libs.versions.toml | 1 + .../org/winlogon/minechat/ClientConnection.kt | 145 ++++++++++++------ .../winlogon/minechat/MarkdownSerializer.kt | 47 ++++++ .../minechat/MineChatCommandRegister.kt | 34 +++- .../minechat/MineChatPluginServices.kt | 1 + .../winlogon/minechat/MineChatServerPlugin.kt | 52 +++++-- .../kotlin/org/winlogon/minechat/Protocol.kt | 102 ++++++++---- src/main/resources/config.yml | 2 +- 9 files changed, 296 insertions(+), 89 deletions(-) create mode 100644 src/main/kotlin/org/winlogon/minechat/MarkdownSerializer.kt diff --git a/build.gradle.kts b/build.gradle.kts index 7c62912..28abcad 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -100,6 +100,7 @@ dependencies { implementation("com.fasterxml.jackson.module:jackson-module-kotlin:2.17.1") implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:" + libs.versions.kotlinx.serialization.json.get()) + implementation("org.jetbrains.kotlinx:kotlinx-serialization-cbor:" + libs.versions.kotlinx.serialization.cbor.get()) testRuntimeOnly("org.junit.platform:junit-platform-launcher:1.11.4") testImplementation("io.papermc.paper:paper-api:1.21.6-R0.1-SNAPSHOT") diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 6401bb1..10d433c 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,4 +1,5 @@ [versions] kotlinx-serialization-json = "1.9.0" +kotlinx-serialization-cbor = "1.9.0" kaml = "0.102.0" caffeine = "3.1.8" diff --git a/src/main/kotlin/org/winlogon/minechat/ClientConnection.kt b/src/main/kotlin/org/winlogon/minechat/ClientConnection.kt index bc43e52..dca88b5 100644 --- a/src/main/kotlin/org/winlogon/minechat/ClientConnection.kt +++ b/src/main/kotlin/org/winlogon/minechat/ClientConnection.kt @@ -1,9 +1,10 @@ package org.winlogon.minechat -import com.fasterxml.jackson.databind.ObjectMapper -import com.fasterxml.jackson.dataformat.cbor.CBORFactory -import com.fasterxml.jackson.module.kotlin.KotlinModule import com.github.luben.zstd.Zstd +import kotlinx.serialization.ExperimentalSerializationApi +import kotlinx.serialization.cbor.Cbor +import kotlinx.serialization.decodeFromByteArray +import kotlinx.serialization.encodeToByteArray import net.kyori.adventure.text.Component import net.kyori.adventure.text.format.NamedTextColor @@ -29,28 +30,36 @@ class ClientConnection( private val plugin: MineChatServerPlugin, private val miniMessage: MiniMessage ) : Runnable { - private val logger = plugin.logger - object ChatGradients { - val JOIN = Pair("#27AE60", "#2ECC71") - val LEAVE = Pair("#C0392B", "#E74C3C") - val AUTH = Pair("#8E44AD", "#9B59B6") - val INFO = Pair("#2980B9", "#3498DB") - } + @PublishedApi + internal val logger = plugin.logger companion object { const val MINECHAT_PREFIX_STRING = "&8[&3MineChat&8]" val MINECHAT_PREFIX_COMPONENT: Component = LegacyComponentSerializer.legacyAmpersand().deserialize(MINECHAT_PREFIX_STRING) } - private val cborMapper = ObjectMapper(CBORFactory()).registerModule(KotlinModule.Builder().build()) - private val reader = DataInputStream(socket.getInputStream()) - private val writer = DataOutputStream(socket.getOutputStream()) + @OptIn(ExperimentalSerializationApi::class) + @PublishedApi + internal val cbor = Cbor { + ignoreUnknownKeys = true + encodeDefaults = false + } + + internal val reader = DataInputStream(socket.getInputStream()) + @PublishedApi + internal val writer = DataOutputStream(socket.getOutputStream()) private var client: Client? = null private var running = true + private var linkAuthCompleted = false // Tracks if LINK_OK has been sent + private var capabilitiesReceived = false // Tracks if CAPABILITIES has been received // Keep-alive timeout tracking private var lastPacketTime = System.currentTimeMillis() + /** Calculated Round Trip Time in milliseconds */ + var currentRtt: Long = -1 + private set + /** 15 seconds as per spec */ private val keepAliveTimeout = 15000L @@ -67,6 +76,7 @@ class ClientConnection( Bukkit.broadcast(formatPrefixed(finalMessage)) } + @OptIn(ExperimentalSerializationApi::class) override fun run() { try { // Schedule periodic PING packets for keep-alive @@ -102,14 +112,12 @@ class ClientConnection( logger.warning("Received non-positive decompressed length: $decompressedLen. Terminating connection.") break // Terminate connection } - logger.fine("Received decompressedLen: $decompressedLen") val compressedLen = reader.readInt() if (compressedLen <= 0) { logger.warning("Received non-positive compressed length: $compressedLen. Terminating connection.") break // Terminate connection } - logger.fine("Received compressedLen: $compressedLen") // Update last packet time after successful read lastPacketTime = currentTime @@ -123,50 +131,53 @@ class ClientConnection( break // Terminate connection } - val mineChatPacket = cborMapper.readValue(decompressed, MineChatPacket::class.java) - logger.fine("Received MineChatPacket: $mineChatPacket") + val mineChatPacket = cbor.decodeFromByteArray(decompressed) // Update last packet time for any received packet lastPacketTime = System.currentTimeMillis() when (mineChatPacket.packetType) { PacketTypes.LINK -> { - val payload = cborMapper.convertValue(mineChatPacket.payload, LinkPayload::class.java) + val payload = cbor.decodeFromByteArray(mineChatPacket.payload) logger.fine("Received LINK message: $payload") handleAuth(payload) } PacketTypes.CAPABILITIES -> { - val payload = cborMapper.convertValue(mineChatPacket.payload, CapabilitiesPayload::class.java) + val payload = cbor.decodeFromByteArray(mineChatPacket.payload) logger.fine("Received CAPABILITIES message: $payload") handleCapabilities(payload) } PacketTypes.CHAT_MESSAGE -> { - val payload = cborMapper.convertValue(mineChatPacket.payload, ChatMessagePayload::class.java) + val payload = cbor.decodeFromByteArray(mineChatPacket.payload) logger.fine("Received CHAT_MESSAGE message: $payload") handleChat(payload) } PacketTypes.PING -> { - val payload = cborMapper.convertValue(mineChatPacket.payload, PingPayload::class.java) + val payload = cbor.decodeFromByteArray(mineChatPacket.payload) logger.fine("Received PING message: $payload") handlePing(payload) } PacketTypes.PONG -> { - val payload = cborMapper.convertValue(mineChatPacket.payload, PongPayload::class.java) + val payload = cbor.decodeFromByteArray(mineChatPacket.payload) logger.fine("Received PONG message: $payload") handlePong(payload) } - else -> plugin.loggerProvider.logger.warning("Unknown packet type: ${mineChatPacket.packetType}") + else -> logger.warning("Unknown packet type: ${mineChatPacket.packetType}") } } catch (_: SocketTimeoutException) { // This is expected due to keep-alive timeout checking, continue loop continue } catch (e: Exception) { - plugin.loggerProvider.logger.warning("Client error: ${e.message}") + if (running && !disconnected.get()) { + logger.warning("Client error during packet processing: ${e.message}") + } break } } } catch (e: Exception) { - plugin.loggerProvider.logger.warning("Client error: ${e.message}") + if (running && !disconnected.get()) { + logger.warning("Client error in run loop: ${e.message}") + } } finally { client?.let { broadcastMinecraft(ChatGradients.LEAVE, "${it.minecraftUsername} has left the chat.") @@ -232,13 +243,15 @@ class ClientConnection( plugin.linkCodeStorage.remove(link.code) this.client = client + // Per spec: send LINK_OK, wait for CAPABILITIES, then send AUTH_OK sendMessage( PacketTypes.LINK_OK, LinkOkPayload(minecraftUuid = link.minecraftUuid.toString()) ) - sendMessage(PacketTypes.AUTH_OK, AuthOkPayload()) + linkAuthCompleted = true - broadcastMinecraft(ChatGradients.AUTH, "${link.minecraftUsername} has successfully authenticated.") + // Now wait for CAPABILITIES before completing auth + logger.fine("Sent LINK_OK, waiting for CAPABILITIES...") } private fun handleExistingClientAuth(clientUuid: String) { @@ -251,15 +264,26 @@ class ClientConnection( } this.client = client - sendMessage(PacketTypes.AUTH_OK, AuthOkPayload()) - broadcastMinecraft(ChatGradients.JOIN, "${client.minecraftUsername} has joined the chat.") + // Per spec: send LINK_OK, wait for CAPABILITIES, then send AUTH_OK + sendMessage( + PacketTypes.LINK_OK, + LinkOkPayload(minecraftUuid = client.minecraftUuid.toString()) + ) + linkAuthCompleted = true + + // Now wait for CAPABILITIES before completing auth + logger.fine("Sent LINK_OK for reconnection, waiting for CAPABILITIES...") } private fun handleChat(payload: ChatMessagePayload) { client?.let { - // Check payload.format here if needed. Assuming "commonmark" for now. - val message = miniMessage.deserialize(payload.content) // Deserialize the content string to an Adventure Component + val message = if (payload.format == "commonmark") { + MarkdownSerializer.markdown().deserialize(payload.content) + } else { + miniMessage.deserialize(payload.content) + } + val usernamePlaceholder = Component.text(it.minecraftUsername, NamedTextColor.DARK_GREEN) val formattedMsg = miniMessage.deserialize( ": ", @@ -268,22 +292,48 @@ class ClientConnection( ) val finalMsg = formatPrefixed(formattedMsg) Bukkit.broadcast(finalMsg) + + val markdownContent = MarkdownSerializer.markdown().serialize(message) val chatMessagePayload = ChatMessagePayload( format = "commonmark", - content = miniMessage.serialize(message) + content = markdownContent ) plugin.broadcastToClients(PacketTypes.CHAT_MESSAGE, chatMessagePayload) } } private fun handleCapabilities(payload: CapabilitiesPayload) { + // Per spec, CAPABILITIES must come after LINK_OK + if (!linkAuthCompleted) { + logger.warning("Received CAPABILITIES packet before LINK_OK. Disconnecting.") + disconnect("Received CAPABILITIES before completing linking.") + return + } + + if (capabilitiesReceived) { + logger.warning("Received duplicate CAPABILITIES packet. Ignoring.") + return + } + client?.let { it.supportsComponents = payload.supportsComponents plugin.clientStorage.add(it) // Update the client in storage - logger.fine("Client ${it.minecraftUsername} updated with capabilities: supportsComponents=${it.supportsComponents}") + + // Send AUTH_OK to complete the authentication flow + sendMessage(PacketTypes.AUTH_OK, AuthOkPayload()) + capabilitiesReceived = true + + // Send join message + if (it.supportsComponents) { + broadcastMinecraft(ChatGradients.JOIN, "${it.minecraftUsername} has joined the chat.") + } else { + broadcastMinecraft(ChatGradients.AUTH, "${it.minecraftUsername} has successfully authenticated.") + } + + logger.fine("Client ${it.minecraftUsername} authenticated with capabilities: supportsComponents=${it.supportsComponents}") } ?: run { - logger.warning("Received CAPABILITIES packet before client was authenticated. Disconnecting.") - disconnect("Received CAPABILITIES before authentication.") + logger.warning("Received CAPABILITIES packet but client is null. This should not happen.") + disconnect("Internal error: client not found.") } } @@ -294,20 +344,17 @@ class ClientConnection( } private fun handlePong(payload: PongPayload) { - // For now, just log that a PONG was received. - // TODO: use this for RTT calculation - logger.fine("Received PONG from client with timestamp ${payload.timestampMs}") + val now = System.currentTimeMillis() + currentRtt = now - payload.timestampMs + logger.fine("Received PONG from client with RTT: ${currentRtt}ms") } - @Suppress("UNCHECKED_CAST") - fun sendMessage(packetType: Int, payload: Any) { - logger.fine("Sending packet type $packetType with payload: $payload") + @OptIn(ExperimentalSerializationApi::class) + inline fun sendMessage(packetType: Int, payload: T) { try { - // Convert the payload object to a Map - val payloadMap = cborMapper.convertValue(payload, Map::class.java) as Map - val mineChatPacket = MineChatPacket(packetType, payloadMap) - - val serialized = cborMapper.writeValueAsBytes(mineChatPacket) + val payloadBytes = cbor.encodeToByteArray(payload) + val mineChatPacket = MineChatPacket(packetType, payloadBytes) + val serialized = cbor.encodeToByteArray(mineChatPacket) val compressed = Zstd.compress(serialized) // Validate sizes are positive (per spec requirement) @@ -321,7 +368,7 @@ class ClientConnection( writer.write(compressed) writer.flush() } catch (e: Exception) { - plugin.loggerProvider.logger.warning("Error sending message: ${e.message}") + logger.warning("Error sending message: ${e.message}") } } @@ -329,7 +376,9 @@ class ClientConnection( running = false disconnected.set(true) scheduledExecutor.shutdown() - socket.close() + try { + socket.close() + } catch (_: Exception) {} } fun formatPrefixed(message: Component): Component { diff --git a/src/main/kotlin/org/winlogon/minechat/MarkdownSerializer.kt b/src/main/kotlin/org/winlogon/minechat/MarkdownSerializer.kt new file mode 100644 index 0000000..0474f6e --- /dev/null +++ b/src/main/kotlin/org/winlogon/minechat/MarkdownSerializer.kt @@ -0,0 +1,47 @@ +package org.winlogon.minechat + +import net.kyori.adventure.text.Component +import net.kyori.adventure.text.TextComponent +import net.kyori.adventure.text.format.TextDecoration +import net.kyori.adventure.text.serializer.ComponentSerializer + +class MarkdownSerializer : ComponentSerializer { + override fun deserialize(input: String): Component { + // Basic implementation: for now we just return a TextComponent. + // A full markdown parser would be needed here (e.g., using commonmark-java). + return Component.text(input) + } + + override fun serialize(component: Component): String { + val sb = StringBuilder() + serializeComponent(component, sb) + return sb.toString() + } + + private fun serializeComponent(component: Component, sb: StringBuilder) { + if (component is TextComponent) { + val hasBold = component.hasDecoration(TextDecoration.BOLD) + val hasItalic = component.hasDecoration(TextDecoration.ITALIC) + val hasStrikethrough = component.hasDecoration(TextDecoration.STRIKETHROUGH) + + if (hasBold) sb.append("**") + if (hasItalic) sb.append("*") + if (hasStrikethrough) sb.append("~~") + + sb.append(component.content()) + + if (hasStrikethrough) sb.append("~~") + if (hasItalic) sb.append("*") + if (hasBold) sb.append("**") + } + + for (child in component.children()) { + serializeComponent(child, sb) + } + } + + companion object { + private val INSTANCE = MarkdownSerializer() + fun markdown(): MarkdownSerializer = INSTANCE + } +} diff --git a/src/main/kotlin/org/winlogon/minechat/MineChatCommandRegister.kt b/src/main/kotlin/org/winlogon/minechat/MineChatCommandRegister.kt index 55aa1b5..d4ef1ca 100644 --- a/src/main/kotlin/org/winlogon/minechat/MineChatCommandRegister.kt +++ b/src/main/kotlin/org/winlogon/minechat/MineChatCommandRegister.kt @@ -31,6 +31,13 @@ class MineChatCommandRegister(private val services: MineChatPluginServices) : Li .executes { ctx -> services.reloadConfigAndDependencies() ctx.source.sender.sendMessage(Component.text("MineChat config reloaded.").color(NamedTextColor.GREEN)) + + val infoMsg = "MineChat configuration has been reloaded by an administrator." + val chatPayload = ChatMessagePayload( + format = "commonmark", + content = "$infoMsg" + ) + services.broadcastToClients(PacketTypes.CHAT_MESSAGE, chatPayload) Command.SINGLE_SUCCESS } .build() @@ -45,8 +52,21 @@ class MineChatCommandRegister(private val services: MineChatPluginServices) : Li ctx.source.sender.sendMessage(Component.text("Player not found.").color(NamedTextColor.RED)) return@executes 0 } - val ban = Ban(minecraftUsername = playerName, reason = "Banned by an operator.") + val reason = "Banned by an operator." + val ban = Ban(minecraftUsername = playerName, reason = reason) services.banStorage.add(ban) + + // Notify other clients + val modPayload = ModerationPayload( + action = ModerationAction.BAN, + scope = ModerationScope.GLOBAL, + reason = reason + ) + services.broadcastToClients(PacketTypes.MODERATION, modPayload) + + // Disconnect the client if online + getClientConnection(playerName)?.disconnect(reason) + ctx.source.sender.sendMessage(Component.text("Banned $playerName from MineChat.").color(NamedTextColor.GREEN)) Command.SINGLE_SUCCESS } @@ -75,7 +95,17 @@ class MineChatCommandRegister(private val services: MineChatPluginServices) : Li ctx.source.sender.sendMessage(Component.text("Player not found or not connected via MineChat.").color(NamedTextColor.RED)) return@executes 0 } - clientConnection.disconnect("Kicked by an operator.") + val reason = "Kicked by an operator." + + // Notify other clients + val modPayload = ModerationPayload( + action = ModerationAction.KICK, + scope = ModerationScope.LOCAL, + reason = reason + ) + services.broadcastToClients(PacketTypes.MODERATION, modPayload) + + clientConnection.disconnect(reason) ctx.source.sender.sendMessage(Component.text("Kicked $playerName from MineChat.").color(NamedTextColor.GREEN)) Command.SINGLE_SUCCESS } diff --git a/src/main/kotlin/org/winlogon/minechat/MineChatPluginServices.kt b/src/main/kotlin/org/winlogon/minechat/MineChatPluginServices.kt index 69d8ebc..7cb7256 100644 --- a/src/main/kotlin/org/winlogon/minechat/MineChatPluginServices.kt +++ b/src/main/kotlin/org/winlogon/minechat/MineChatPluginServices.kt @@ -20,4 +20,5 @@ interface MineChatPluginServices { fun reloadConfigAndDependencies() fun generateRandomLinkCode(): String + fun broadcastToClients(packetType: Int, payload: T) } diff --git a/src/main/kotlin/org/winlogon/minechat/MineChatServerPlugin.kt b/src/main/kotlin/org/winlogon/minechat/MineChatServerPlugin.kt index 589651f..e54622b 100644 --- a/src/main/kotlin/org/winlogon/minechat/MineChatServerPlugin.kt +++ b/src/main/kotlin/org/winlogon/minechat/MineChatServerPlugin.kt @@ -28,6 +28,7 @@ import javax.net.ssl.KeyManagerFactory import javax.net.ssl.SSLContext import kotlinx.serialization.decodeFromString +import kotlinx.serialization.serializer class MineChatServerPlugin : JavaPlugin(), MineChatPluginServices { private var serverSocket: ServerSocket? = null @@ -150,20 +151,25 @@ class MineChatServerPlugin : JavaPlugin(), MineChatPluginServices { } serverThread?.start() + // Register chat listener server.pluginManager.registerEvents(object : Listener { @EventHandler fun onChat(event: AsyncChatEvent) { - val plainMsg = PlainTextComponentSerializer.plainText().serialize(event.message()) - // TODO: actually format message as CommonMark - val chatMessagePayload = ChatMessagePayload( - format = "commonmark", - content = plainMsg - ) - broadcastToClients(PacketTypes.CHAT_MESSAGE, chatMessagePayload) + this@MineChatServerPlugin.onChat(event) } }, this) } + @EventHandler + fun onChat(event: AsyncChatEvent) { + val markdownMsg = MarkdownSerializer.markdown().serialize(event.message()) + val chatMessagePayload = ChatMessagePayload( + format = "commonmark", + content = markdownMsg + ) + broadcastToClients(PacketTypes.CHAT_MESSAGE, chatMessagePayload) + } + override fun onDisable() { loggerProvider.logger.info("Disabling MineChatServerPlugin") isServerRunning = false @@ -173,21 +179,45 @@ class MineChatServerPlugin : JavaPlugin(), MineChatPluginServices { executorService.shutdownNow() try { executorService.awaitTermination(10, TimeUnit.SECONDS) - } catch (e: InterruptedException) { + } catch (_: InterruptedException) { Thread.currentThread().interrupt() } boxStore.close() try { serverThread?.join() - } catch (e: InterruptedException) { + } catch (_: InterruptedException) { Thread.currentThread().interrupt() } } - fun broadcastToClients(packetType: Int, payload: Any) { + @OptIn(kotlinx.serialization.ExperimentalSerializationApi::class) + private val cbor = kotlinx.serialization.cbor.Cbor { + ignoreUnknownKeys = true + encodeDefaults = false + } + + override fun broadcastToClients(packetType: Int, payload: T) { + val payloadBytes = try { + @OptIn(kotlinx.serialization.InternalSerializationApi::class, kotlinx.serialization.ExperimentalSerializationApi::class) + val serializer = payload::class.serializer() + @Suppress("UNCHECKED_CAST") + cbor.encodeToByteArray(serializer as kotlinx.serialization.KSerializer, payload) + } catch (e: Exception) { + loggerProvider.logger.warning("Error encoding payload for broadcast: ${e.message}") + return + } + connectedClients.forEach { client -> try { - client.sendMessage(packetType, payload) + // Send already encoded payload + val mineChatPacket = MineChatPacket(packetType, payloadBytes) + val serialized = cbor.encodeToByteArray(MineChatPacket.serializer(), mineChatPacket) + val compressed = com.github.luben.zstd.Zstd.compress(serialized) + + client.writer.writeInt(serialized.size) + client.writer.writeInt(compressed.size) + client.writer.write(compressed) + client.writer.flush() } catch (e: Exception) { loggerProvider.logger.warning("Error sending message to client: ${e.message}") connectedClients.remove(client) diff --git a/src/main/kotlin/org/winlogon/minechat/Protocol.kt b/src/main/kotlin/org/winlogon/minechat/Protocol.kt index 5b54064..7a9c8fa 100644 --- a/src/main/kotlin/org/winlogon/minechat/Protocol.kt +++ b/src/main/kotlin/org/winlogon/minechat/Protocol.kt @@ -1,7 +1,7 @@ package org.winlogon.minechat -import com.fasterxml.jackson.annotation.JsonCreator -import com.fasterxml.jackson.annotation.JsonProperty +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable // Packet Type IDs as defined in the spec object PacketTypes { @@ -18,6 +18,23 @@ object PacketTypes { const val DISCONNECT = 0x80 } +object ModerationAction { + const val KICK = 0 + const val BAN = 1 +} + +object ModerationScope { + const val GLOBAL = 0 + const val LOCAL = 1 +} + +object ChatGradients { + val JOIN = Pair("#27AE60", "#2ECC71") + val LEAVE = Pair("#C0392B", "#E74C3C") + val AUTH = Pair("#8E44AD", "#9B59B6") + val INFO = Pair("#2980B9", "#3498DB") +} + /** * Represents the common packet envelope as defined by the MineChat Protocol. * { @@ -25,47 +42,78 @@ object PacketTypes { * 1: payload (map) * } */ -data class MineChatPacket @JsonCreator constructor( - @JsonProperty("0") val packetType: Int, - @JsonProperty("1") val payload: Map // Payload fields use integer keys -) +@Serializable +data class MineChatPacket( + @SerialName("0") val packetType: Int, + @SerialName("1") val payload: ByteArray // Raw CBOR bytes for the payload map +) { + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + + other as MineChatPacket + + if (packetType != other.packetType) return false + if (!payload.contentEquals(other.payload)) return false + + return true + } + + override fun hashCode(): Int { + var result = packetType + result = 31 * result + payload.contentHashCode() + return result + } +} // Payload data classes -data class LinkPayload @JsonCreator constructor( - @JsonProperty("0") val linkingCode: String, - @JsonProperty("1") val clientUuid: String +@Serializable +data class LinkPayload( + @SerialName("0") val linkingCode: String, + @SerialName("1") val clientUuid: String ) -data class LinkOkPayload @JsonCreator constructor( - @JsonProperty("0") val minecraftUuid: String +@Serializable +data class LinkOkPayload( + @SerialName("0") val minecraftUuid: String ) -data class CapabilitiesPayload @JsonCreator constructor( - @JsonProperty("0") val supportsComponents: Boolean +@Serializable +data class CapabilitiesPayload( + @SerialName("0") val supportsComponents: Boolean ) +@Serializable class AuthOkPayload -data class ChatMessagePayload @JsonCreator constructor( - @JsonProperty("0") val format: String, - @JsonProperty("1") val content: String +@Serializable +data class ChatMessagePayload( + @SerialName("0") val format: String, + @SerialName("1") val content: String ) -data class PingPayload @JsonCreator constructor( - @JsonProperty("0") val timestampMs: Long +@Serializable +data class PingPayload( + @SerialName("0") val timestampMs: Long ) -data class PongPayload @JsonCreator constructor( - @JsonProperty("0") val timestampMs: Long +@Serializable +data class PongPayload( + @SerialName("0") val timestampMs: Long ) -data class ModerationPayload @JsonCreator constructor( - @JsonProperty("0") val action: Int, - @JsonProperty("1") val scope: Int, - @JsonProperty("2") val reason: String?, // Optional - @JsonProperty("3") val durationSeconds: Int? // Optional +@Serializable +data class ModerationPayload( + @SerialName("0") val action: Int, + @SerialName("1") val scope: Int, + @SerialName("2") val reason: String? = null, + @SerialName("3") val durationSeconds: Int? = null ) -data class DisconnectPayload @JsonCreator constructor( - @JsonProperty("0") val reason: String +@Serializable +data class DisconnectPayload( + @SerialName("2") val reason: String ) + +@Serializable +class EmptyPayload \ No newline at end of file diff --git a/src/main/resources/config.yml b/src/main/resources/config.yml index 347c5b0..89ff22f 100644 --- a/src/main/resources/config.yml +++ b/src/main/resources/config.yml @@ -12,7 +12,7 @@ expiry-code-minutes: 5 tls: # Enable or disable TLS. # If enabled, you must provide a keystore. - enabled: false + enabled: true # The name of the keystore file in the plugin's data folder. keystore: "keystore.jks" # The password for the keystore. From d40bd5f35859c8158778761165d4b44c9e203c8a Mon Sep 17 00:00:00 2001 From: winlogon Date: Thu, 12 Mar 2026 00:31:59 +0100 Subject: [PATCH 20/30] chore: fix some small oversights in moderation structs --- .../org/winlogon/minechat/MineChatCommandRegister.kt | 4 ++-- src/main/kotlin/org/winlogon/minechat/Protocol.kt | 12 +++++++----- 2 files changed, 9 insertions(+), 7 deletions(-) diff --git a/src/main/kotlin/org/winlogon/minechat/MineChatCommandRegister.kt b/src/main/kotlin/org/winlogon/minechat/MineChatCommandRegister.kt index d4ef1ca..b6dd6e4 100644 --- a/src/main/kotlin/org/winlogon/minechat/MineChatCommandRegister.kt +++ b/src/main/kotlin/org/winlogon/minechat/MineChatCommandRegister.kt @@ -59,7 +59,7 @@ class MineChatCommandRegister(private val services: MineChatPluginServices) : Li // Notify other clients val modPayload = ModerationPayload( action = ModerationAction.BAN, - scope = ModerationScope.GLOBAL, + scope = ModerationScope.ACCOUNT, reason = reason ) services.broadcastToClients(PacketTypes.MODERATION, modPayload) @@ -100,7 +100,7 @@ class MineChatCommandRegister(private val services: MineChatPluginServices) : Li // Notify other clients val modPayload = ModerationPayload( action = ModerationAction.KICK, - scope = ModerationScope.LOCAL, + scope = ModerationScope.CLIENT, reason = reason ) services.broadcastToClients(PacketTypes.MODERATION, modPayload) diff --git a/src/main/kotlin/org/winlogon/minechat/Protocol.kt b/src/main/kotlin/org/winlogon/minechat/Protocol.kt index 7a9c8fa..d60422c 100644 --- a/src/main/kotlin/org/winlogon/minechat/Protocol.kt +++ b/src/main/kotlin/org/winlogon/minechat/Protocol.kt @@ -19,13 +19,15 @@ object PacketTypes { } object ModerationAction { - const val KICK = 0 - const val BAN = 1 + const val WARN = 0 + const val MUTE = 1 + const val KICK = 2 + const val BAN = 3 } object ModerationScope { - const val GLOBAL = 0 - const val LOCAL = 1 + const val CLIENT = 0 + const val ACCOUNT = 1 } object ChatGradients { @@ -112,7 +114,7 @@ data class ModerationPayload( @Serializable data class DisconnectPayload( - @SerialName("2") val reason: String + @SerialName("0") val reason: String ) @Serializable From 8000ab3b4e3d76300ee18c6a2af86b3f9f53768f Mon Sep 17 00:00:00 2001 From: winlogon Date: Thu, 12 Mar 2026 02:43:50 +0100 Subject: [PATCH 21/30] feat: use virtual thread executor for client connections --- src/main/kotlin/org/winlogon/minechat/MineChatServerPlugin.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/kotlin/org/winlogon/minechat/MineChatServerPlugin.kt b/src/main/kotlin/org/winlogon/minechat/MineChatServerPlugin.kt index e54622b..9bc1a9e 100644 --- a/src/main/kotlin/org/winlogon/minechat/MineChatServerPlugin.kt +++ b/src/main/kotlin/org/winlogon/minechat/MineChatServerPlugin.kt @@ -36,7 +36,7 @@ class MineChatServerPlugin : JavaPlugin(), MineChatPluginServices { private lateinit var boxStore: BoxStore @Volatile private var isServerRunning: Boolean = false private var serverThread: Thread? = null - private val executorService = Executors.newCachedThreadPool() + private val executorService = Executors.newVirtualThreadPerTaskExecutor() override val connectedClients = ConcurrentLinkedQueue() lateinit var loggerProvider: PluginLoggerProvider From 32ccfb56fdb84a84e5fcc8b28fd76c6f779721a2 Mon Sep 17 00:00:00 2001 From: winlogon Date: Thu, 12 Mar 2026 02:44:10 +0100 Subject: [PATCH 22/30] docs: add TLS keystore guide as it's required by the spec --- README.md | 31 ++++++++++++++++++++++++++++++- 1 file changed, 30 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 844fb06..a3def29 100644 --- a/README.md +++ b/README.md @@ -23,7 +23,36 @@ The plugin works by generating temporary codes that players can use to authentic - Download the latest release from the [releases](https://github.com/walker84837/MineChat-Server/releases/latest) page. - Place the downloaded JAR file into your server's `plugins` directory. -3. **Start Your Server**: Start or restart your Paper server to load the MineChat Server Plugin. +3. **Generate a TLS keystore**: + MineChat requires TLS to be enabled. Generate a self-signed keystore: + ```bash + keytool -genkeypair \ + -alias minechat \ + -keyalg RSA \ + -keysize 2048 \ + -storetype JKS \ + -keystore keystore.jks \ + -validity 3650 \ + -storepassword \ + -keypassword \ + -dname "CN=localhost, OU=Dev, O=MineChat, L=City, ST=State, C=US" + ``` + +4. **Configure TLS**: + - Copy the generated `keystore.jks` to your server's plugin data folder: + ``` + /plugins/MineChat/keystore.jks + ``` + - Edit the generated `config.yml` in the plugin folder, or create one with: + ```yaml + port: 25575 + tls: + enabled: true + keystore: "keystore.jks" + keystore-password: "your-password" + ``` + +5. **Start Your Server**: Start or restart your Paper server to load the MineChat Server Plugin. ## Usage From dc7c91b4c8a275651f34dcdd5fbfcc9ad386b8fe Mon Sep 17 00:00:00 2001 From: winlogon Date: Thu, 12 Mar 2026 18:24:54 +0100 Subject: [PATCH 23/30] fix: use correct linking flow - Implement Markdown parsing from client - Add broadcasting messages with gradients - Implement moderation enforcement --- gradle/libs.versions.toml | 4 +- objectbox-models/default.json | 74 +-- objectbox-models/default.json.bak | 120 ----- .../org/winlogon/minechat/ClientConnection.kt | 476 +++++++++--------- .../winlogon/minechat/MarkdownSerializer.kt | 88 +++- .../minechat/MineChatPluginServices.kt | 2 +- .../winlogon/minechat/MineChatServerPlugin.kt | 25 +- .../kotlin/org/winlogon/minechat/Protocol.kt | 207 +++++--- 8 files changed, 523 insertions(+), 473 deletions(-) delete mode 100644 objectbox-models/default.json.bak diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 10d433c..698201d 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,5 +1,5 @@ [versions] -kotlinx-serialization-json = "1.9.0" -kotlinx-serialization-cbor = "1.9.0" +kotlinx-serialization-json = "1.10.0" +kotlinx-serialization-cbor = "1.10.0" kaml = "0.102.0" caffeine = "3.1.8" diff --git a/objectbox-models/default.json b/objectbox-models/default.json index abd62da..f1640ff 100644 --- a/objectbox-models/default.json +++ b/objectbox-models/default.json @@ -4,114 +4,114 @@ "_note3": "If you have VCS merge conflicts, you must resolve them according to ObjectBox docs.", "entities": [ { - "id": "1:3958641807978990121", - "lastPropertyId": "5:885808597397071819", - "name": "LinkCode", + "id": "1:8261292230741874475", + "lastPropertyId": "6:1439611636147655021", + "name": "Ban", "properties": [ { - "id": "1:8578775582616557464", + "id": "1:8841001532244465999", "name": "id", "type": 6, "flags": 1 }, { - "id": "2:5211271274410806493", - "name": "code", + "id": "2:2392367576445077149", + "name": "clientUuid", "type": 9 }, { - "id": "3:8525598388342322678", + "id": "3:5241039350824200514", "name": "minecraftUuid", "type": 9 }, { - "id": "4:1268060968951354113", + "id": "4:4359432178243352065", "name": "minecraftUsername", "type": 9 }, { - "id": "5:885808597397071819", - "name": "expiresAt", + "id": "5:1878550432723739035", + "name": "reason", + "type": 9 + }, + { + "id": "6:1439611636147655021", + "name": "timestamp", "type": 6 } ], "relations": [] }, { - "id": "2:6624657809806865546", - "lastPropertyId": "6:5571998185572009749", - "name": "Ban", + "id": "2:2163146026126670709", + "lastPropertyId": "5:6924894891449230143", + "name": "Client", "properties": [ { - "id": "1:8712240317965336909", + "id": "1:3478056570875465288", "name": "id", "type": 6, "flags": 1 }, { - "id": "2:6941306988557758874", + "id": "2:660439629542299095", "name": "clientUuid", "type": 9 }, { - "id": "3:6976374136811816221", + "id": "3:8235748400939030633", "name": "minecraftUuid", "type": 9 }, { - "id": "4:6711419724093205395", + "id": "4:5499463405898406734", "name": "minecraftUsername", "type": 9 }, { - "id": "5:1077476954252784310", - "name": "reason", - "type": 9 - }, - { - "id": "6:5571998185572009749", - "name": "timestamp", - "type": 6 + "id": "5:6924894891449230143", + "name": "supportsComponents", + "type": 1 } ], "relations": [] }, { - "id": "3:2018865171595602336", - "lastPropertyId": "5:6963362824776171892", - "name": "Client", + "id": "3:2559959171573362814", + "lastPropertyId": "5:7837552611035865525", + "name": "LinkCode", "properties": [ { - "id": "1:1003009403954781106", + "id": "1:4986906910597541623", "name": "id", "type": 6, "flags": 1 }, { - "id": "2:1169755582415132442", - "name": "clientUuid", + "id": "2:9042144905393385362", + "name": "code", "type": 9 }, { - "id": "3:7655496526742702577", + "id": "3:4072015821015161242", "name": "minecraftUuid", "type": 9 }, { - "id": "4:516704796771639505", + "id": "4:6023228194007721096", "name": "minecraftUsername", "type": 9 }, { - "id": "5:6963362824776171892", - "name": "supportsComponents", - "type": 1 + "id": "5:7837552611035865525", + "name": "expiresAt", + "type": 6 } ], "relations": [] } ], - "lastEntityId": "3:2018865171595602336", + "lastEntityId": "3:2559959171573362814", "lastIndexId": "0:0", "lastRelationId": "0:0", "lastSequenceId": "0:0", diff --git a/objectbox-models/default.json.bak b/objectbox-models/default.json.bak deleted file mode 100644 index 73b361a..0000000 --- a/objectbox-models/default.json.bak +++ /dev/null @@ -1,120 +0,0 @@ -{ - "_note1": "KEEP THIS FILE! Check it into a version control system (VCS) like git.", - "_note2": "ObjectBox manages crucial IDs for your object model. See docs for details.", - "_note3": "If you have VCS merge conflicts, you must resolve them according to ObjectBox docs.", - "entities": [ - { - "id": "1:3958641807978990121", - "lastPropertyId": "5:885808597397071819", - "name": "LinkCode", - "properties": [ - { - "id": "1:8578775582616557464", - "name": "id", - "type": 6, - "flags": 1 - }, - { - "id": "2:5211271274410806493", - "name": "code", - "type": 9 - }, - { - "id": "3:8525598388342322678", - "name": "minecraftUuid", - "type": 9 - }, - { - "id": "4:1268060968951354113", - "name": "minecraftUsername", - "type": 9 - }, - { - "id": "5:885808597397071819", - "name": "expiresAt", - "type": 6 - } - ], - "relations": [] - }, - { - "id": "2:6624657809806865546", - "lastPropertyId": "6:5571998185572009749", - "name": "Ban", - "properties": [ - { - "id": "1:8712240317965336909", - "name": "id", - "type": 6, - "flags": 1 - }, - { - "id": "2:6941306988557758874", - "name": "clientUuid", - "type": 9 - }, - { - "id": "3:6976374136811816221", - "name": "minecraftUuid", - "type": 9 - }, - { - "id": "4:6711419724093205395", - "name": "minecraftUsername", - "type": 9 - }, - { - "id": "5:1077476954252784310", - "name": "reason", - "type": 9 - }, - { - "id": "6:5571998185572009749", - "name": "timestamp", - "type": 6 - } - ], - "relations": [] - }, - { - "id": "3:2018865171595602336", - "lastPropertyId": "4:516704796771639505", - "name": "Client", - "properties": [ - { - "id": "1:1003009403954781106", - "name": "id", - "type": 6, - "flags": 1 - }, - { - "id": "2:1169755582415132442", - "name": "clientUuid", - "type": 9 - }, - { - "id": "3:7655496526742702577", - "name": "minecraftUuid", - "type": 9 - }, - { - "id": "4:516704796771639505", - "name": "minecraftUsername", - "type": 9 - } - ], - "relations": [] - } - ], - "lastEntityId": "3:2018865171595602336", - "lastIndexId": "0:0", - "lastRelationId": "0:0", - "lastSequenceId": "0:0", - "modelVersion": 5, - "modelVersionParserMinimum": 5, - "retiredEntityUids": [], - "retiredIndexUids": [], - "retiredPropertyUids": [], - "retiredRelationUids": [], - "version": 1 -} \ No newline at end of file diff --git a/src/main/kotlin/org/winlogon/minechat/ClientConnection.kt b/src/main/kotlin/org/winlogon/minechat/ClientConnection.kt index dca88b5..536b8a6 100644 --- a/src/main/kotlin/org/winlogon/minechat/ClientConnection.kt +++ b/src/main/kotlin/org/winlogon/minechat/ClientConnection.kt @@ -2,14 +2,11 @@ package org.winlogon.minechat import com.github.luben.zstd.Zstd import kotlinx.serialization.ExperimentalSerializationApi -import kotlinx.serialization.cbor.Cbor import kotlinx.serialization.decodeFromByteArray import kotlinx.serialization.encodeToByteArray import net.kyori.adventure.text.Component -import net.kyori.adventure.text.format.NamedTextColor import net.kyori.adventure.text.minimessage.MiniMessage -import net.kyori.adventure.text.minimessage.tag.resolver.Placeholder import net.kyori.adventure.text.serializer.legacy.LegacyComponentSerializer import org.bukkit.Bukkit @@ -24,15 +21,14 @@ import java.util.concurrent.Executors import java.util.concurrent.ScheduledExecutorService import java.util.concurrent.TimeUnit import java.util.concurrent.atomic.AtomicBoolean +import java.util.concurrent.ExecutorService +import java.util.logging.Logger class ClientConnection( private val socket: Socket, private val plugin: MineChatServerPlugin, - private val miniMessage: MiniMessage + private val executorService: ExecutorService ) : Runnable { - @PublishedApi - internal val logger = plugin.logger - companion object { const val MINECHAT_PREFIX_STRING = "&8[&3MineChat&8]" val MINECHAT_PREFIX_COMPONENT: Component = LegacyComponentSerializer.legacyAmpersand().deserialize(MINECHAT_PREFIX_STRING) @@ -40,10 +36,7 @@ class ClientConnection( @OptIn(ExperimentalSerializationApi::class) @PublishedApi - internal val cbor = Cbor { - ignoreUnknownKeys = true - encodeDefaults = false - } + internal val cbor = createCbor() internal val reader = DataInputStream(socket.getInputStream()) @PublishedApi @@ -55,125 +48,92 @@ class ClientConnection( // Keep-alive timeout tracking private var lastPacketTime = System.currentTimeMillis() + private val keepAliveTimeout = 15000L // 15 seconds + private var currentRtt: Long = 0 - /** Calculated Round Trip Time in milliseconds */ - var currentRtt: Long = -1 - private set - - /** 15 seconds as per spec */ - private val keepAliveTimeout = 15000L - - /** Send PING every 10 seconds */ - private val pingInterval = 10000L - private val scheduledExecutor: ScheduledExecutorService = Executors.newSingleThreadScheduledExecutor() private val disconnected = AtomicBoolean(false) + private val scheduledExecutor: ScheduledExecutorService = Executors.newSingleThreadScheduledExecutor() - fun getClient(): Client? = client - - private fun broadcastMinecraft(colors: Pair?, message: String) { - val formattedMessage = colors?.let { "$message" } ?: message - val finalMessage = miniMessage.deserialize(formattedMessage) - Bukkit.broadcast(formatPrefixed(finalMessage)) + init { + // Schedule periodic PING messages every 10 seconds + scheduledExecutor.scheduleAtFixedRate({ + if (running && !disconnected.get()) { + sendPing() + } + }, 10, 10, TimeUnit.SECONDS) } @OptIn(ExperimentalSerializationApi::class) override fun run() { try { - // Schedule periodic PING packets for keep-alive - scheduledExecutor.scheduleAtFixedRate({ - if (running && !disconnected.get()) { - val currentTime = System.currentTimeMillis() - if (currentTime - lastPacketTime > pingInterval) { - try { - sendMessage(PacketTypes.PING, PingPayload(System.currentTimeMillis())) - logger.fine("Sent PING for keep-alive") - } catch (e: Exception) { - logger.warning("Failed to send PING: ${e.message}") - disconnected.set(true) - } - } - } - }, pingInterval, pingInterval, TimeUnit.MILLISECONDS) + logger.info("Client connected: ${socket.remoteSocketAddress}") - while (running) { - // Check for keep-alive timeout before reading next packet + // Main packet processing loop + while (running && !disconnected.get()) { + // Check keep-alive timeout val currentTime = System.currentTimeMillis() if (currentTime - lastPacketTime > keepAliveTimeout) { - logger.info("Client connection timed out after ${keepAliveTimeout}ms of inactivity") - break + logger.warning("Client connection timed out after ${keepAliveTimeout}ms of inactivity") + break // Terminate connection } + + // Read the framing layer (per spec section 4) + val decompressedLen = reader.readInt() + val compressedLen = reader.readInt() - // Set socket timeout to avoid blocking indefinitely - socket.soTimeout = 1000 // 1 second timeout for reads + if (decompressedLen <= 0 || compressedLen <= 0) { + logger.warning("Received non-positive frame size. Terminating connection.") + break + } - try { - val decompressedLen = reader.readInt() - if (decompressedLen <= 0) { - logger.warning("Received non-positive decompressed length: $decompressedLen. Terminating connection.") - break // Terminate connection - } + // Update last packet time after successful read + lastPacketTime = currentTime - val compressedLen = reader.readInt() - if (compressedLen <= 0) { - logger.warning("Received non-positive compressed length: $compressedLen. Terminating connection.") - break // Terminate connection - } - - // Update last packet time after successful read - lastPacketTime = currentTime + val compressed = ByteArray(compressedLen) + reader.readFully(compressed) - val compressed = ByteArray(compressedLen) - reader.readFully(compressed) + val decompressed = Zstd.decompress(compressed, decompressedLen) + if (decompressed.size != decompressedLen) { + logger.warning("Decompressed size mismatch. Expected $decompressedLen, got ${decompressed.size}. Terminating connection.") + break + } - val decompressed = Zstd.decompress(compressed, decompressedLen) - if (decompressed.size != decompressedLen) { - logger.warning("Decompressed size mismatch. Expected $decompressedLen, got ${decompressed.size}. Terminating connection.") - break // Terminate connection + val mineChatPacket = cbor.decodeFromByteArray(decompressed) + + // Update last packet time for any received packet + lastPacketTime = System.currentTimeMillis() + + when (mineChatPacket.packetType) { + PacketTypes.LINK -> { + val payload = mineChatPacket.payload as LinkPayload + logger.fine("Received LINK message: $payload") + handleAuth(payload) } - - val mineChatPacket = cbor.decodeFromByteArray(decompressed) - - // Update last packet time for any received packet - lastPacketTime = System.currentTimeMillis() - - when (mineChatPacket.packetType) { - PacketTypes.LINK -> { - val payload = cbor.decodeFromByteArray(mineChatPacket.payload) - logger.fine("Received LINK message: $payload") - handleAuth(payload) - } - PacketTypes.CAPABILITIES -> { - val payload = cbor.decodeFromByteArray(mineChatPacket.payload) - logger.fine("Received CAPABILITIES message: $payload") - handleCapabilities(payload) - } - PacketTypes.CHAT_MESSAGE -> { - val payload = cbor.decodeFromByteArray(mineChatPacket.payload) - logger.fine("Received CHAT_MESSAGE message: $payload") - handleChat(payload) - } - PacketTypes.PING -> { - val payload = cbor.decodeFromByteArray(mineChatPacket.payload) - logger.fine("Received PING message: $payload") - handlePing(payload) - } - PacketTypes.PONG -> { - val payload = cbor.decodeFromByteArray(mineChatPacket.payload) - logger.fine("Received PONG message: $payload") - handlePong(payload) - } - else -> logger.warning("Unknown packet type: ${mineChatPacket.packetType}") + PacketTypes.CAPABILITIES -> { + val payload = mineChatPacket.payload as CapabilitiesPayload + logger.fine("Received CAPABILITIES message: $payload") + handleCapabilities(payload) } - } catch (_: SocketTimeoutException) { - // This is expected due to keep-alive timeout checking, continue loop - continue - } catch (e: Exception) { - if (running && !disconnected.get()) { - logger.warning("Client error during packet processing: ${e.message}") + PacketTypes.CHAT_MESSAGE -> { + val payload = mineChatPacket.payload as ChatMessagePayload + logger.fine("Received CHAT_MESSAGE message: $payload") + handleChat(payload) } - break + PacketTypes.PING -> { + val payload = mineChatPacket.payload as PingPayload + logger.fine("Received PING message: $payload") + handlePing(payload) + } + PacketTypes.PONG -> { + val payload = mineChatPacket.payload as PongPayload + logger.fine("Received PONG message: $payload") + handlePong(payload) + } + else -> logger.warning("Unknown packet type: ${mineChatPacket.packetType}") } } + } catch (_: SocketTimeoutException) { + // This is expected due to keep-alive timeout checking, continue loop } catch (e: Exception) { if (running && !disconnected.get()) { logger.warning("Client error in run loop: ${e.message}") @@ -187,22 +147,114 @@ class ClientConnection( } } - private fun sendBannedMessage(ban: Ban) { - logger.fine("Sending banned message to client: $ban") - disconnect(ban.reason ?: "You are banned from MineChat.") + private fun sendPing() { + val timestamp = System.currentTimeMillis() + sendMessage(PacketTypes.PING, PingPayload(timestamp_ms = timestamp)) } - fun disconnect(reason: String) { - sendMessage(PacketTypes.DISCONNECT, DisconnectPayload(reason)) - close() + private fun sendMessage(packetType: Int, payload: LinkOkPayload) { + try { + val mineChatPacket = createPacket(packetType, payload) + sendPacket(mineChatPacket) + } catch (e: Exception) { + logger.warning("Error sending LinkOkPayload: ${e.message}") + } + } + + private fun sendMessage(packetType: Int, payload: CapabilitiesPayload) { + try { + val mineChatPacket = createPacket(packetType, payload) + sendPacket(mineChatPacket) + } catch (e: Exception) { + logger.warning("Error sending CapabilitiesPayload: ${e.message}") + } + } + + private fun sendMessage(packetType: Int, payload: AuthOkPayload) { + try { + val mineChatPacket = createPacket(packetType, payload) + sendPacket(mineChatPacket) + } catch (e: Exception) { + logger.warning("Error sending AuthOkPayload: ${e.message}") + } + } + + private fun sendMessage(packetType: Int, payload: ChatMessagePayload) { + try { + val mineChatPacket = createPacket(packetType, payload) + sendPacket(mineChatPacket) + } catch (e: Exception) { + logger.warning("Error sending ChatMessagePayload: ${e.message}") + } + } + + private fun sendMessage(packetType: Int, payload: PingPayload) { + try { + val mineChatPacket = createPacket(packetType, payload) + sendPacket(mineChatPacket) + } catch (e: Exception) { + logger.warning("Error sending PingPayload: ${e.message}") + } + } + + private fun sendMessage(packetType: Int, payload: PongPayload) { + try { + val mineChatPacket = createPacket(packetType, payload) + sendPacket(mineChatPacket) + } catch (e: Exception) { + logger.warning("Error sending PongPayload: ${e.message}") + } + } + + private fun sendMessage(packetType: Int, payload: DisconnectPayload) { + try { + val mineChatPacket = createPacket(packetType, payload) + sendPacket(mineChatPacket) + } catch (e: Exception) { + logger.warning("Error sending DisconnectPayload: ${e.message}") + } + } + + private fun createPacket(packetType: Int, payload: T): MineChatPacket { + return MineChatPacket(packetType, payload) + } + + @OptIn(ExperimentalSerializationApi::class) + private fun sendPacket(mineChatPacket: MineChatPacket) { + val serialized = cbor.encodeToByteArray(mineChatPacket) + val compressed = Zstd.compress(serialized) + + if (serialized.size > Int.MAX_VALUE || compressed.size > Int.MAX_VALUE) { + throw IllegalArgumentException("Packet too large") + } + + writer.writeInt(serialized.size) + writer.writeInt(compressed.size) + writer.write(compressed) + writer.flush() + } + + fun close() { + running = false + disconnected.set(true) + scheduledExecutor.shutdown() + try { + socket.close() + } catch (_: Exception) {} + } + + fun formatPrefixed(message: Component): Component { + return MINECHAT_PREFIX_COMPONENT + .append(Component.space()) + .append(message) } private fun handleAuth(payload: LinkPayload) { logger.fine("Handling auth with payload: $payload") val banStorage = plugin.banStorage - val clientUuid = payload.clientUuid - val linkCode = payload.linkingCode + val clientUuid = payload.client_uuid + val linkCode = payload.linking_code // Check ban by client UUID first banStorage.getBan(clientUuid, null)?.let { @@ -210,103 +262,58 @@ class ClientConnection( return } - if (linkCode.isNotEmpty()) { - handleLinkAuth(clientUuid, linkCode) - return - } - - handleExistingClientAuth(clientUuid) - } - - private fun handleLinkAuth(clientUuid: String, linkCode: String) { + // Look up the link code val link = plugin.linkCodeStorage.find(linkCode) - ?: return disconnect("Invalid or expired link code") - - // Check ban by Minecraft username - plugin.banStorage.getBan(null, link.minecraftUsername)?.let { - sendBannedMessage(it) + if (link == null) { + logger.warning("Invalid link code: $linkCode") + disconnect("Invalid link code") return } - if (link.expiresAt <= System.currentTimeMillis()) { - disconnect("Invalid or expired link code") + // Check if link code is expired (15 minutes) + if (System.currentTimeMillis() - link.expiresAt > 15 * 60 * 1000) { + logger.warning("Expired link code: $linkCode") + plugin.linkCodeStorage.remove(linkCode) + disconnect("Link code expired") return } - val client = Client( - clientUuid = clientUuid, - minecraftUuid = link.minecraftUuid, - minecraftUsername = link.minecraftUsername - ) - - plugin.clientStorage.add(client) - plugin.linkCodeStorage.remove(link.code) - this.client = client - - // Per spec: send LINK_OK, wait for CAPABILITIES, then send AUTH_OK - sendMessage( - PacketTypes.LINK_OK, - LinkOkPayload(minecraftUuid = link.minecraftUuid.toString()) - ) - linkAuthCompleted = true - - // Now wait for CAPABILITIES before completing auth - logger.fine("Sent LINK_OK, waiting for CAPABILITIES...") - } - - private fun handleExistingClientAuth(clientUuid: String) { - val client = plugin.clientStorage.find(clientUuid, null) - ?: return disconnect("Client not registered") - - plugin.banStorage.getBan(null, client.minecraftUsername)?.let { + // Check ban by Minecraft UUID + banStorage.getBan(null, link.minecraftUuid?.toString())?.let { sendBannedMessage(it) return } + // Delete the used link code + plugin.linkCodeStorage.remove(linkCode) + + // Get or create client + val existingClient = plugin.clientStorage.find(clientUuid, null) + val client = if (existingClient != null) { + existingClient.minecraftUuid = link.minecraftUuid + existingClient.minecraftUsername = link.minecraftUsername + existingClient + } else { + Client( + clientUuid = clientUuid, + minecraftUuid = link.minecraftUuid, + minecraftUsername = link.minecraftUsername + ) + } + this.client = client // Per spec: send LINK_OK, wait for CAPABILITIES, then send AUTH_OK - sendMessage( - PacketTypes.LINK_OK, - LinkOkPayload(minecraftUuid = client.minecraftUuid.toString()) - ) + sendMessage(PacketTypes.LINK_OK, LinkOkPayload(minecraft_uuid = link.minecraftUuid.toString())) linkAuthCompleted = true // Now wait for CAPABILITIES before completing auth - logger.fine("Sent LINK_OK for reconnection, waiting for CAPABILITIES...") - } - - private fun handleChat(payload: ChatMessagePayload) { - client?.let { - val message = if (payload.format == "commonmark") { - MarkdownSerializer.markdown().deserialize(payload.content) - } else { - miniMessage.deserialize(payload.content) - } - - val usernamePlaceholder = Component.text(it.minecraftUsername, NamedTextColor.DARK_GREEN) - val formattedMsg = miniMessage.deserialize( - ": ", - Placeholder.component("sender", usernamePlaceholder), - Placeholder.component("message", message) - ) - val finalMsg = formatPrefixed(formattedMsg) - Bukkit.broadcast(finalMsg) - - val markdownContent = MarkdownSerializer.markdown().serialize(message) - val chatMessagePayload = ChatMessagePayload( - format = "commonmark", - content = markdownContent - ) - plugin.broadcastToClients(PacketTypes.CHAT_MESSAGE, chatMessagePayload) - } + logger.fine("Sent LINK_OK, waiting for CAPABILITIES...") } private fun handleCapabilities(payload: CapabilitiesPayload) { - // Per spec, CAPABILITIES must come after LINK_OK if (!linkAuthCompleted) { - logger.warning("Received CAPABILITIES packet before LINK_OK. Disconnecting.") - disconnect("Received CAPABILITIES before completing linking.") + logger.warning("Received CAPABILITIES without prior LINK_OK. Ignoring.") return } @@ -316,14 +323,12 @@ class ClientConnection( } client?.let { - it.supportsComponents = payload.supportsComponents - plugin.clientStorage.add(it) // Update the client in storage + it.supportsComponents = payload.supports_components + plugin.clientStorage.add(it) - // Send AUTH_OK to complete the authentication flow sendMessage(PacketTypes.AUTH_OK, AuthOkPayload()) capabilitiesReceived = true - // Send join message if (it.supportsComponents) { broadcastMinecraft(ChatGradients.JOIN, "${it.minecraftUsername} has joined the chat.") } else { @@ -337,53 +342,60 @@ class ClientConnection( } } + private fun handleChat(payload: ChatMessagePayload) { + val c = client ?: return + + // Process the chat message + val format = payload.format + val content = payload.content + + if (format == "commonmark") { + // Parse commonmark and send to Minecraft + val component = try { + MiniMessage.miniMessage().deserialize(content) + } catch (e: Exception) { + logger.warning("Failed to parse MiniMessage: ${e.message}") + Component.text(content) + } + + broadcastMinecraft(formatPrefixed(component)) + } + } + private fun handlePing(payload: PingPayload) { - // Respond with a PONG packet, echoing the timestamp - sendMessage(PacketTypes.PONG, PongPayload(payload.timestampMs)) - logger.fine("Responded to PING from client with timestamp ${payload.timestampMs}") + sendMessage(PacketTypes.PONG, PongPayload(payload.timestamp_ms)) + logger.fine("Responded to PING from client with timestamp ${payload.timestamp_ms}") } private fun handlePong(payload: PongPayload) { val now = System.currentTimeMillis() - currentRtt = now - payload.timestampMs + currentRtt = now - payload.timestamp_ms logger.fine("Received PONG from client with RTT: ${currentRtt}ms") } - @OptIn(ExperimentalSerializationApi::class) - inline fun sendMessage(packetType: Int, payload: T) { - try { - val payloadBytes = cbor.encodeToByteArray(payload) - val mineChatPacket = MineChatPacket(packetType, payloadBytes) - val serialized = cbor.encodeToByteArray(mineChatPacket) - val compressed = Zstd.compress(serialized) - - // Validate sizes are positive (per spec requirement) - if (serialized.size > Int.MAX_VALUE || compressed.size > Int.MAX_VALUE) { - throw IllegalArgumentException("Packet too large") - } - - // DataOutputStream already uses big-endian signed integers by default - writer.writeInt(serialized.size) - writer.writeInt(compressed.size) - writer.write(compressed) - writer.flush() - } catch (e: Exception) { - logger.warning("Error sending message: ${e.message}") - } + private fun broadcastMinecraft(gradient: Pair, message: String) { + val gradientColor = "" + val component = MiniMessage.miniMessage().deserialize(gradientColor) + broadcastMinecraft(formatPrefixed(component)) } - fun close() { - running = false - disconnected.set(true) - scheduledExecutor.shutdown() - try { - socket.close() - } catch (_: Exception) {} + private fun broadcastMinecraft(component: Component) { + Bukkit.getServer().sendMessage(component) } - fun formatPrefixed(message: Component): Component { - return MINECHAT_PREFIX_COMPONENT - .append(Component.space()) - .append(message) + private fun sendBannedMessage(ban: Ban) { + val reason = ban.reason ?: "You are banned" + sendMessage(PacketTypes.DISCONNECT, DisconnectPayload(reason)) + close() } + + fun getClient(): Client? = client + + fun disconnect(reason: String) { + sendMessage(PacketTypes.DISCONNECT, DisconnectPayload(reason)) + close() + } + + private val logger: Logger + get() = plugin.logger } diff --git a/src/main/kotlin/org/winlogon/minechat/MarkdownSerializer.kt b/src/main/kotlin/org/winlogon/minechat/MarkdownSerializer.kt index 0474f6e..5549507 100644 --- a/src/main/kotlin/org/winlogon/minechat/MarkdownSerializer.kt +++ b/src/main/kotlin/org/winlogon/minechat/MarkdownSerializer.kt @@ -2,14 +2,13 @@ package org.winlogon.minechat import net.kyori.adventure.text.Component import net.kyori.adventure.text.TextComponent +import net.kyori.adventure.text.format.NamedTextColor import net.kyori.adventure.text.format.TextDecoration import net.kyori.adventure.text.serializer.ComponentSerializer class MarkdownSerializer : ComponentSerializer { override fun deserialize(input: String): Component { - // Basic implementation: for now we just return a TextComponent. - // A full markdown parser would be needed here (e.g., using commonmark-java). - return Component.text(input) + return parseMarkdown(input) } override fun serialize(component: Component): String { @@ -18,6 +17,89 @@ class MarkdownSerializer : ComponentSerializer { return sb.toString() } + private fun parseMarkdown(input: String): Component { + if (input.isEmpty()) return Component.empty() + + val builder = Component.text() + var remaining = input + var currentText = StringBuilder() + + while (remaining.isNotEmpty()) { + when { + remaining.startsWith("**") -> { + if (currentText.isNotEmpty()) { + builder.append(Component.text(currentText.toString())) + currentText = StringBuilder() + } + val endIndex = remaining.indexOf("**", 2) + if (endIndex != -1) { + val boldContent = remaining.substring(2, endIndex) + builder.append(Component.text(boldContent).decorate(TextDecoration.BOLD)) + remaining = remaining.substring(endIndex + 2) + } else { + currentText.append(remaining.take(2)) + remaining = remaining.substring(2) + } + } + remaining.startsWith("~~") -> { + if (currentText.isNotEmpty()) { + builder.append(Component.text(currentText.toString())) + currentText = StringBuilder() + } + val endIndex = remaining.indexOf("~~", 2) + if (endIndex != -1) { + val strikeContent = remaining.substring(2, endIndex) + builder.append(Component.text(strikeContent).decorate(TextDecoration.STRIKETHROUGH)) + remaining = remaining.substring(endIndex + 2) + } else { + currentText.append(remaining.take(2)) + remaining = remaining.substring(2) + } + } + remaining.startsWith("*") -> { + if (currentText.isNotEmpty()) { + builder.append(Component.text(currentText.toString())) + currentText = StringBuilder() + } + val endIndex = remaining.indexOf("*", 1) + if (endIndex > 1) { + val italicContent = remaining.substring(1, endIndex) + builder.append(Component.text(italicContent).decorate(TextDecoration.ITALIC)) + remaining = remaining.substring(endIndex + 1) + } else { + currentText.append(remaining.take(1)) + remaining = remaining.substring(1) + } + } + remaining.startsWith("`") -> { + if (currentText.isNotEmpty()) { + builder.append(Component.text(currentText.toString())) + currentText = StringBuilder() + } + val endIndex = remaining.indexOf("`", 1) + if (endIndex != -1) { + val codeContent = remaining.substring(1, endIndex) + builder.append(Component.text(codeContent).color(NamedTextColor.GRAY)) + remaining = remaining.substring(endIndex + 1) + } else { + currentText.append(remaining.take(1)) + remaining = remaining.substring(1) + } + } + else -> { + currentText.append(remaining.first()) + remaining = remaining.substring(1) + } + } + } + + if (currentText.isNotEmpty()) { + builder.append(Component.text(currentText.toString())) + } + + return builder.build() + } + private fun serializeComponent(component: Component, sb: StringBuilder) { if (component is TextComponent) { val hasBold = component.hasDecoration(TextDecoration.BOLD) diff --git a/src/main/kotlin/org/winlogon/minechat/MineChatPluginServices.kt b/src/main/kotlin/org/winlogon/minechat/MineChatPluginServices.kt index 7cb7256..4f3d57b 100644 --- a/src/main/kotlin/org/winlogon/minechat/MineChatPluginServices.kt +++ b/src/main/kotlin/org/winlogon/minechat/MineChatPluginServices.kt @@ -20,5 +20,5 @@ interface MineChatPluginServices { fun reloadConfigAndDependencies() fun generateRandomLinkCode(): String - fun broadcastToClients(packetType: Int, payload: T) + fun broadcastToClients(packetType: Int, payload: PacketPayload) } diff --git a/src/main/kotlin/org/winlogon/minechat/MineChatServerPlugin.kt b/src/main/kotlin/org/winlogon/minechat/MineChatServerPlugin.kt index 9bc1a9e..5f47f23 100644 --- a/src/main/kotlin/org/winlogon/minechat/MineChatServerPlugin.kt +++ b/src/main/kotlin/org/winlogon/minechat/MineChatServerPlugin.kt @@ -138,7 +138,7 @@ class MineChatServerPlugin : JavaPlugin(), MineChatPluginServices { val socket = serverSocket?.accept() if (socket != null) { loggerProvider.logger.info("Client connected: ${socket.inetAddress}") - val connection = ClientConnection(socket, this, miniMessage) + val connection = ClientConnection(socket, this, executorService) connectedClients.add(connection) executorService.submit(connection) } @@ -190,28 +190,13 @@ class MineChatServerPlugin : JavaPlugin(), MineChatPluginServices { } } - @OptIn(kotlinx.serialization.ExperimentalSerializationApi::class) - private val cbor = kotlinx.serialization.cbor.Cbor { - ignoreUnknownKeys = true - encodeDefaults = false - } - - override fun broadcastToClients(packetType: Int, payload: T) { - val payloadBytes = try { - @OptIn(kotlinx.serialization.InternalSerializationApi::class, kotlinx.serialization.ExperimentalSerializationApi::class) - val serializer = payload::class.serializer() - @Suppress("UNCHECKED_CAST") - cbor.encodeToByteArray(serializer as kotlinx.serialization.KSerializer, payload) - } catch (e: Exception) { - loggerProvider.logger.warning("Error encoding payload for broadcast: ${e.message}") - return - } + private val cbor = createCbor() + override fun broadcastToClients(packetType: Int, payload: PacketPayload) { connectedClients.forEach { client -> try { - // Send already encoded payload - val mineChatPacket = MineChatPacket(packetType, payloadBytes) - val serialized = cbor.encodeToByteArray(MineChatPacket.serializer(), mineChatPacket) + val mineChatPacket = MineChatPacket(packetType, payload) + val serialized = cbor.encodeToByteArray(MineChatPacketSerializer, mineChatPacket) val compressed = com.github.luben.zstd.Zstd.compress(serialized) client.writer.writeInt(serialized.size) diff --git a/src/main/kotlin/org/winlogon/minechat/Protocol.kt b/src/main/kotlin/org/winlogon/minechat/Protocol.kt index d60422c..80f8520 100644 --- a/src/main/kotlin/org/winlogon/minechat/Protocol.kt +++ b/src/main/kotlin/org/winlogon/minechat/Protocol.kt @@ -1,9 +1,23 @@ +@file:OptIn(ExperimentalSerializationApi::class) + package org.winlogon.minechat -import kotlinx.serialization.SerialName +import kotlinx.serialization.ExperimentalSerializationApi +import kotlinx.serialization.KSerializer import kotlinx.serialization.Serializable +import kotlinx.serialization.SerializationException +import kotlinx.serialization.SerialName +import kotlinx.serialization.cbor.Cbor +import kotlinx.serialization.cbor.CborLabel +import kotlinx.serialization.descriptors.SerialDescriptor +import kotlinx.serialization.descriptors.buildClassSerialDescriptor +import kotlinx.serialization.descriptors.element +import kotlinx.serialization.encoding.CompositeDecoder +import kotlinx.serialization.encoding.Decoder +import kotlinx.serialization.encoding.Encoder +import kotlinx.serialization.encoding.decodeStructure +import kotlinx.serialization.encoding.encodeStructure -// Packet Type IDs as defined in the spec object PacketTypes { const val LINK = 0x01 const val LINK_OK = 0x02 @@ -13,8 +27,6 @@ object PacketTypes { const val PING = 0x06 const val PONG = 0x07 const val MODERATION = 0x08 - - // Custom/implementation-private packet types (0x80-0xFF) const val DISCONNECT = 0x80 } @@ -37,85 +49,164 @@ object ChatGradients { val INFO = Pair("#2980B9", "#3498DB") } -/** - * Represents the common packet envelope as defined by the MineChat Protocol. - * { - * 0: packet_type (int), - * 1: payload (map) - * } - */ @Serializable -data class MineChatPacket( - @SerialName("0") val packetType: Int, - @SerialName("1") val payload: ByteArray // Raw CBOR bytes for the payload map -) { - override fun equals(other: Any?): Boolean { - if (this === other) return true - if (javaClass != other?.javaClass) return false - - other as MineChatPacket - - if (packetType != other.packetType) return false - if (!payload.contentEquals(other.payload)) return false +sealed class PacketPayload - return true - } - - override fun hashCode(): Int { - var result = packetType - result = 31 * result + payload.contentHashCode() - return result - } -} - -// Payload data classes +@OptIn(ExperimentalSerializationApi::class) @Serializable +@SerialName("link") data class LinkPayload( - @SerialName("0") val linkingCode: String, - @SerialName("1") val clientUuid: String -) + @CborLabel(0) + val linking_code: String, + @CborLabel(1) + val client_uuid: String +) : PacketPayload() +@OptIn(ExperimentalSerializationApi::class) @Serializable +@SerialName("link_ok") data class LinkOkPayload( - @SerialName("0") val minecraftUuid: String -) + @CborLabel(0) + val minecraft_uuid: String +) : PacketPayload() +@OptIn(ExperimentalSerializationApi::class) @Serializable +@SerialName("capabilities") data class CapabilitiesPayload( - @SerialName("0") val supportsComponents: Boolean -) + @CborLabel(0) + val supports_components: Boolean +) : PacketPayload() +@OptIn(ExperimentalSerializationApi::class) @Serializable -class AuthOkPayload +@SerialName("auth_ok") +class AuthOkPayload : PacketPayload() +@OptIn(ExperimentalSerializationApi::class) @Serializable +@SerialName("chat_message") data class ChatMessagePayload( - @SerialName("0") val format: String, - @SerialName("1") val content: String -) + @CborLabel(0) + val format: String, + @CborLabel(1) + val content: String +) : PacketPayload() +@OptIn(ExperimentalSerializationApi::class) @Serializable +@SerialName("ping") data class PingPayload( - @SerialName("0") val timestampMs: Long -) + @CborLabel(0) + val timestamp_ms: Long +) : PacketPayload() +@OptIn(ExperimentalSerializationApi::class) @Serializable +@SerialName("pong") data class PongPayload( - @SerialName("0") val timestampMs: Long -) + @CborLabel(0) + val timestamp_ms: Long +) : PacketPayload() +@OptIn(ExperimentalSerializationApi::class) @Serializable +@SerialName("moderation") data class ModerationPayload( - @SerialName("0") val action: Int, - @SerialName("1") val scope: Int, - @SerialName("2") val reason: String? = null, - @SerialName("3") val durationSeconds: Int? = null -) - + @CborLabel(0) + val action: Int, + @CborLabel(1) + val scope: Int, + @CborLabel(2) + val reason: String? = null, + @CborLabel(3) + val duration_seconds: Int? = null +) : PacketPayload() + +@OptIn(ExperimentalSerializationApi::class) @Serializable +@SerialName("disconnect") data class DisconnectPayload( - @SerialName("0") val reason: String -) + @CborLabel(0) + val reason: String +) : PacketPayload() +@OptIn(ExperimentalSerializationApi::class) @Serializable -class EmptyPayload \ No newline at end of file +@SerialName("empty") +class EmptyPayload : PacketPayload() + +@OptIn(ExperimentalSerializationApi::class) +data class MineChatPacket( + val packetType: Int, + val payload: PacketPayload +) { + companion object { + val serializer = MineChatPacketSerializer + } +} + +@OptIn(ExperimentalSerializationApi::class) +object MineChatPacketSerializer : KSerializer { + override val descriptor: SerialDescriptor = buildClassSerialDescriptor("MineChatPacket") { + element("packetType") + element("payload") + } + + override fun serialize(encoder: Encoder, value: MineChatPacket) { + encoder.encodeStructure(descriptor) { + encodeIntElement(descriptor, 0, value.packetType) + @Suppress("UNCHECKED_CAST") + encodeSerializableElement( + descriptor, 1, + payloadSerializer(value.packetType) as kotlinx.serialization.SerializationStrategy, + value.payload + ) + } + } + + override fun deserialize(decoder: Decoder): MineChatPacket { + return decoder.decodeStructure(descriptor) { + var packetType: Int? = null + var payload: PacketPayload? = null + + while (true) { + when (decodeElementIndex(descriptor)) { + 0 -> packetType = decodeIntElement(descriptor, 0) + 1 -> { + val pt = packetType ?: throw SerializationException("packetType must be before payload") + payload = decodeSerializableElement(descriptor, 1, payloadSerializer(pt)) + } + CompositeDecoder.DECODE_DONE -> break + else -> throw SerializationException("Unknown index") + } + } + + if (packetType == null) throw SerializationException("Missing packetType") + if (payload == null) throw SerializationException("Missing payload") + + MineChatPacket(packetType, payload) + } + } + + private fun payloadSerializer(packetType: Int): KSerializer { + return when (packetType) { + PacketTypes.LINK -> LinkPayload.serializer() + PacketTypes.LINK_OK -> LinkOkPayload.serializer() + PacketTypes.CAPABILITIES -> CapabilitiesPayload.serializer() + PacketTypes.AUTH_OK -> AuthOkPayload.serializer() + PacketTypes.CHAT_MESSAGE -> ChatMessagePayload.serializer() + PacketTypes.PING -> PingPayload.serializer() + PacketTypes.PONG -> PongPayload.serializer() + PacketTypes.MODERATION -> ModerationPayload.serializer() + PacketTypes.DISCONNECT -> DisconnectPayload.serializer() + else -> EmptyPayload.serializer() + } + } +} + +fun createCbor(): Cbor = Cbor { + preferCborLabelsOverNames = true + ignoreUnknownKeys = true + encodeDefaults = false +} From f18a0f947696d0f68c80acf565b9ad20a68e3e7c Mon Sep 17 00:00:00 2001 From: winlogon Date: Sat, 14 Mar 2026 09:03:41 +0100 Subject: [PATCH 24/30] feat(protocol): improve packet handling and reconnection This commit improves packet processing robustness and debugging by adding detailed logging, safer error handling, and reconnection support for previously authenticated clients. Summary of changes: - Change zstd-jni dependency from compileOnly to implementation (will be added to the logger soon) - Add exhaustive packet processing and compression debug logging - Catch Zstd and CBOR deserialization errors explicitly - Handle EOFException for normal client disconnects - Implement client reconnection using empty link code - Simplify CBOR serialization calls across the codebase - Move MineChatPacket serializer into the class companion - Remove unused serialization annotations and imports - Rename MineChatPluginServices to PluginServices - Improve command feedback messages and placeholders - Change reload command to "minechat-reload" - Fix gradient broadcast message formatting --- build.gradle.kts | 2 +- .../org/winlogon/minechat/ClientConnection.kt | 212 ++++++++++++------ .../minechat/MineChatCommandRegister.kt | 25 ++- .../org/winlogon/minechat/MineChatLoader.kt | 2 + .../winlogon/minechat/MineChatServerPlugin.kt | 8 +- ...hatPluginServices.kt => PluginServices.kt} | 2 +- .../kotlin/org/winlogon/minechat/Protocol.kt | 117 +++------- 7 files changed, 198 insertions(+), 170 deletions(-) rename src/main/kotlin/org/winlogon/minechat/{MineChatPluginServices.kt => PluginServices.kt} (96%) diff --git a/build.gradle.kts b/build.gradle.kts index 28abcad..a552a11 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -85,7 +85,7 @@ repositories { dependencies { compileOnly("com.github.ben-manes.caffeine:caffeine:3.2.0") - compileOnly("com.github.luben:zstd-jni:1.5.6-1") + implementation("com.github.luben:zstd-jni:1.5.6-1") // idt this is needed // compileOnly("com.google.code.gson:gson:2.11.0") compileOnly("io.objectbox:objectbox-kotlin:3.8.0") diff --git a/src/main/kotlin/org/winlogon/minechat/ClientConnection.kt b/src/main/kotlin/org/winlogon/minechat/ClientConnection.kt index 536b8a6..4ccd95a 100644 --- a/src/main/kotlin/org/winlogon/minechat/ClientConnection.kt +++ b/src/main/kotlin/org/winlogon/minechat/ClientConnection.kt @@ -2,9 +2,6 @@ package org.winlogon.minechat import com.github.luben.zstd.Zstd import kotlinx.serialization.ExperimentalSerializationApi -import kotlinx.serialization.decodeFromByteArray -import kotlinx.serialization.encodeToByteArray - import net.kyori.adventure.text.Component import net.kyori.adventure.text.minimessage.MiniMessage import net.kyori.adventure.text.serializer.legacy.LegacyComponentSerializer @@ -15,6 +12,7 @@ import org.winlogon.minechat.entities.Client import java.io.DataInputStream import java.io.DataOutputStream +import java.io.EOFException import java.net.Socket import java.net.SocketTimeoutException import java.util.concurrent.Executors @@ -35,17 +33,20 @@ class ClientConnection( } @OptIn(ExperimentalSerializationApi::class) - @PublishedApi - internal val cbor = createCbor() + val cbor = createCbor() + + val reader = DataInputStream(socket.inputStream) + val writer = DataOutputStream(socket.outputStream) - internal val reader = DataInputStream(socket.getInputStream()) - @PublishedApi - internal val writer = DataOutputStream(socket.getOutputStream()) private var client: Client? = null private var running = true - private var linkAuthCompleted = false // Tracks if LINK_OK has been sent - private var capabilitiesReceived = false // Tracks if CAPABILITIES has been received - + + + /** Tracks if LINK_OK has been sent */ + private var linkAuthCompleted = false + /** Tracks if CAPABILITIES has been received */ + private var capabilitiesReceived = false + // Keep-alive timeout tracking private var lastPacketTime = System.currentTimeMillis() private val keepAliveTimeout = 15000L // 15 seconds @@ -66,8 +67,8 @@ class ClientConnection( @OptIn(ExperimentalSerializationApi::class) override fun run() { try { - logger.info("Client connected: ${socket.remoteSocketAddress}") - + logger.info("ClientConnection.run() started for ${socket.remoteSocketAddress}") + // Main packet processing loop while (running && !disconnected.get()) { // Check keep-alive timeout @@ -77,66 +78,105 @@ class ClientConnection( break // Terminate connection } - // Read the framing layer (per spec section 4) + // Read the framing layer val decompressedLen = reader.readInt() val compressedLen = reader.readInt() - + logger.info("Read frame header: decompressedLen=$decompressedLen, compressedLen=$compressedLen") + if (decompressedLen <= 0 || compressedLen <= 0) { logger.warning("Received non-positive frame size. Terminating connection.") break } - + // Update last packet time after successful read lastPacketTime = currentTime val compressed = ByteArray(compressedLen) reader.readFully(compressed) + logger.info("Read compressed data: ${compressed.size} bytes") + + // Log compressed bytes hex for debugging + val compressedHex = compressed.take(32).joinToString("") { "%02X".format(it) } + logger.info("Compressed hex (first 32 bytes): $compressedHex") + + try { + logger.fine("Decompressing ${compressed.size} bytes to expected $decompressedLen bytes...") + val decompressed: ByteArray + try { + decompressed = Zstd.decompress(compressed, decompressedLen) + } catch (t: Throwable) { + logger.severe("Zstd.decompress EXCEPTION: ${t.javaClass.name}: ${t.message}") + t.printStackTrace() + break + } + logger.fine("Decompression complete: ${decompressed.size} bytes") - val decompressed = Zstd.decompress(compressed, decompressedLen) - if (decompressed.size != decompressedLen) { - logger.warning("Decompressed size mismatch. Expected $decompressedLen, got ${decompressed.size}. Terminating connection.") - break - } + if (decompressed.size != decompressedLen) { + logger.warning("Decompressed size mismatch. Expected $decompressedLen, got ${decompressed.size}. Terminating connection.") + break + } - val mineChatPacket = cbor.decodeFromByteArray(decompressed) - - // Update last packet time for any received packet - lastPacketTime = System.currentTimeMillis() + // Debug: Log raw CBOR bytes for inspection + val hexPreview = decompressed.take(32).joinToString("") { "%02X".format(it) } + logger.fine("Decompressed CBOR: len=${decompressed.size}, hex=$hexPreview") - when (mineChatPacket.packetType) { - PacketTypes.LINK -> { - val payload = mineChatPacket.payload as LinkPayload - logger.fine("Received LINK message: $payload") - handleAuth(payload) - } - PacketTypes.CAPABILITIES -> { - val payload = mineChatPacket.payload as CapabilitiesPayload - logger.fine("Received CAPABILITIES message: $payload") - handleCapabilities(payload) + val mineChatPacket = try { + cbor.decodeFromByteArray(MineChatPacket, decompressed) + } catch (e: Exception) { + logger.severe("CBOR deserialization failed: ${e.message}") + e.printStackTrace() + break } - PacketTypes.CHAT_MESSAGE -> { - val payload = mineChatPacket.payload as ChatMessagePayload - logger.fine("Received CHAT_MESSAGE message: $payload") - handleChat(payload) - } - PacketTypes.PING -> { - val payload = mineChatPacket.payload as PingPayload - logger.fine("Received PING message: $payload") - handlePing(payload) - } - PacketTypes.PONG -> { - val payload = mineChatPacket.payload as PongPayload - logger.fine("Received PONG message: $payload") - handlePong(payload) + + logger.fine("Decoded packet: type=${mineChatPacket.packetType}, payloadClass=${mineChatPacket.payload::class.java.simpleName}") + + // Update last packet time for any received packet + lastPacketTime = System.currentTimeMillis() + + when (mineChatPacket.packetType) { + PacketTypes.LINK -> { + val payload = mineChatPacket.payload as LinkPayload + logger.info("Received LINK message: $payload") + handleAuth(payload) + } + PacketTypes.CAPABILITIES -> { + val payload = mineChatPacket.payload as CapabilitiesPayload + logger.info("Received CAPABILITIES message: $payload") + handleCapabilities(payload) + } + PacketTypes.CHAT_MESSAGE -> { + val payload = mineChatPacket.payload as ChatMessagePayload + logger.info("Received CHAT_MESSAGE message: $payload") + handleChat(payload) + } + PacketTypes.PING -> { + val payload = mineChatPacket.payload as PingPayload + logger.info("Received PING message: $payload") + handlePing(payload) + } + PacketTypes.PONG -> { + val payload = mineChatPacket.payload as PongPayload + logger.info("Received PONG message: $payload") + handlePong(payload) + } + else -> logger.warning("Unknown packet type: ${mineChatPacket.packetType}") } - else -> logger.warning("Unknown packet type: ${mineChatPacket.packetType}") + } catch (e: Exception) { + logger.severe("Error in packet processing: ${e.message}") + e.printStackTrace() + break } } } catch (_: SocketTimeoutException) { // This is expected due to keep-alive timeout checking, continue loop + } catch (_: EOFException) { + // Client disconnected normally - this is expected behavior + logger.info("Client disconnected normally") + return } catch (e: Exception) { if (running && !disconnected.get()) { - logger.warning("Client error in run loop: ${e.message}") + logger.severe("Client error in run loop: ${e.message}") + e.printStackTrace() } } finally { client?.let { @@ -221,17 +261,28 @@ class ClientConnection( @OptIn(ExperimentalSerializationApi::class) private fun sendPacket(mineChatPacket: MineChatPacket) { - val serialized = cbor.encodeToByteArray(mineChatPacket) - val compressed = Zstd.compress(serialized) - - if (serialized.size > Int.MAX_VALUE || compressed.size > Int.MAX_VALUE) { - throw IllegalArgumentException("Packet too large") + try { + logger.info("sendPacket: serializing packetType=${mineChatPacket.packetType}") + val serialized = cbor.encodeToByteArray(MineChatPacket, mineChatPacket) + logger.info("sendPacket: serialized ${serialized.size} bytes") + + val compressed = Zstd.compress(serialized) + logger.info("sendPacket: compressed to ${compressed.size} bytes") + + if (serialized.size > Int.MAX_VALUE || compressed.size > Int.MAX_VALUE) { + throw IllegalArgumentException("Packet too large") + } + + writer.writeInt(serialized.size) + writer.writeInt(compressed.size) + writer.write(compressed) + writer.flush() + logger.info("sendPacket: sent packetType=${mineChatPacket.packetType}") + } catch (e: Exception) { + logger.severe("sendPacket failed: ${e.message}") + e.printStackTrace() + throw e } - - writer.writeInt(serialized.size) - writer.writeInt(compressed.size) - writer.write(compressed) - writer.flush() } fun close() { @@ -250,7 +301,7 @@ class ClientConnection( } private fun handleAuth(payload: LinkPayload) { - logger.fine("Handling auth with payload: $payload") + logger.info("Handling auth with payload: $payload") val banStorage = plugin.banStorage val clientUuid = payload.client_uuid @@ -262,6 +313,21 @@ class ClientConnection( return } + // Handle reconnection (empty link code) - look up existing client + if (linkCode.isEmpty()) { + val existingClient = plugin.clientStorage.find(clientUuid, null) + if (existingClient != null) { + this.client = existingClient + sendMessage(PacketTypes.LINK_OK, LinkOkPayload(minecraft_uuid = existingClient.minecraftUuid.toString())) + linkAuthCompleted = true + logger.info("Reconnected client: ${existingClient.minecraftUsername}") + return + } else { + disconnect("Unknown client") + return + } + } + // Look up the link code val link = plugin.linkCodeStorage.find(linkCode) if (link == null) { @@ -308,7 +374,7 @@ class ClientConnection( linkAuthCompleted = true // Now wait for CAPABILITIES before completing auth - logger.fine("Sent LINK_OK, waiting for CAPABILITIES...") + logger.info("Sent LINK_OK, waiting for CAPABILITIES...") } private fun handleCapabilities(payload: CapabilitiesPayload) { @@ -325,17 +391,17 @@ class ClientConnection( client?.let { it.supportsComponents = payload.supports_components plugin.clientStorage.add(it) - + sendMessage(PacketTypes.AUTH_OK, AuthOkPayload()) capabilitiesReceived = true - + if (it.supportsComponents) { broadcastMinecraft(ChatGradients.JOIN, "${it.minecraftUsername} has joined the chat.") } else { broadcastMinecraft(ChatGradients.AUTH, "${it.minecraftUsername} has successfully authenticated.") } - - logger.fine("Client ${it.minecraftUsername} authenticated with capabilities: supportsComponents=${it.supportsComponents}") + + logger.info("Client ${it.minecraftUsername} authenticated with capabilities: supportsComponents=${it.supportsComponents}") } ?: run { logger.warning("Received CAPABILITIES packet but client is null. This should not happen.") disconnect("Internal error: client not found.") @@ -344,11 +410,11 @@ class ClientConnection( private fun handleChat(payload: ChatMessagePayload) { val c = client ?: return - + // Process the chat message val format = payload.format val content = payload.content - + if (format == "commonmark") { // Parse commonmark and send to Minecraft val component = try { @@ -357,24 +423,24 @@ class ClientConnection( logger.warning("Failed to parse MiniMessage: ${e.message}") Component.text(content) } - + broadcastMinecraft(formatPrefixed(component)) } } private fun handlePing(payload: PingPayload) { sendMessage(PacketTypes.PONG, PongPayload(payload.timestamp_ms)) - logger.fine("Responded to PING from client with timestamp ${payload.timestamp_ms}") + logger.info("Responded to PING from client with timestamp ${payload.timestamp_ms}") } private fun handlePong(payload: PongPayload) { val now = System.currentTimeMillis() currentRtt = now - payload.timestamp_ms - logger.fine("Received PONG from client with RTT: ${currentRtt}ms") + logger.info("Received PONG from client with RTT: ${currentRtt}ms") } private fun broadcastMinecraft(gradient: Pair, message: String) { - val gradientColor = "" + val gradientColor = "$message" val component = MiniMessage.miniMessage().deserialize(gradientColor) broadcastMinecraft(formatPrefixed(component)) } diff --git a/src/main/kotlin/org/winlogon/minechat/MineChatCommandRegister.kt b/src/main/kotlin/org/winlogon/minechat/MineChatCommandRegister.kt index b6dd6e4..600837f 100644 --- a/src/main/kotlin/org/winlogon/minechat/MineChatCommandRegister.kt +++ b/src/main/kotlin/org/winlogon/minechat/MineChatCommandRegister.kt @@ -1,3 +1,5 @@ +@file:Suppress("UnstableApiUsage") + package org.winlogon.minechat import com.mojang.brigadier.Command @@ -15,7 +17,7 @@ import org.bukkit.event.Listener import org.winlogon.minechat.entities.Ban import org.winlogon.minechat.entities.LinkCode -class MineChatCommandRegister(private val services: MineChatPluginServices) : Listener { +class MineChatCommandRegister(private val services: PluginServices) : Listener { fun registerCommands() { val linkCommand = Commands.literal("link") .requires { sender -> sender.executor is Player } @@ -26,12 +28,12 @@ class MineChatCommandRegister(private val services: MineChatPluginServices) : Li } .build() - val reloadCommand = Commands.literal("mchatreload") + val reloadCommand = Commands.literal("minechat-reload") .requires { sender -> sender.sender.hasPermission(services.permissions["reload"]!!) } .executes { ctx -> services.reloadConfigAndDependencies() ctx.source.sender.sendMessage(Component.text("MineChat config reloaded.").color(NamedTextColor.GREEN)) - + val infoMsg = "MineChat configuration has been reloaded by an administrator." val chatPayload = ChatMessagePayload( format = "commonmark", @@ -55,7 +57,7 @@ class MineChatCommandRegister(private val services: MineChatPluginServices) : Li val reason = "Banned by an operator." val ban = Ban(minecraftUsername = playerName, reason = reason) services.banStorage.add(ban) - + // Notify other clients val modPayload = ModerationPayload( action = ModerationAction.BAN, @@ -63,10 +65,10 @@ class MineChatCommandRegister(private val services: MineChatPluginServices) : Li reason = reason ) services.broadcastToClients(PacketTypes.MODERATION, modPayload) - + // Disconnect the client if online getClientConnection(playerName)?.disconnect(reason) - + ctx.source.sender.sendMessage(Component.text("Banned $playerName from MineChat.").color(NamedTextColor.GREEN)) Command.SINGLE_SUCCESS } @@ -92,11 +94,11 @@ class MineChatCommandRegister(private val services: MineChatPluginServices) : Li val playerName = StringArgumentType.getString(ctx, "player") val clientConnection = this.getClientConnection(playerName) if (clientConnection == null) { - ctx.source.sender.sendMessage(Component.text("Player not found or not connected via MineChat.").color(NamedTextColor.RED)) + ctx.source.sender.sendRichMessage("Player not found or not connected via MineChat.") return@executes 0 } val reason = "Kicked by an operator." - + // Notify other clients val modPayload = ModerationPayload( action = ModerationAction.KICK, @@ -104,9 +106,12 @@ class MineChatCommandRegister(private val services: MineChatPluginServices) : Li reason = reason ) services.broadcastToClients(PacketTypes.MODERATION, modPayload) - + clientConnection.disconnect(reason) - ctx.source.sender.sendMessage(Component.text("Kicked $playerName from MineChat.").color(NamedTextColor.GREEN)) + ctx.source.sender.sendRichMessage( + "Kicked from MineChat.", + Placeholder.unparsed("player", playerName) + ) Command.SINGLE_SUCCESS } ) diff --git a/src/main/kotlin/org/winlogon/minechat/MineChatLoader.kt b/src/main/kotlin/org/winlogon/minechat/MineChatLoader.kt index 5a22c07..1d8e836 100644 --- a/src/main/kotlin/org/winlogon/minechat/MineChatLoader.kt +++ b/src/main/kotlin/org/winlogon/minechat/MineChatLoader.kt @@ -1,3 +1,5 @@ +@file:Suppress("UnstableApiUsage") + package org.winlogon.minechat import io.papermc.paper.plugin.loader.PluginClasspathBuilder diff --git a/src/main/kotlin/org/winlogon/minechat/MineChatServerPlugin.kt b/src/main/kotlin/org/winlogon/minechat/MineChatServerPlugin.kt index 5f47f23..ee07091 100644 --- a/src/main/kotlin/org/winlogon/minechat/MineChatServerPlugin.kt +++ b/src/main/kotlin/org/winlogon/minechat/MineChatServerPlugin.kt @@ -10,7 +10,6 @@ import org.winlogon.minechat.storage.LinkCodeStorage import org.winlogon.minechat.entities.MyObjectBox import net.kyori.adventure.text.minimessage.MiniMessage -import net.kyori.adventure.text.serializer.plain.PlainTextComponentSerializer import org.bukkit.event.EventHandler import org.bukkit.event.Listener @@ -28,9 +27,8 @@ import javax.net.ssl.KeyManagerFactory import javax.net.ssl.SSLContext import kotlinx.serialization.decodeFromString -import kotlinx.serialization.serializer -class MineChatServerPlugin : JavaPlugin(), MineChatPluginServices { +class MineChatServerPlugin : JavaPlugin(), PluginServices { private var serverSocket: ServerSocket? = null private var isFolia: Boolean = false private lateinit var boxStore: BoxStore @@ -196,9 +194,9 @@ class MineChatServerPlugin : JavaPlugin(), MineChatPluginServices { connectedClients.forEach { client -> try { val mineChatPacket = MineChatPacket(packetType, payload) - val serialized = cbor.encodeToByteArray(MineChatPacketSerializer, mineChatPacket) + val serialized = cbor.encodeToByteArray(MineChatPacket, mineChatPacket) val compressed = com.github.luben.zstd.Zstd.compress(serialized) - + client.writer.writeInt(serialized.size) client.writer.writeInt(compressed.size) client.writer.write(compressed) diff --git a/src/main/kotlin/org/winlogon/minechat/MineChatPluginServices.kt b/src/main/kotlin/org/winlogon/minechat/PluginServices.kt similarity index 96% rename from src/main/kotlin/org/winlogon/minechat/MineChatPluginServices.kt rename to src/main/kotlin/org/winlogon/minechat/PluginServices.kt index 4f3d57b..e685c53 100644 --- a/src/main/kotlin/org/winlogon/minechat/MineChatPluginServices.kt +++ b/src/main/kotlin/org/winlogon/minechat/PluginServices.kt @@ -8,7 +8,7 @@ import org.winlogon.minechat.storage.ClientStorage import org.winlogon.minechat.storage.LinkCodeStorage import java.util.concurrent.ConcurrentLinkedQueue -interface MineChatPluginServices { +interface PluginServices { val pluginInstance: JavaPlugin val linkCodeStorage: LinkCodeStorage val clientStorage: ClientStorage diff --git a/src/main/kotlin/org/winlogon/minechat/Protocol.kt b/src/main/kotlin/org/winlogon/minechat/Protocol.kt index 80f8520..77f0ba2 100644 --- a/src/main/kotlin/org/winlogon/minechat/Protocol.kt +++ b/src/main/kotlin/org/winlogon/minechat/Protocol.kt @@ -5,8 +5,6 @@ package org.winlogon.minechat import kotlinx.serialization.ExperimentalSerializationApi import kotlinx.serialization.KSerializer import kotlinx.serialization.Serializable -import kotlinx.serialization.SerializationException -import kotlinx.serialization.SerialName import kotlinx.serialization.cbor.Cbor import kotlinx.serialization.cbor.CborLabel import kotlinx.serialization.descriptors.SerialDescriptor @@ -52,145 +50,104 @@ object ChatGradients { @Serializable sealed class PacketPayload -@OptIn(ExperimentalSerializationApi::class) @Serializable -@SerialName("link") data class LinkPayload( - @CborLabel(0) val linking_code: String, - @CborLabel(1) val client_uuid: String ) : PacketPayload() -@OptIn(ExperimentalSerializationApi::class) @Serializable -@SerialName("link_ok") data class LinkOkPayload( - @CborLabel(0) val minecraft_uuid: String ) : PacketPayload() -@OptIn(ExperimentalSerializationApi::class) @Serializable -@SerialName("capabilities") data class CapabilitiesPayload( - @CborLabel(0) val supports_components: Boolean ) : PacketPayload() -@OptIn(ExperimentalSerializationApi::class) @Serializable -@SerialName("auth_ok") class AuthOkPayload : PacketPayload() -@OptIn(ExperimentalSerializationApi::class) @Serializable -@SerialName("chat_message") data class ChatMessagePayload( - @CborLabel(0) val format: String, - @CborLabel(1) val content: String ) : PacketPayload() -@OptIn(ExperimentalSerializationApi::class) @Serializable -@SerialName("ping") data class PingPayload( - @CborLabel(0) val timestamp_ms: Long ) : PacketPayload() -@OptIn(ExperimentalSerializationApi::class) @Serializable -@SerialName("pong") data class PongPayload( - @CborLabel(0) val timestamp_ms: Long ) : PacketPayload() -@OptIn(ExperimentalSerializationApi::class) @Serializable -@SerialName("moderation") data class ModerationPayload( - @CborLabel(0) val action: Int, - @CborLabel(1) val scope: Int, - @CborLabel(2) val reason: String? = null, - @CborLabel(3) val duration_seconds: Int? = null ) : PacketPayload() -@OptIn(ExperimentalSerializationApi::class) @Serializable -@SerialName("disconnect") data class DisconnectPayload( - @CborLabel(0) val reason: String ) : PacketPayload() -@OptIn(ExperimentalSerializationApi::class) @Serializable -@SerialName("empty") class EmptyPayload : PacketPayload() -@OptIn(ExperimentalSerializationApi::class) data class MineChatPacket( - val packetType: Int, - val payload: PacketPayload + @CborLabel(0) val packetType: Int, + @CborLabel(1) val payload: PacketPayload ) { - companion object { - val serializer = MineChatPacketSerializer - } -} - -@OptIn(ExperimentalSerializationApi::class) -object MineChatPacketSerializer : KSerializer { - override val descriptor: SerialDescriptor = buildClassSerialDescriptor("MineChatPacket") { - element("packetType") - element("payload") - } + companion object : KSerializer { + private val packetDescriptor = buildClassSerialDescriptor("MineChatPacket") { + element("packetType", annotations = listOf(CborLabel(0))) + element("payload", annotations = listOf(CborLabel(1))) + } - override fun serialize(encoder: Encoder, value: MineChatPacket) { - encoder.encodeStructure(descriptor) { - encodeIntElement(descriptor, 0, value.packetType) - @Suppress("UNCHECKED_CAST") - encodeSerializableElement( - descriptor, 1, - payloadSerializer(value.packetType) as kotlinx.serialization.SerializationStrategy, - value.payload - ) + override val descriptor: SerialDescriptor get() = packetDescriptor + + override fun serialize(encoder: Encoder, value: MineChatPacket) { + encoder.encodeStructure(packetDescriptor) { + encodeIntElement(packetDescriptor, 0, value.packetType) + @Suppress("UNCHECKED_CAST") + encodeSerializableElement( + packetDescriptor, 1, + getPayloadSerializer(value.packetType) as kotlinx.serialization.SerializationStrategy, + value.payload + ) + } } - } - override fun deserialize(decoder: Decoder): MineChatPacket { - return decoder.decodeStructure(descriptor) { - var packetType: Int? = null - var payload: PacketPayload? = null - - while (true) { - when (decodeElementIndex(descriptor)) { - 0 -> packetType = decodeIntElement(descriptor, 0) - 1 -> { - val pt = packetType ?: throw SerializationException("packetType must be before payload") - payload = decodeSerializableElement(descriptor, 1, payloadSerializer(pt)) + override fun deserialize(decoder: Decoder): MineChatPacket { + var packetType = 0 + var payload: PacketPayload = EmptyPayload() + + decoder.decodeStructure(packetDescriptor) { + while (true) { + when (decodeElementIndex(packetDescriptor)) { + 0 -> packetType = decodeIntElement(packetDescriptor, 0) + 1 -> { + @Suppress("UNCHECKED_CAST") + payload = decodeSerializableElement( + packetDescriptor, 1, + getPayloadSerializer(packetType) as kotlinx.serialization.DeserializationStrategy + ) + } + CompositeDecoder.DECODE_DONE -> break } - CompositeDecoder.DECODE_DONE -> break - else -> throw SerializationException("Unknown index") } } - - if (packetType == null) throw SerializationException("Missing packetType") - if (payload == null) throw SerializationException("Missing payload") - - MineChatPacket(packetType, payload) + return MineChatPacket(packetType, payload) } - } - private fun payloadSerializer(packetType: Int): KSerializer { - return when (packetType) { + private fun getPayloadSerializer(type: Int): KSerializer = when (type) { PacketTypes.LINK -> LinkPayload.serializer() PacketTypes.LINK_OK -> LinkOkPayload.serializer() PacketTypes.CAPABILITIES -> CapabilitiesPayload.serializer() From e5cf23387d954de3891ccb34f1626d030fb4d97f Mon Sep 17 00:00:00 2001 From: winlogon Date: Sat, 14 Mar 2026 09:04:52 +0100 Subject: [PATCH 25/30] feat: add pre-commit config --- .pre-commit-config.yaml | 7 +++++++ 1 file changed, 7 insertions(+) create mode 100644 .pre-commit-config.yaml diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 0000000..859ac3d --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,7 @@ +repos: +- repo: https://github.com/pre-commit/pre-commit-hooks + rev: v2.3.0 + hooks: + - id: check-yaml + - id: end-of-file-fixer + - id: trailing-whitespace From 1b35b2898713a6efd71fcba6e01d179de8d51631 Mon Sep 17 00:00:00 2001 From: winlogon Date: Sat, 14 Mar 2026 09:31:36 +0100 Subject: [PATCH 26/30] refactor(client): use plugin MiniMessage instance MiniMessage was instantiated directly in ClientConnection, bypassing the plugin-managed instance and causing unnecessary object allocation. This centralizes MiniMessage usage and removes redundant dependencies. Summary of changes: - Use plugin.miniMessage instead of new MiniMessage instance - Remove MiniMessage import from ClientConnection - Remove unused ExecutorService parameter from constructor as ClientConnection uses its per-instance one - Update ClientConnection instantiation in plugin - Refactor MarkdownSerializer parsing logic - Simplify deserialize implementation - Replace remaining/currentText with index-based parsing - Add buffer flushing helper for cleaner component building - Add helper for applying text decorations - Improve token detection for markdown markers - Simplify serialization decoration handling --- .../org/winlogon/minechat/ClientConnection.kt | 9 +- .../winlogon/minechat/MarkdownSerializer.kt | 154 ++++++++++-------- .../winlogon/minechat/MineChatServerPlugin.kt | 2 +- 3 files changed, 88 insertions(+), 77 deletions(-) diff --git a/src/main/kotlin/org/winlogon/minechat/ClientConnection.kt b/src/main/kotlin/org/winlogon/minechat/ClientConnection.kt index 4ccd95a..38871e7 100644 --- a/src/main/kotlin/org/winlogon/minechat/ClientConnection.kt +++ b/src/main/kotlin/org/winlogon/minechat/ClientConnection.kt @@ -3,7 +3,6 @@ package org.winlogon.minechat import com.github.luben.zstd.Zstd import kotlinx.serialization.ExperimentalSerializationApi import net.kyori.adventure.text.Component -import net.kyori.adventure.text.minimessage.MiniMessage import net.kyori.adventure.text.serializer.legacy.LegacyComponentSerializer import org.bukkit.Bukkit @@ -19,13 +18,11 @@ import java.util.concurrent.Executors import java.util.concurrent.ScheduledExecutorService import java.util.concurrent.TimeUnit import java.util.concurrent.atomic.AtomicBoolean -import java.util.concurrent.ExecutorService import java.util.logging.Logger class ClientConnection( private val socket: Socket, - private val plugin: MineChatServerPlugin, - private val executorService: ExecutorService + private val plugin: MineChatServerPlugin ) : Runnable { companion object { const val MINECHAT_PREFIX_STRING = "&8[&3MineChat&8]" @@ -418,7 +415,7 @@ class ClientConnection( if (format == "commonmark") { // Parse commonmark and send to Minecraft val component = try { - MiniMessage.miniMessage().deserialize(content) + plugin.miniMessage.deserialize(content) } catch (e: Exception) { logger.warning("Failed to parse MiniMessage: ${e.message}") Component.text(content) @@ -441,7 +438,7 @@ class ClientConnection( private fun broadcastMinecraft(gradient: Pair, message: String) { val gradientColor = "$message" - val component = MiniMessage.miniMessage().deserialize(gradientColor) + val component = plugin.miniMessage.deserialize(gradientColor) broadcastMinecraft(formatPrefixed(component)) } diff --git a/src/main/kotlin/org/winlogon/minechat/MarkdownSerializer.kt b/src/main/kotlin/org/winlogon/minechat/MarkdownSerializer.kt index 5549507..e4502ff 100644 --- a/src/main/kotlin/org/winlogon/minechat/MarkdownSerializer.kt +++ b/src/main/kotlin/org/winlogon/minechat/MarkdownSerializer.kt @@ -7,9 +7,7 @@ import net.kyori.adventure.text.format.TextDecoration import net.kyori.adventure.text.serializer.ComponentSerializer class MarkdownSerializer : ComponentSerializer { - override fun deserialize(input: String): Component { - return parseMarkdown(input) - } + override fun deserialize(input: String): Component = parseMarkdown(input) override fun serialize(component: Component): String { val sb = StringBuilder() @@ -20,103 +18,119 @@ class MarkdownSerializer : ComponentSerializer { private fun parseMarkdown(input: String): Component { if (input.isEmpty()) return Component.empty() - val builder = Component.text() - var remaining = input - var currentText = StringBuilder() + val builder = Component.text() // builder to accumulate components + val buffer = StringBuilder() + var i = 0 + val n = input.length - while (remaining.isNotEmpty()) { + fun flushBuffer() { + if (buffer.isNotEmpty()) { + builder.append(Component.text(buffer.toString())) + buffer.setLength(0) + } + } + + fun appendDecorated(text: String, vararg decorations: TextDecoration) { + var comp = Component.text(text) + for (dec in decorations) comp = comp.decorate(dec) + builder.append(comp) + } + + // helper to find closing token start index, returns -1 if not found + fun findClosing(startIndex: Int, token: String): Int = + input.indexOf(token, startIndex + token.length).also { if (it == -1) { /* not found */ } } + + while (i < n) { + // Try multi-char tokens first when { - remaining.startsWith("**") -> { - if (currentText.isNotEmpty()) { - builder.append(Component.text(currentText.toString())) - currentText = StringBuilder() - } - val endIndex = remaining.indexOf("**", 2) - if (endIndex != -1) { - val boldContent = remaining.substring(2, endIndex) - builder.append(Component.text(boldContent).decorate(TextDecoration.BOLD)) - remaining = remaining.substring(endIndex + 2) + input.startsWith("**", i) -> { + val close = findClosing(i, "**") + if (close != -1) { + flushBuffer() + val content = input.substring(i + 2, close) + appendDecorated(content, TextDecoration.BOLD) + i = close + 2 } else { - currentText.append(remaining.take(2)) - remaining = remaining.substring(2) + // treat literally + buffer.append("**") + i += 2 } } - remaining.startsWith("~~") -> { - if (currentText.isNotEmpty()) { - builder.append(Component.text(currentText.toString())) - currentText = StringBuilder() - } - val endIndex = remaining.indexOf("~~", 2) - if (endIndex != -1) { - val strikeContent = remaining.substring(2, endIndex) - builder.append(Component.text(strikeContent).decorate(TextDecoration.STRIKETHROUGH)) - remaining = remaining.substring(endIndex + 2) + + input.startsWith("~~", i) -> { + val close = findClosing(i, "~~") + if (close != -1) { + flushBuffer() + val content = input.substring(i + 2, close) + appendDecorated(content, TextDecoration.STRIKETHROUGH) + i = close + 2 } else { - currentText.append(remaining.take(2)) - remaining = remaining.substring(2) + buffer.append("~~") + i += 2 } } - remaining.startsWith("*") -> { - if (currentText.isNotEmpty()) { - builder.append(Component.text(currentText.toString())) - currentText = StringBuilder() - } - val endIndex = remaining.indexOf("*", 1) - if (endIndex > 1) { - val italicContent = remaining.substring(1, endIndex) - builder.append(Component.text(italicContent).decorate(TextDecoration.ITALIC)) - remaining = remaining.substring(endIndex + 1) + + // Single-char tokens + input[i] == '*' -> { + // avoid matching "**" (already handled) + val close = input.indexOf('*', i + 1) + if (close != -1) { + flushBuffer() + val content = input.substring(i + 1, close) + appendDecorated(content, TextDecoration.ITALIC) + i = close + 1 } else { - currentText.append(remaining.take(1)) - remaining = remaining.substring(1) + buffer.append('*') + i++ } } - remaining.startsWith("`") -> { - if (currentText.isNotEmpty()) { - builder.append(Component.text(currentText.toString())) - currentText = StringBuilder() - } - val endIndex = remaining.indexOf("`", 1) - if (endIndex != -1) { - val codeContent = remaining.substring(1, endIndex) - builder.append(Component.text(codeContent).color(NamedTextColor.GRAY)) - remaining = remaining.substring(endIndex + 1) + + input[i] == '`' -> { + val close = input.indexOf('`', i + 1) + if (close != -1) { + flushBuffer() + val content = input.substring(i + 1, close) + // inline code — gray color in original + builder.append(Component.text(content).color(NamedTextColor.GRAY)) + i = close + 1 } else { - currentText.append(remaining.take(1)) - remaining = remaining.substring(1) + buffer.append('`') + i++ } } + else -> { - currentText.append(remaining.first()) - remaining = remaining.substring(1) + buffer.append(input[i]) + i++ } } } - if (currentText.isNotEmpty()) { - builder.append(Component.text(currentText.toString())) - } - + flushBuffer() return builder.build() } private fun serializeComponent(component: Component, sb: StringBuilder) { + // If this is a TextComponent, write markers for active decorations if (component is TextComponent) { - val hasBold = component.hasDecoration(TextDecoration.BOLD) - val hasItalic = component.hasDecoration(TextDecoration.ITALIC) - val hasStrikethrough = component.hasDecoration(TextDecoration.STRIKETHROUGH) + val bold = component.hasDecoration(TextDecoration.BOLD) + val italic = component.hasDecoration(TextDecoration.ITALIC) + val strike = component.hasDecoration(TextDecoration.STRIKETHROUGH) - if (hasBold) sb.append("**") - if (hasItalic) sb.append("*") - if (hasStrikethrough) sb.append("~~") + // openers (fixed order) + if (bold) sb.append("**") + if (italic) sb.append("*") + if (strike) sb.append("~~") sb.append(component.content()) - if (hasStrikethrough) sb.append("~~") - if (hasItalic) sb.append("*") - if (hasBold) sb.append("**") + // closers in reverse order + if (strike) sb.append("~~") + if (italic) sb.append("*") + if (bold) sb.append("**") } + // serialize children for (child in component.children()) { serializeComponent(child, sb) } diff --git a/src/main/kotlin/org/winlogon/minechat/MineChatServerPlugin.kt b/src/main/kotlin/org/winlogon/minechat/MineChatServerPlugin.kt index ee07091..c3b58d7 100644 --- a/src/main/kotlin/org/winlogon/minechat/MineChatServerPlugin.kt +++ b/src/main/kotlin/org/winlogon/minechat/MineChatServerPlugin.kt @@ -136,7 +136,7 @@ class MineChatServerPlugin : JavaPlugin(), PluginServices { val socket = serverSocket?.accept() if (socket != null) { loggerProvider.logger.info("Client connected: ${socket.inetAddress}") - val connection = ClientConnection(socket, this, executorService) + val connection = ClientConnection(socket, this) connectedClients.add(connection) executorService.submit(connection) } From ce6441943485b98dc4dd5823881c6804f2ce2d38 Mon Sep 17 00:00:00 2001 From: winlogon Date: Sat, 14 Mar 2026 10:09:31 +0100 Subject: [PATCH 27/30] chore(tls): switch to PKCS12 keystore and TLS 1.3 TLS configuration was updated to use modern defaults and improve security. The plugin now expects a PKCS12 keystore and enforces TLS 1.3 for all connections. Summary of changes: - Switch keystore format from JKS to PKCS12 - Update README instructions for EC secp384r1 keystore generation - Enforce TLS 1.3 on the SSL server socket - Rename MineChatServerPlugin to MineChatPlugin - Rename MineChatCommandRegister to CommandRegister - Update default config keystore name to keystore.p12 - Refactor MavenLibraryResolver dependency setup - Update caffeine version to 3.2.0 - Change zstd-jni dependency to compileOnly - Clean up imports and minor build script improvements --- README.md | 25 ++++++++------- build.gradle.kts | 13 +++----- gradle/libs.versions.toml | 2 +- .../org/winlogon/minechat/ClientConnection.kt | 5 ++- ...tCommandRegister.kt => CommandRegister.kt} | 2 +- .../org/winlogon/minechat/MineChatConfig.kt | 2 +- .../org/winlogon/minechat/MineChatLoader.kt | 32 +++++++++++++++---- ...eChatServerPlugin.kt => MineChatPlugin.kt} | 28 +++++++++------- .../minechat/storage/ClientStorage.kt | 1 + .../minechat/storage/LinkCodeStorage.kt | 7 ++-- src/main/resources/config.yml | 2 +- src/main/resources/paper-plugin.yml | 2 +- 12 files changed, 74 insertions(+), 47 deletions(-) rename src/main/kotlin/org/winlogon/minechat/{MineChatCommandRegister.kt => CommandRegister.kt} (98%) rename src/main/kotlin/org/winlogon/minechat/{MineChatServerPlugin.kt => MineChatPlugin.kt} (94%) diff --git a/README.md b/README.md index a3def29..eac822a 100644 --- a/README.md +++ b/README.md @@ -24,33 +24,34 @@ The plugin works by generating temporary codes that players can use to authentic - Place the downloaded JAR file into your server's `plugins` directory. 3. **Generate a TLS keystore**: - MineChat requires TLS to be enabled. Generate a self-signed keystore: + MineChat requires TLS to be enabled. Generate a self-signed keystore using modern algorithms (EC secp384r1): ```bash keytool -genkeypair \ -alias minechat \ - -keyalg RSA \ - -keysize 2048 \ - -storetype JKS \ - -keystore keystore.jks \ + -keyalg EC \ + -keysize 384 \ + -storetype PKCS12 \ + -keystore keystore.p12 \ -validity 3650 \ - -storepassword \ - -keypassword \ + -storepass \ + -keypass \ -dname "CN=localhost, OU=Dev, O=MineChat, L=City, ST=State, C=US" ``` 4. **Configure TLS**: - - Copy the generated `keystore.jks` to your server's plugin data folder: + - Copy the generated `keystore.p12` to your server's plugin data folder: ``` - /plugins/MineChat/keystore.jks + /plugins/MineChat/keystore.p12 ``` - Edit the generated `config.yml` in the plugin folder, or create one with: ```yaml port: 25575 tls: enabled: true - keystore: "keystore.jks" + keystore: "keystore.p12" keystore-password: "your-password" ``` + - **Note**: The server enforces TLS 1.3 for maximum security. 5. **Start Your Server**: Start or restart your Paper server to load the MineChat Server Plugin. @@ -71,13 +72,13 @@ The plugin works by generating temporary codes that players can use to authentic ## How it works -- **Initial phase**: +- **Initial phase**: The plugin opens a server socket on port `25575` to listen for connections from MineChat clients. - **Authentication**: - Clients use either a new link code or their stored client UUID to authenticate. - Successful authentication triggers in-game notifications (join/leave messages) to all players. - + - **Message broadcasting**: - The plugin listens for in-game chat events and broadcasts messages to connected clients. - Similarly, messages received from clients are broadcast to the Minecraft chat. diff --git a/build.gradle.kts b/build.gradle.kts index a552a11..254849d 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -1,6 +1,3 @@ -import java.text.SimpleDateFormat -import java.util.* - buildscript { repositories { mavenCentral() @@ -33,7 +30,7 @@ fun getLatestGitTag(): String? { } else { null } - } catch (e: Exception) { + } catch (_: Exception) { null } } @@ -83,11 +80,11 @@ repositories { mavenCentral() } +// TODO: move these to libs.versions.toml dependencies { compileOnly("com.github.ben-manes.caffeine:caffeine:3.2.0") - implementation("com.github.luben:zstd-jni:1.5.6-1") - // idt this is needed - // compileOnly("com.google.code.gson:gson:2.11.0") + compileOnly("com.github.luben:zstd-jni:1.5.6-1") + compileOnly("io.objectbox:objectbox-kotlin:3.8.0") compileOnly("io.papermc.paper:paper-api:1.21.6-R0.1-SNAPSHOT") // is this needed? @@ -95,7 +92,7 @@ dependencies { compileOnly("org.winlogon:asynccraftr:0.1.0") implementation("com.charleskorn.kaml:kaml:" + libs.versions.kaml.get()) - // Jackson + // TODO: is this actually used anywhere? implementation("com.fasterxml.jackson.dataformat:jackson-dataformat-cbor:2.17.1") implementation("com.fasterxml.jackson.module:jackson-module-kotlin:2.17.1") diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 698201d..afc07c8 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -2,4 +2,4 @@ kotlinx-serialization-json = "1.10.0" kotlinx-serialization-cbor = "1.10.0" kaml = "0.102.0" -caffeine = "3.1.8" +caffeine = "3.2.0" diff --git a/src/main/kotlin/org/winlogon/minechat/ClientConnection.kt b/src/main/kotlin/org/winlogon/minechat/ClientConnection.kt index 38871e7..d912caf 100644 --- a/src/main/kotlin/org/winlogon/minechat/ClientConnection.kt +++ b/src/main/kotlin/org/winlogon/minechat/ClientConnection.kt @@ -20,9 +20,10 @@ import java.util.concurrent.TimeUnit import java.util.concurrent.atomic.AtomicBoolean import java.util.logging.Logger +// TODO: add brief kdoc documentation to the public functions class ClientConnection( private val socket: Socket, - private val plugin: MineChatServerPlugin + private val plugin: MineChatPlugin ) : Runnable { companion object { const val MINECHAT_PREFIX_STRING = "&8[&3MineChat&8]" @@ -198,6 +199,7 @@ class ClientConnection( } } + // TODO: "Function "sendMessage" is never used" private fun sendMessage(packetType: Int, payload: CapabilitiesPayload) { try { val mineChatPacket = createPacket(packetType, payload) @@ -216,6 +218,7 @@ class ClientConnection( } } + // TODO: "Function "sendMessage" is never used" private fun sendMessage(packetType: Int, payload: ChatMessagePayload) { try { val mineChatPacket = createPacket(packetType, payload) diff --git a/src/main/kotlin/org/winlogon/minechat/MineChatCommandRegister.kt b/src/main/kotlin/org/winlogon/minechat/CommandRegister.kt similarity index 98% rename from src/main/kotlin/org/winlogon/minechat/MineChatCommandRegister.kt rename to src/main/kotlin/org/winlogon/minechat/CommandRegister.kt index 600837f..8dfb276 100644 --- a/src/main/kotlin/org/winlogon/minechat/MineChatCommandRegister.kt +++ b/src/main/kotlin/org/winlogon/minechat/CommandRegister.kt @@ -17,7 +17,7 @@ import org.bukkit.event.Listener import org.winlogon.minechat.entities.Ban import org.winlogon.minechat.entities.LinkCode -class MineChatCommandRegister(private val services: PluginServices) : Listener { +class CommandRegister(private val services: PluginServices) : Listener { fun registerCommands() { val linkCommand = Commands.literal("link") .requires { sender -> sender.executor is Player } diff --git a/src/main/kotlin/org/winlogon/minechat/MineChatConfig.kt b/src/main/kotlin/org/winlogon/minechat/MineChatConfig.kt index 355a986..87d0de0 100644 --- a/src/main/kotlin/org/winlogon/minechat/MineChatConfig.kt +++ b/src/main/kotlin/org/winlogon/minechat/MineChatConfig.kt @@ -6,7 +6,7 @@ import kotlinx.serialization.Serializable @Serializable data class TlsConfig( val enabled: Boolean = true, - val keystore: String = "keystore.jks", + val keystore: String = "keystore.p12", @SerialName("keystore-password") val keystorePassword: String = "password" ) diff --git a/src/main/kotlin/org/winlogon/minechat/MineChatLoader.kt b/src/main/kotlin/org/winlogon/minechat/MineChatLoader.kt index 1d8e836..44708e4 100644 --- a/src/main/kotlin/org/winlogon/minechat/MineChatLoader.kt +++ b/src/main/kotlin/org/winlogon/minechat/MineChatLoader.kt @@ -1,4 +1,4 @@ -@file:Suppress("UnstableApiUsage") +@file:Suppress("UnstableApiUsage") // Loader API is "experimental" package org.winlogon.minechat @@ -13,13 +13,31 @@ import org.eclipse.aether.repository.RemoteRepository class MineChatLoader : PluginLoader { override fun classloader(classpathBuilder: PluginClasspathBuilder) { val caffeineVersion = "3.2.0" - val resolver = MavenLibraryResolver().apply { - addDependency(Dependency(DefaultArtifact("com.github.ben-manes.caffeine:caffeine:$caffeineVersion"), null)) - addRepository( - RemoteRepository.Builder("maven-central", "default", MavenLibraryResolver.MAVEN_CENTRAL_DEFAULT_MIRROR) - .build() - ) + val resolver = MavenLibraryResolver() + + val dependencies = arrayOf( + library("com.github.ben-manes.caffeine", "caffeine", caffeineVersion), + library("com.github.luben", "zstd-jni", "1.5.6-1") + ) + + val repositories = arrayOf( + repo("maven-central", MavenLibraryResolver.MAVEN_CENTRAL_DEFAULT_MIRROR) + ) + + for (dependency in dependencies) { + resolver.addDependency(dependency) + } + + for (repository in repositories) { + resolver.addRepository(repository) } + classpathBuilder.addLibrary(resolver) } + + fun library(group: String, artifact: String, version: String): Dependency = + Dependency(DefaultArtifact("$group:$artifact:$version"), null) + + fun repo(id: String, url: String): RemoteRepository = + RemoteRepository.Builder(id, "default", url).build() } diff --git a/src/main/kotlin/org/winlogon/minechat/MineChatServerPlugin.kt b/src/main/kotlin/org/winlogon/minechat/MineChatPlugin.kt similarity index 94% rename from src/main/kotlin/org/winlogon/minechat/MineChatServerPlugin.kt rename to src/main/kotlin/org/winlogon/minechat/MineChatPlugin.kt index c3b58d7..d0866fd 100644 --- a/src/main/kotlin/org/winlogon/minechat/MineChatServerPlugin.kt +++ b/src/main/kotlin/org/winlogon/minechat/MineChatPlugin.kt @@ -4,18 +4,18 @@ import com.charleskorn.kaml.Yaml import io.objectbox.BoxStore import io.papermc.paper.event.player.AsyncChatEvent -import org.winlogon.minechat.storage.BanStorage -import org.winlogon.minechat.storage.ClientStorage -import org.winlogon.minechat.storage.LinkCodeStorage -import org.winlogon.minechat.entities.MyObjectBox import net.kyori.adventure.text.minimessage.MiniMessage +import org.bukkit.Bukkit import org.bukkit.event.EventHandler import org.bukkit.event.Listener import org.bukkit.permissions.Permission import org.bukkit.plugin.java.JavaPlugin -import org.bukkit.Bukkit +import org.winlogon.minechat.entities.MyObjectBox +import org.winlogon.minechat.storage.BanStorage +import org.winlogon.minechat.storage.ClientStorage +import org.winlogon.minechat.storage.LinkCodeStorage import java.io.File import java.net.ServerSocket @@ -25,10 +25,10 @@ import java.util.concurrent.Executors import java.util.concurrent.TimeUnit import javax.net.ssl.KeyManagerFactory import javax.net.ssl.SSLContext - +import javax.net.ssl.SSLServerSocket import kotlinx.serialization.decodeFromString -class MineChatServerPlugin : JavaPlugin(), PluginServices { +class MineChatPlugin : JavaPlugin(), PluginServices { private var serverSocket: ServerSocket? = null private var isFolia: Boolean = false private lateinit var boxStore: BoxStore @@ -94,7 +94,7 @@ class MineChatServerPlugin : JavaPlugin(), PluginServices { clientStorage = ClientStorage(boxStore) banStorage = BanStorage(boxStore) - MineChatCommandRegister(this).registerCommands() + CommandRegister(this).registerCommands() if (!mineChatConfig.tls.enabled) { loggerProvider.logger.severe("MineChat server cannot start: TLS is disabled in config.yml, but it is mandatory.") @@ -110,7 +110,7 @@ class MineChatServerPlugin : JavaPlugin(), PluginServices { } try { - val keyStore = KeyStore.getInstance("JKS") + val keyStore = KeyStore.getInstance("PKCS12") keyStore.load(keystoreFile.inputStream(), keystorePassword) val keyManagerFactory = KeyManagerFactory.getInstance(KeyManagerFactory.getDefaultAlgorithm()) @@ -121,7 +121,11 @@ class MineChatServerPlugin : JavaPlugin(), PluginServices { val sslServerSocketFactory = sslContext.serverSocketFactory serverSocket = sslServerSocketFactory.createServerSocket(mineChatConfig.port) - loggerProvider.logger.info("MineChat server started with TLS on port ${mineChatConfig.port}") + + // Enforce TLS 1.3 + (serverSocket as SSLServerSocket).enabledProtocols = arrayOf("TLSv1.3") + + loggerProvider.logger.info("MineChat server started with TLS 1.3 on port ${mineChatConfig.port}") } catch (e: Exception) { loggerProvider.logger.severe("MineChat server cannot start: Failed to initialize TLS: ${e.message}. TLS is mandatory as per specification.") e.printStackTrace() @@ -153,7 +157,7 @@ class MineChatServerPlugin : JavaPlugin(), PluginServices { server.pluginManager.registerEvents(object : Listener { @EventHandler fun onChat(event: AsyncChatEvent) { - this@MineChatServerPlugin.onChat(event) + this@MineChatPlugin.onChat(event) } }, this) } @@ -169,7 +173,7 @@ class MineChatServerPlugin : JavaPlugin(), PluginServices { } override fun onDisable() { - loggerProvider.logger.info("Disabling MineChatServerPlugin") + loggerProvider.logger.info("Disabling MineChatPlugin") isServerRunning = false serverThread?.interrupt() serverSocket?.close() diff --git a/src/main/kotlin/org/winlogon/minechat/storage/ClientStorage.kt b/src/main/kotlin/org/winlogon/minechat/storage/ClientStorage.kt index 7bfb09b..0ce7e4d 100644 --- a/src/main/kotlin/org/winlogon/minechat/storage/ClientStorage.kt +++ b/src/main/kotlin/org/winlogon/minechat/storage/ClientStorage.kt @@ -44,6 +44,7 @@ class ClientStorage(boxStore: BoxStore) { } } + // TODO: "Function "remove" is never used" fun remove(clientUuid: String?, minecraftUsername: String?) { if (clientUuid != null) { clientCache.invalidate(clientUuid) diff --git a/src/main/kotlin/org/winlogon/minechat/storage/LinkCodeStorage.kt b/src/main/kotlin/org/winlogon/minechat/storage/LinkCodeStorage.kt index 3f49f91..2d5de4a 100644 --- a/src/main/kotlin/org/winlogon/minechat/storage/LinkCodeStorage.kt +++ b/src/main/kotlin/org/winlogon/minechat/storage/LinkCodeStorage.kt @@ -7,11 +7,14 @@ import io.objectbox.Box import io.objectbox.BoxStore import org.winlogon.minechat.entities.LinkCode import org.winlogon.minechat.entities.LinkCode_ +import java.io.Closeable import java.util.concurrent.Executors import java.util.concurrent.TimeUnit -class LinkCodeStorage(private val boxStore: BoxStore) { +// TODO: "Constructor parameter is never used as a property" +// TODO: use Closeable properly +class LinkCodeStorage(private val boxStore: BoxStore) : Closeable { private val linkCodeBox: Box = boxStore.boxFor(LinkCode::class.java) private val scheduler = Executors.newSingleThreadScheduledExecutor() private val linkCodeCache: Cache = Caffeine.newBuilder() @@ -49,7 +52,7 @@ class LinkCodeStorage(private val boxStore: BoxStore) { linkCodeCache.asMap().values.removeIf { it.expiresAt < now } } - fun close() { + override fun close() { scheduler.shutdown() } } diff --git a/src/main/resources/config.yml b/src/main/resources/config.yml index 89ff22f..ff981a4 100644 --- a/src/main/resources/config.yml +++ b/src/main/resources/config.yml @@ -14,6 +14,6 @@ tls: # If enabled, you must provide a keystore. enabled: true # The name of the keystore file in the plugin's data folder. - keystore: "keystore.jks" + keystore: "keystore.p12" # The password for the keystore. keystore-password: "password" diff --git a/src/main/resources/paper-plugin.yml b/src/main/resources/paper-plugin.yml index b8f86f8..9b6429b 100644 --- a/src/main/resources/paper-plugin.yml +++ b/src/main/resources/paper-plugin.yml @@ -1,6 +1,6 @@ name: ${NAME} version: ${VERSION} -main: ${PACKAGE}.MineChatServerPlugin +main: ${PACKAGE}.MineChatPlugin description: MineChat Server author: winlogon website: https://github.com/walker84837/MineChat-Server From 00e8329ef5f723c65f1006dee32884c999d9decf Mon Sep 17 00:00:00 2001 From: winlogon Date: Sat, 14 Mar 2026 13:26:02 +0100 Subject: [PATCH 28/30] chore(build): migrate deps to version catalog Dependencies were previously declared inline in the build script, which made version management harder and duplicated version information. Moving them to the Gradle version catalog centralizes dependency versions and improves maintainability. Summary of changes: - Move dependency versions to libs.versions.toml - Replace inline dependency declarations with catalog refs - Add library mappings for all dependencies - Add version entries for zstd-jni, objectbox, paper, kotlin - Add version entries for asynccraftr, jackson, and junit - Remove outdated TODO comments in build.gradle.kts - Remove generated comments from Gradle config files --- build.gradle.kts | 43 +++++++++++++++++---------------------- gradle.properties | 4 ---- gradle/libs.versions.toml | 24 ++++++++++++++++++++++ settings.gradle.kts | 8 -------- 4 files changed, 43 insertions(+), 36 deletions(-) diff --git a/build.gradle.kts b/build.gradle.kts index 254849d..e0634e8 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -80,29 +80,26 @@ repositories { mavenCentral() } -// TODO: move these to libs.versions.toml dependencies { - compileOnly("com.github.ben-manes.caffeine:caffeine:3.2.0") - compileOnly("com.github.luben:zstd-jni:1.5.6-1") - - compileOnly("io.objectbox:objectbox-kotlin:3.8.0") - compileOnly("io.papermc.paper:paper-api:1.21.6-R0.1-SNAPSHOT") - // is this needed? - compileOnly("org.jetbrains.kotlin:kotlin-reflect:2.1.10") - compileOnly("org.winlogon:asynccraftr:0.1.0") - implementation("com.charleskorn.kaml:kaml:" + libs.versions.kaml.get()) - - // TODO: is this actually used anywhere? - implementation("com.fasterxml.jackson.dataformat:jackson-dataformat-cbor:2.17.1") - implementation("com.fasterxml.jackson.module:jackson-module-kotlin:2.17.1") - - implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:" + libs.versions.kotlinx.serialization.json.get()) - implementation("org.jetbrains.kotlinx:kotlinx-serialization-cbor:" + libs.versions.kotlinx.serialization.cbor.get()) - - testRuntimeOnly("org.junit.platform:junit-platform-launcher:1.11.4") - testImplementation("io.papermc.paper:paper-api:1.21.6-R0.1-SNAPSHOT") - testImplementation("org.junit.jupiter:junit-jupiter:5.11.4") - testImplementation("org.jetbrains.kotlin:kotlin-test:2.1.10") + compileOnly(libs.caffeine) + compileOnly(libs.zstd.jni) + + compileOnly(libs.objectbox.kotlin) + compileOnly(libs.paper.api) + compileOnly(libs.kotlin.reflect) + compileOnly(libs.asynccraftr) + implementation(libs.kaml) + + implementation(libs.jackson.dataformat.cbor) + implementation(libs.jackson.module.kotlin) + + implementation(libs.kotlinx.serialization.json) + implementation(libs.kotlinx.serialization.cbor) + + testRuntimeOnly(libs.junit.platform.launcher) + testImplementation(libs.paper.api.test) + testImplementation(libs.junit.jupiter) + testImplementation(libs.kotlin.test) } tasks.test { @@ -126,7 +123,6 @@ tasks.shadowJar { minimize() } -// Disable jar and replace with shadowJar tasks.jar { enabled = false } @@ -135,7 +131,6 @@ tasks.assemble { dependsOn(tasks.shadowJar) } -// Utility tasks tasks.register("printProjectName") { doLast { println(projectName) diff --git a/gradle.properties b/gradle.properties index 1f11f9b..1063152 100644 --- a/gradle.properties +++ b/gradle.properties @@ -1,7 +1,3 @@ -# This file was generated by the Gradle 'init' task. -# https://docs.gradle.org/current/userguide/build_environment.html#sec:gradle_configuration_properties - org.gradle.configuration-cache=false org.gradle.parallel=true org.gradle.caching=true - diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index afc07c8..410596d 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -3,3 +3,27 @@ kotlinx-serialization-json = "1.10.0" kotlinx-serialization-cbor = "1.10.0" kaml = "0.102.0" caffeine = "3.2.0" +zstd-jni = "1.5.6-1" +objectbox = "3.8.0" +paper = "1.21.6-R0.1-SNAPSHOT" +kotlin = "2.1.10" +asynccraftr = "0.1.0" +jackson = "2.17.1" +junit = "1.11.4" + +[libraries] +caffeine = { module = "com.github.ben-manes.caffeine:caffeine", version.ref = "caffeine" } +zstd-jni = { module = "com.github.luben:zstd-jni", version.ref = "zstd-jni" } +objectbox-kotlin = { module = "io.objectbox:objectbox-kotlin", version.ref = "objectbox" } +paper-api = { module = "io.papermc.paper:paper-api", version.ref = "paper" } +kotlin-reflect = { module = "org.jetbrains.kotlin:kotlin-reflect", version.ref = "kotlin" } +asynccraftr = { module = "org.winlogon:asynccraftr", version.ref = "asynccraftr" } +kaml = { module = "com.charleskorn.kaml:kaml", version.ref = "kaml" } +jackson-dataformat-cbor = { module = "com.fasterxml.jackson.dataformat:jackson-dataformat-cbor", version.ref = "jackson" } +jackson-module-kotlin = { module = "com.fasterxml.jackson.module:jackson-module-kotlin", version.ref = "jackson" } +kotlinx-serialization-json = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "kotlinx-serialization-json" } +kotlinx-serialization-cbor = { module = "org.jetbrains.kotlinx:kotlinx-serialization-cbor", version.ref = "kotlinx-serialization-cbor" } +junit-platform-launcher = { module = "org.junit.platform:junit-platform-launcher", version.ref = "junit" } +paper-api-test = { module = "io.papermc.paper:paper-api", version.ref = "paper" } +junit-jupiter = { module = "org.junit.jupiter:junit-jupiter", version.ref = "junit" } +kotlin-test = { module = "org.jetbrains.kotlin:kotlin-test", version.ref = "kotlin" } diff --git a/settings.gradle.kts b/settings.gradle.kts index ab0302f..c258f0e 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -1,11 +1,3 @@ -/* - * This file was generated by the Gradle 'init' task. - * - * The settings file is used to specify which projects to include in your build. - * For more detailed information on multi-project builds, please refer to https://docs.gradle.org/8.12/userguide/multi_project_builds.html in the Gradle documentation. - * This project uses @Incubating APIs which are subject to change. - */ - pluginManagement { repositories { gradlePluginPortal() From 051966d3cdf0f793ea6d7b0a4d503129641c8972 Mon Sep 17 00:00:00 2001 From: winlogon Date: Sat, 14 Mar 2026 13:44:47 +0100 Subject: [PATCH 29/30] chore: remove unused methods and add docs This commit removes unused functions and resolves compiler warnings while adding KDoc documentation to clarify responsibilities of core classes and interfaces. Summary of changes: * Add KDoc documentation to ClientConnection and its run method * Add KDoc documentation to PluginServices interface * Remove unused sendMessage overloads in ClientConnection * Remove unused remove method from ClientStorage * Fix unused constructor property in LinkCodeStorage * Clean up TODO comments related to resolved warnings --- .../org/winlogon/minechat/ClientConnection.kt | 39 +++++++++---------- .../org/winlogon/minechat/PluginServices.kt | 7 ++++ .../minechat/storage/ClientStorage.kt | 15 ------- .../minechat/storage/LinkCodeStorage.kt | 4 +- 4 files changed, 26 insertions(+), 39 deletions(-) diff --git a/src/main/kotlin/org/winlogon/minechat/ClientConnection.kt b/src/main/kotlin/org/winlogon/minechat/ClientConnection.kt index d912caf..55b2b2f 100644 --- a/src/main/kotlin/org/winlogon/minechat/ClientConnection.kt +++ b/src/main/kotlin/org/winlogon/minechat/ClientConnection.kt @@ -20,7 +20,18 @@ import java.util.concurrent.TimeUnit import java.util.concurrent.atomic.AtomicBoolean import java.util.logging.Logger -// TODO: add brief kdoc documentation to the public functions +/** + * Handles a client connection to the MineChat server. + * + * This class manages the connection lifecycle including: + * - Reading and writing packets using CBOR + zstd compression + * - Handling LINK/LINK_OK authentication flow + * - Processing chat messages and moderation actions + * - Sending periodic PING messages for keep-alive + * + * @param socket The client socket connection + * @param plugin The MineChat plugin instance + */ class ClientConnection( private val socket: Socket, private val plugin: MineChatPlugin @@ -62,6 +73,12 @@ class ClientConnection( }, 10, 10, TimeUnit.SECONDS) } + /** + * Main connection loop that processes incoming packets. + * + * This method runs continuously until the client disconnects + * or an error occurs, reading and processing MineChat protocol packets. + */ @OptIn(ExperimentalSerializationApi::class) override fun run() { try { @@ -199,16 +216,6 @@ class ClientConnection( } } - // TODO: "Function "sendMessage" is never used" - private fun sendMessage(packetType: Int, payload: CapabilitiesPayload) { - try { - val mineChatPacket = createPacket(packetType, payload) - sendPacket(mineChatPacket) - } catch (e: Exception) { - logger.warning("Error sending CapabilitiesPayload: ${e.message}") - } - } - private fun sendMessage(packetType: Int, payload: AuthOkPayload) { try { val mineChatPacket = createPacket(packetType, payload) @@ -218,16 +225,6 @@ class ClientConnection( } } - // TODO: "Function "sendMessage" is never used" - private fun sendMessage(packetType: Int, payload: ChatMessagePayload) { - try { - val mineChatPacket = createPacket(packetType, payload) - sendPacket(mineChatPacket) - } catch (e: Exception) { - logger.warning("Error sending ChatMessagePayload: ${e.message}") - } - } - private fun sendMessage(packetType: Int, payload: PingPayload) { try { val mineChatPacket = createPacket(packetType, payload) diff --git a/src/main/kotlin/org/winlogon/minechat/PluginServices.kt b/src/main/kotlin/org/winlogon/minechat/PluginServices.kt index e685c53..ea8b553 100644 --- a/src/main/kotlin/org/winlogon/minechat/PluginServices.kt +++ b/src/main/kotlin/org/winlogon/minechat/PluginServices.kt @@ -8,6 +8,13 @@ import org.winlogon.minechat.storage.ClientStorage import org.winlogon.minechat.storage.LinkCodeStorage import java.util.concurrent.ConcurrentLinkedQueue +/** + * Interface for plugin services and dependencies. + * + * This interface provides a centralized way to access all plugin services, + * including storage, configuration, and connected client management. + * It is implemented by [org.winlogon.minechat.MineChatPlugin]. + */ interface PluginServices { val pluginInstance: JavaPlugin val linkCodeStorage: LinkCodeStorage diff --git a/src/main/kotlin/org/winlogon/minechat/storage/ClientStorage.kt b/src/main/kotlin/org/winlogon/minechat/storage/ClientStorage.kt index 0ce7e4d..9010b2c 100644 --- a/src/main/kotlin/org/winlogon/minechat/storage/ClientStorage.kt +++ b/src/main/kotlin/org/winlogon/minechat/storage/ClientStorage.kt @@ -43,19 +43,4 @@ class ClientStorage(boxStore: BoxStore) { clientCache.put(client.clientUuid, client) } } - - // TODO: "Function "remove" is never used" - fun remove(clientUuid: String?, minecraftUsername: String?) { - if (clientUuid != null) { - clientCache.invalidate(clientUuid) - clientBox.query(Client_.clientUuid.equal(clientUuid)).build().remove() - } - if (minecraftUsername != null) { - val client = find(null, minecraftUsername) - if (client != null) { - clientCache.invalidate(client.clientUuid) - clientBox.remove(client.id) - } - } - } } diff --git a/src/main/kotlin/org/winlogon/minechat/storage/LinkCodeStorage.kt b/src/main/kotlin/org/winlogon/minechat/storage/LinkCodeStorage.kt index 2d5de4a..c2733a8 100644 --- a/src/main/kotlin/org/winlogon/minechat/storage/LinkCodeStorage.kt +++ b/src/main/kotlin/org/winlogon/minechat/storage/LinkCodeStorage.kt @@ -12,9 +12,7 @@ import java.io.Closeable import java.util.concurrent.Executors import java.util.concurrent.TimeUnit -// TODO: "Constructor parameter is never used as a property" -// TODO: use Closeable properly -class LinkCodeStorage(private val boxStore: BoxStore) : Closeable { +class LinkCodeStorage(boxStore: BoxStore) : Closeable { private val linkCodeBox: Box = boxStore.boxFor(LinkCode::class.java) private val scheduler = Executors.newSingleThreadScheduledExecutor() private val linkCodeCache: Cache = Caffeine.newBuilder() From 8cfec23b579d7e97770927da2ef3d68f4e899f1e Mon Sep 17 00:00:00 2001 From: winlogon Date: Sat, 14 Mar 2026 13:47:40 +0100 Subject: [PATCH 30/30] chore: add descriptions for each command --- .../kotlin/org/winlogon/minechat/CommandRegister.kt | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/src/main/kotlin/org/winlogon/minechat/CommandRegister.kt b/src/main/kotlin/org/winlogon/minechat/CommandRegister.kt index 8dfb276..d3821a0 100644 --- a/src/main/kotlin/org/winlogon/minechat/CommandRegister.kt +++ b/src/main/kotlin/org/winlogon/minechat/CommandRegister.kt @@ -119,11 +119,12 @@ class CommandRegister(private val services: PluginServices) : Listener { services.pluginInstance.lifecycleManager.registerEventHandler(LifecycleEvents.COMMANDS) { event -> val registrar = event.registrar() - registrar.register(linkCommand) - registrar.register(reloadCommand) - registrar.register(banCommand) - registrar.register(unbanCommand) - registrar.register(kickCommand) + + registrar.register(linkCommand, "Generate a MineChat link code for your account.") + registrar.register(reloadCommand, "Reload the MineChat configuration and services.") + registrar.register(banCommand, "Ban a player from using MineChat.") + registrar.register(unbanCommand, "Remove a MineChat ban from a player.") + registrar.register(kickCommand, "Kick a player from the MineChat client.") } }