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 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 diff --git a/README.md b/README.md index 844fb06..eac822a 100644 --- a/README.md +++ b/README.md @@ -23,7 +23,37 @@ 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 using modern algorithms (EC secp384r1): + ```bash + keytool -genkeypair \ + -alias minechat \ + -keyalg EC \ + -keysize 384 \ + -storetype PKCS12 \ + -keystore keystore.p12 \ + -validity 3650 \ + -storepass \ + -keypass \ + -dname "CN=localhost, OU=Dev, O=MineChat, L=City, ST=State, C=US" + ``` + +4. **Configure TLS**: + - Copy the generated `keystore.p12` to your server's plugin data folder: + ``` + /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.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. ## Usage @@ -42,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 03f8160..e0634e8 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -1,11 +1,21 @@ -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" + 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") + group = "org.winlogon.minechat" fun getLatestGitTag(): String? { @@ -20,7 +30,7 @@ fun getLatestGitTag(): String? { } else { null } - } catch (e: Exception) { + } catch (_: Exception) { null } } @@ -52,7 +62,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,22 +74,32 @@ repositories { } maven { - url = uri("https://libraries.minecraft.net") + url = uri("https://maven.winlogon.org/releases") } mavenCentral() } dependencies { - compileOnly("io.papermc.paper:paper-api:1.21.4-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("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 { @@ -104,7 +123,6 @@ tasks.shadowJar { minimize() } -// Disable jar and replace with shadowJar tasks.jar { enabled = false } @@ -113,7 +131,6 @@ tasks.assemble { dependsOn(tasks.shadowJar) } -// Utility tasks tasks.register("printProjectName") { doLast { println(projectName) @@ -131,3 +148,9 @@ tasks.register("release") { } } } + +tasks.withType().configureEach { + compilerOptions { + freeCompilerArgs.add("-Xannotation-default-target=param-property") + } +} 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 4ac3234..410596d 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,2 +1,29 @@ -# 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.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/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar index a4b76b9..f8e1ee3 100644 Binary files a/gradle/wrapper/gradle-wrapper.jar and b/gradle/wrapper/gradle-wrapper.jar differ 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 diff --git a/objectbox-models/default.json b/objectbox-models/default.json new file mode 100644 index 0000000..f1640ff --- /dev/null +++ b/objectbox-models/default.json @@ -0,0 +1,125 @@ +{ + "_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:8261292230741874475", + "lastPropertyId": "6:1439611636147655021", + "name": "Ban", + "properties": [ + { + "id": "1:8841001532244465999", + "name": "id", + "type": 6, + "flags": 1 + }, + { + "id": "2:2392367576445077149", + "name": "clientUuid", + "type": 9 + }, + { + "id": "3:5241039350824200514", + "name": "minecraftUuid", + "type": 9 + }, + { + "id": "4:4359432178243352065", + "name": "minecraftUsername", + "type": 9 + }, + { + "id": "5:1878550432723739035", + "name": "reason", + "type": 9 + }, + { + "id": "6:1439611636147655021", + "name": "timestamp", + "type": 6 + } + ], + "relations": [] + }, + { + "id": "2:2163146026126670709", + "lastPropertyId": "5:6924894891449230143", + "name": "Client", + "properties": [ + { + "id": "1:3478056570875465288", + "name": "id", + "type": 6, + "flags": 1 + }, + { + "id": "2:660439629542299095", + "name": "clientUuid", + "type": 9 + }, + { + "id": "3:8235748400939030633", + "name": "minecraftUuid", + "type": 9 + }, + { + "id": "4:5499463405898406734", + "name": "minecraftUsername", + "type": 9 + }, + { + "id": "5:6924894891449230143", + "name": "supportsComponents", + "type": 1 + } + ], + "relations": [] + }, + { + "id": "3:2559959171573362814", + "lastPropertyId": "5:7837552611035865525", + "name": "LinkCode", + "properties": [ + { + "id": "1:4986906910597541623", + "name": "id", + "type": 6, + "flags": 1 + }, + { + "id": "2:9042144905393385362", + "name": "code", + "type": 9 + }, + { + "id": "3:4072015821015161242", + "name": "minecraftUuid", + "type": 9 + }, + { + "id": "4:6023228194007721096", + "name": "minecraftUsername", + "type": 9 + }, + { + "id": "5:7837552611035865525", + "name": "expiresAt", + "type": 6 + } + ], + "relations": [] + } + ], + "lastEntityId": "3:2559959171573362814", + "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/settings.gradle.kts b/settings.gradle.kts index 7b1e251..c258f0e 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -1,9 +1,9 @@ -/* - * 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() + mavenCentral() + maven { url = uri("https://download.objectbox.io/maven") } + } +} rootProject.name = "MineChat" 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..55b2b2f --- /dev/null +++ b/src/main/kotlin/org/winlogon/minechat/ClientConnection.kt @@ -0,0 +1,464 @@ +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.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.io.EOFException +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 +import java.util.logging.Logger + +/** + * 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 +) : Runnable { + companion object { + const val MINECHAT_PREFIX_STRING = "&8[&3MineChat&8]" + val MINECHAT_PREFIX_COMPONENT: Component = LegacyComponentSerializer.legacyAmpersand().deserialize(MINECHAT_PREFIX_STRING) + } + + @OptIn(ExperimentalSerializationApi::class) + val cbor = createCbor() + + val reader = DataInputStream(socket.inputStream) + val writer = DataOutputStream(socket.outputStream) + + private var client: Client? = null + private var running = true + + + /** 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 + private var currentRtt: Long = 0 + + private val disconnected = AtomicBoolean(false) + private val scheduledExecutor: ScheduledExecutorService = Executors.newSingleThreadScheduledExecutor() + + init { + // Schedule periodic PING messages every 10 seconds + scheduledExecutor.scheduleAtFixedRate({ + if (running && !disconnected.get()) { + sendPing() + } + }, 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 { + logger.info("ClientConnection.run() started for ${socket.remoteSocketAddress}") + + // Main packet processing loop + while (running && !disconnected.get()) { + // Check keep-alive timeout + val currentTime = System.currentTimeMillis() + if (currentTime - lastPacketTime > keepAliveTimeout) { + logger.warning("Client connection timed out after ${keepAliveTimeout}ms of inactivity") + break // Terminate connection + } + + // 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") + + if (decompressed.size != decompressedLen) { + logger.warning("Decompressed size mismatch. Expected $decompressedLen, got ${decompressed.size}. Terminating connection.") + break + } + + // 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") + + val mineChatPacket = try { + cbor.decodeFromByteArray(MineChatPacket, decompressed) + } catch (e: Exception) { + logger.severe("CBOR deserialization failed: ${e.message}") + e.printStackTrace() + break + } + + 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}") + } + } 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.severe("Client error in run loop: ${e.message}") + e.printStackTrace() + } + } finally { + client?.let { + broadcastMinecraft(ChatGradients.LEAVE, "${it.minecraftUsername} has left the chat.") + } + close() + plugin.removeClient(this) + } + } + + private fun sendPing() { + val timestamp = System.currentTimeMillis() + sendMessage(PacketTypes.PING, PingPayload(timestamp_ms = timestamp)) + } + + 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: 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: 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) { + 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 + } + } + + 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.info("Handling auth with payload: $payload") + + val banStorage = plugin.banStorage + val clientUuid = payload.client_uuid + val linkCode = payload.linking_code + + // Check ban by client UUID first + banStorage.getBan(clientUuid, null)?.let { + sendBannedMessage(it) + 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) { + logger.warning("Invalid link code: $linkCode") + disconnect("Invalid link code") + return + } + + // 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 + } + + // 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(minecraft_uuid = link.minecraftUuid.toString())) + linkAuthCompleted = true + + // Now wait for CAPABILITIES before completing auth + logger.info("Sent LINK_OK, waiting for CAPABILITIES...") + } + + private fun handleCapabilities(payload: CapabilitiesPayload) { + if (!linkAuthCompleted) { + logger.warning("Received CAPABILITIES without prior LINK_OK. Ignoring.") + return + } + + if (capabilitiesReceived) { + logger.warning("Received duplicate CAPABILITIES packet. Ignoring.") + return + } + + 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.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.") + } + } + + 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 { + plugin.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) { + sendMessage(PacketTypes.PONG, PongPayload(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.info("Received PONG from client with RTT: ${currentRtt}ms") + } + + private fun broadcastMinecraft(gradient: Pair, message: String) { + val gradientColor = "$message" + val component = plugin.miniMessage.deserialize(gradientColor) + broadcastMinecraft(formatPrefixed(component)) + } + + private fun broadcastMinecraft(component: Component) { + Bukkit.getServer().sendMessage(component) + } + + 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/CommandRegister.kt b/src/main/kotlin/org/winlogon/minechat/CommandRegister.kt new file mode 100644 index 0000000..d3821a0 --- /dev/null +++ b/src/main/kotlin/org/winlogon/minechat/CommandRegister.kt @@ -0,0 +1,154 @@ +@file:Suppress("UnstableApiUsage") + +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 +import org.winlogon.minechat.entities.Ban +import org.winlogon.minechat.entities.LinkCode + +class CommandRegister(private val services: PluginServices) : 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("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", + content = "$infoMsg" + ) + services.broadcastToClients(PacketTypes.CHAT_MESSAGE, chatPayload) + 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 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.ACCOUNT, + 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 + } + ) + .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.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, + scope = ModerationScope.CLIENT, + reason = reason + ) + services.broadcastToClients(PacketTypes.MODERATION, modPayload) + + clientConnection.disconnect(reason) + ctx.source.sender.sendRichMessage( + "Kicked from MineChat.", + Placeholder.unparsed("player", playerName) + ) + Command.SINGLE_SUCCESS + } + ) + .build() + + services.pluginInstance.lifecycleManager.registerEventHandler(LifecycleEvents.COMMANDS) { event -> + val registrar = event.registrar() + + 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.") + } + } + + 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/MarkdownSerializer.kt b/src/main/kotlin/org/winlogon/minechat/MarkdownSerializer.kt new file mode 100644 index 0000000..e4502ff --- /dev/null +++ b/src/main/kotlin/org/winlogon/minechat/MarkdownSerializer.kt @@ -0,0 +1,143 @@ +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 = parseMarkdown(input) + + override fun serialize(component: Component): String { + val sb = StringBuilder() + serializeComponent(component, sb) + return sb.toString() + } + + private fun parseMarkdown(input: String): Component { + if (input.isEmpty()) return Component.empty() + + val builder = Component.text() // builder to accumulate components + val buffer = StringBuilder() + var i = 0 + val n = input.length + + 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 { + 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 { + // treat literally + buffer.append("**") + i += 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 { + buffer.append("~~") + i += 2 + } + } + + // 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 { + buffer.append('*') + i++ + } + } + + 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 { + buffer.append('`') + i++ + } + } + + else -> { + buffer.append(input[i]) + i++ + } + } + } + + 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 bold = component.hasDecoration(TextDecoration.BOLD) + val italic = component.hasDecoration(TextDecoration.ITALIC) + val strike = component.hasDecoration(TextDecoration.STRIKETHROUGH) + + // openers (fixed order) + if (bold) sb.append("**") + if (italic) sb.append("*") + if (strike) sb.append("~~") + + sb.append(component.content()) + + // 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) + } + } + + companion object { + private val INSTANCE = MarkdownSerializer() + fun markdown(): MarkdownSerializer = INSTANCE + } +} 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..87d0de0 --- /dev/null +++ b/src/main/kotlin/org/winlogon/minechat/MineChatConfig.kt @@ -0,0 +1,20 @@ +package org.winlogon.minechat + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +data class TlsConfig( + val enabled: Boolean = true, + val keystore: String = "keystore.p12", + @SerialName("keystore-password") + val keystorePassword: String = "password" +) + +@Serializable +data class MineChatConfig( + val port: Int = 25575, + @SerialName("expiry-code-minutes") + 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 542bbc4..44708e4 100644 --- a/src/main/kotlin/org/winlogon/minechat/MineChatLoader.kt +++ b/src/main/kotlin/org/winlogon/minechat/MineChatLoader.kt @@ -1,24 +1,43 @@ +@file:Suppress("UnstableApiUsage") // Loader API is "experimental" + 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 -import java.nio.file.Path - 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", "https://repo1.maven.org/maven2/") - .build() - ) + val caffeineVersion = "3.2.0" + 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/MineChatPlugin.kt b/src/main/kotlin/org/winlogon/minechat/MineChatPlugin.kt new file mode 100644 index 0000000..d0866fd --- /dev/null +++ b/src/main/kotlin/org/winlogon/minechat/MineChatPlugin.kt @@ -0,0 +1,216 @@ +package org.winlogon.minechat + +import com.charleskorn.kaml.Yaml + +import io.objectbox.BoxStore +import io.papermc.paper.event.player.AsyncChatEvent + +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.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 +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 javax.net.ssl.SSLServerSocket +import kotlinx.serialization.decodeFromString + +class MineChatPlugin : JavaPlugin(), PluginServices { + private var serverSocket: ServerSocket? = null + private var isFolia: Boolean = false + private lateinit var boxStore: BoxStore + @Volatile private var isServerRunning: Boolean = false + private var serverThread: Thread? = null + private val executorService = Executors.newVirtualThreadPerTaskExecutor() + + override val connectedClients = ConcurrentLinkedQueue() + lateinit var loggerProvider: PluginLoggerProvider + 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) { + loggerProvider.logger.severe("Failed to load config.yml: ${e.message}. Using default config.") + MineChatConfig() + } + } + + override fun reloadConfigAndDependencies() { + saveResource("config.yml", false) + reloadConfig() + mineChatConfig = loadConfig() + } + + override fun generateRandomLinkCode(): String { + val chars = ('A'..'Z') + ('0'..'9') + return (1..6).map { chars.random() }.joinToString("") + } + + override fun onLoad() { + isFolia = try { + Class.forName("io.papermc.paper.threadedregions.RegionizedServer") + true + } catch (_: ClassNotFoundException) { + false + } + + 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) + banStorage = BanStorage(boxStore) + + CommandRegister(this).registerCommands() + + if (!mineChatConfig.tls.enabled) { + loggerProvider.logger.severe("MineChat server cannot start: TLS is disabled in config.yml, but it is mandatory.") + return + } + + val keystoreFile = File(dataFolder, mineChatConfig.tls.keystore) + val keystorePassword = mineChatConfig.tls.keystorePassword.toCharArray() + + if (!keystoreFile.exists()) { + loggerProvider.logger.severe("MineChat server cannot start: TLS is mandatory, but no keystore file was found at ${keystoreFile.absolutePath}.") + return + } + + try { + val keyStore = KeyStore.getInstance("PKCS12") + keyStore.load(keystoreFile.inputStream(), keystorePassword) + + 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(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() + return + } + + isServerRunning = true + + serverThread = Thread { + while (isServerRunning) { + try { + val socket = serverSocket?.accept() + if (socket != null) { + loggerProvider.logger.info("Client connected: ${socket.inetAddress}") + val connection = ClientConnection(socket, this) + connectedClients.add(connection) + executorService.submit(connection) + } + } catch (e: Exception) { + if (!isServerRunning) break + loggerProvider.logger.warning("Error accepting client: ${e.message}") + } + } + loggerProvider.logger.info("MineChat server socket thread stopped.") + } + serverThread?.start() + + // Register chat listener + server.pluginManager.registerEvents(object : Listener { + @EventHandler + fun onChat(event: AsyncChatEvent) { + this@MineChatPlugin.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 MineChatPlugin") + isServerRunning = false + serverThread?.interrupt() + serverSocket?.close() + connectedClients.forEach { it.disconnect("Server is shutting down.") } + executorService.shutdownNow() + try { + executorService.awaitTermination(10, TimeUnit.SECONDS) + } catch (_: InterruptedException) { + Thread.currentThread().interrupt() + } + boxStore.close() + try { + serverThread?.join() + } catch (_: InterruptedException) { + Thread.currentThread().interrupt() + } + } + + private val cbor = createCbor() + + override fun broadcastToClients(packetType: Int, payload: PacketPayload) { + connectedClients.forEach { client -> + try { + val mineChatPacket = MineChatPacket(packetType, payload) + 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) + client.writer.flush() + } catch (e: Exception) { + loggerProvider.logger.warning("Error sending message to client: ${e.message}") + connectedClients.remove(client) + } + } + } + + fun removeClient(client: ClientConnection) = connectedClients.remove(client) +} diff --git a/src/main/kotlin/org/winlogon/minechat/MineChatServerPlugin.kt b/src/main/kotlin/org/winlogon/minechat/MineChatServerPlugin.kt deleted file mode 100644 index bb563ce..0000000 --- a/src/main/kotlin/org/winlogon/minechat/MineChatServerPlugin.kt +++ /dev/null @@ -1,528 +0,0 @@ -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 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.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 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 -) - -class MineChatServerPlugin : JavaPlugin() { - private var serverSocket: ServerSocket? = null - private val connectedClients = CopyOnWriteArrayList() - private lateinit var linkCodeStorage: LinkCodeStorage - private lateinit var clientStorage: ClientStorage - private var isFolia = false - - private var port: Int = 25575 - private var expiryCodeMs = 300_000 // 5 minutes - private var serverThread: Thread? = null - @Volatile private var isServerRunning = false - private val executorService = Executors.newCachedThreadPool() - val gson = Gson() - val miniMessage = MiniMessage.miniMessage() - - private fun generateLinkCode(): 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() + expiryCodeMs - ) - linkCodeStorage.add(link) - - 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 .", - Placeholder.component("code", codeComponent), - Placeholder.component("expiry_time", timeComponent) - ) - } - - fun registerCommands() { - val linkCommand = Commands.literal("link") - .requires { sender -> sender.getExecutor() is Player } - .executes { ctx -> - val sender = ctx.source.sender - generateAndSendLinkCode(sender as Player) - Command.SINGLE_SUCCESS - } - .build() - - val reloadCommand = Commands.literal("mchatreload") - .requires { sender -> sender.getSender().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.") - Command.SINGLE_SUCCESS - } - .build() - - this.getLifecycleManager().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") - } - } - - override fun onEnable() { - isFolia = try { - Class.forName("io.papermc.paper.threadedregions.RegionizedServer") - true - } catch (e: ClassNotFoundException) { - false - } - - saveResource("config.yml", false) - reloadConfig() - - port = config.getInt("port", 25575) - expiryCodeMs = config.getInt("expiry-code-minutes", 5) * 60_000 - - dataFolder.mkdirs() - - linkCodeStorage = LinkCodeStorage(dataFolder, gson) - clientStorage = ClientStorage(dataFolder, gson) - linkCodeStorage.load() - clientStorage.load() - - registerCommands() - - serverSocket = ServerSocket(port) - logger.info("Starting MineChat server on port $port") - - val saveTask = Runnable { - linkCodeStorage.cleanupExpired() - linkCodeStorage.save() - clientStorage.save() - } - - if (isFolia) { - val scheduler = server.getAsyncScheduler() - scheduler.runAtFixedRate(this, { _ -> saveTask.run() }, 1, 1, TimeUnit.MINUTES) - } else { - server.scheduler.runTaskTimer(this, saveTask, 0, 20 * 60) - } - - isServerRunning = true - - serverThread = Thread { - while (isServerRunning) { - try { - val socket = serverSocket?.accept() - if (socket != null) { - logger.info("Client connected: ${socket.inetAddress}") - val connection = ClientConnection(socket, this, gson, miniMessage) - connectedClients.add(connection) - executorService.submit(connection) - } - } catch (e: Exception) { - if (!isServerRunning) break - logger.warning("Error accepting client: ${e.message}") - } - } - 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()) - val message = mapOf( - "type" to "BROADCAST", - "payload" to mapOf( - "from" to event.player.name, - "message" to plainMsg - ) - ) - broadcastToClients(gson.toJson(message)) - } - }, this) - } - - override fun onDisable() { - isServerRunning = false - serverThread?.interrupt() - serverSocket?.close() - connectedClients.forEach { it.close() } - executorService.shutdownNow() - try { - executorService.awaitTermination(10, TimeUnit.SECONDS) - } catch (e: InterruptedException) { - Thread.currentThread().interrupt() - } - linkCodeStorage.save() - clientStorage.save() - try { - serverThread?.join() - } catch (e: InterruptedException) { - Thread.currentThread().interrupt() - } - } - - fun broadcastToClients(message: String) { - connectedClients.forEach { client -> - try { - client.sendMessage(message) - } catch (e: Exception) { - logger.warning("Error sending message to client: ${e.message}") - connectedClients.remove(client) - } - } - } - - fun getLinkCodeStorage(): LinkCodeStorage = linkCodeStorage - fun getClientStorage(): ClientStorage = clientStorage - 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 -) - -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) - } -} 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/PluginServices.kt b/src/main/kotlin/org/winlogon/minechat/PluginServices.kt new file mode 100644 index 0000000..ea8b553 --- /dev/null +++ b/src/main/kotlin/org/winlogon/minechat/PluginServices.kt @@ -0,0 +1,31 @@ +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 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 + val clientStorage: ClientStorage + val banStorage: BanStorage + val mineChatConfig: MineChatConfig + val permissions: Map + val miniMessage: MiniMessage + val connectedClients: ConcurrentLinkedQueue + + fun reloadConfigAndDependencies() + fun generateRandomLinkCode(): String + fun broadcastToClients(packetType: Int, payload: PacketPayload) +} 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..77f0ba2 --- /dev/null +++ b/src/main/kotlin/org/winlogon/minechat/Protocol.kt @@ -0,0 +1,169 @@ +@file:OptIn(ExperimentalSerializationApi::class) + +package org.winlogon.minechat + +import kotlinx.serialization.ExperimentalSerializationApi +import kotlinx.serialization.KSerializer +import kotlinx.serialization.Serializable +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 + +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 + const val DISCONNECT = 0x80 +} + +object ModerationAction { + const val WARN = 0 + const val MUTE = 1 + const val KICK = 2 + const val BAN = 3 +} + +object ModerationScope { + const val CLIENT = 0 + const val ACCOUNT = 1 +} + +object ChatGradients { + val JOIN = Pair("#27AE60", "#2ECC71") + val LEAVE = Pair("#C0392B", "#E74C3C") + val AUTH = Pair("#8E44AD", "#9B59B6") + val INFO = Pair("#2980B9", "#3498DB") +} + +@Serializable +sealed class PacketPayload + +@Serializable +data class LinkPayload( + val linking_code: String, + val client_uuid: String +) : PacketPayload() + +@Serializable +data class LinkOkPayload( + val minecraft_uuid: String +) : PacketPayload() + +@Serializable +data class CapabilitiesPayload( + val supports_components: Boolean +) : PacketPayload() + +@Serializable +class AuthOkPayload : PacketPayload() + +@Serializable +data class ChatMessagePayload( + val format: String, + val content: String +) : PacketPayload() + +@Serializable +data class PingPayload( + val timestamp_ms: Long +) : PacketPayload() + +@Serializable +data class PongPayload( + val timestamp_ms: Long +) : PacketPayload() + +@Serializable +data class ModerationPayload( + val action: Int, + val scope: Int, + val reason: String? = null, + val duration_seconds: Int? = null +) : PacketPayload() + +@Serializable +data class DisconnectPayload( + val reason: String +) : PacketPayload() + +@Serializable +class EmptyPayload : PacketPayload() + +data class MineChatPacket( + @CborLabel(0) val packetType: Int, + @CborLabel(1) val payload: PacketPayload +) { + companion object : KSerializer { + private val packetDescriptor = buildClassSerialDescriptor("MineChatPacket") { + element("packetType", annotations = listOf(CborLabel(0))) + element("payload", annotations = listOf(CborLabel(1))) + } + + 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 { + 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 + } + } + } + return MineChatPacket(packetType, payload) + } + + private fun getPayloadSerializer(type: Int): KSerializer = when (type) { + 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 +} diff --git a/src/main/kotlin/org/winlogon/minechat/entities/Ban.kt b/src/main/kotlin/org/winlogon/minechat/entities/Ban.kt new file mode 100644 index 0000000..da3dfab --- /dev/null +++ b/src/main/kotlin/org/winlogon/minechat/entities/Ban.kt @@ -0,0 +1,20 @@ +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 +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/entities/Client.kt b/src/main/kotlin/org/winlogon/minechat/entities/Client.kt new file mode 100644 index 0000000..a70bc6d --- /dev/null +++ b/src/main/kotlin/org/winlogon/minechat/entities/Client.kt @@ -0,0 +1,19 @@ +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 +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 = "", + var supportsComponents: Boolean = false +) diff --git a/src/main/kotlin/org/winlogon/minechat/entities/LinkCode.kt b/src/main/kotlin/org/winlogon/minechat/entities/LinkCode.kt new file mode 100644 index 0000000..5441ce1 --- /dev/null +++ b/src/main/kotlin/org/winlogon/minechat/entities/LinkCode.kt @@ -0,0 +1,19 @@ +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 +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/storage/BanStorage.kt b/src/main/kotlin/org/winlogon/minechat/storage/BanStorage.kt new file mode 100644 index 0000000..361a198 --- /dev/null +++ b/src/main/kotlin/org/winlogon/minechat/storage/BanStorage.kt @@ -0,0 +1,39 @@ +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) + + 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 + } +} diff --git a/src/main/kotlin/org/winlogon/minechat/storage/ClientStorage.kt b/src/main/kotlin/org/winlogon/minechat/storage/ClientStorage.kt new file mode 100644 index 0000000..9010b2c --- /dev/null +++ b/src/main/kotlin/org/winlogon/minechat/storage/ClientStorage.kt @@ -0,0 +1,46 @@ +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) + private val clientCache: Cache = Caffeine.newBuilder().build() + + fun find(clientUuid: String?, minecraftUsername: String?): Client? { + if (clientUuid != null) { + 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 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 + } + + 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) + clientCache.put(existing.clientUuid, existing) + } else { + clientBox.put(client) + clientCache.put(client.clientUuid, client) + } + } +} diff --git a/src/main/kotlin/org/winlogon/minechat/storage/LinkCodeStorage.kt b/src/main/kotlin/org/winlogon/minechat/storage/LinkCodeStorage.kt new file mode 100644 index 0000000..c2733a8 --- /dev/null +++ b/src/main/kotlin/org/winlogon/minechat/storage/LinkCodeStorage.kt @@ -0,0 +1,56 @@ +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.io.Closeable + +import java.util.concurrent.Executors +import java.util.concurrent.TimeUnit + +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() + .expireAfterWrite(5, TimeUnit.MINUTES) + .build() + + init { + // Schedule cleanup of expired link codes every minute + scheduler.scheduleAtFixedRate({ + cleanupExpired() + }, 0, 1, TimeUnit.MINUTES) + } + + fun add(linkCode: LinkCode) { + linkCodeBox.put(linkCode) + linkCodeCache.put(linkCode.code, linkCode) + } + + fun find(code: String): LinkCode? { + 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 } + } + + override fun close() { + scheduler.shutdown() + } +} diff --git a/src/main/kotlin/org/winlogon/minechat/storage/UuidConverter.kt b/src/main/kotlin/org/winlogon/minechat/storage/UuidConverter.kt new file mode 100644 index 0000000..795b42e --- /dev/null +++ b/src/main/kotlin/org/winlogon/minechat/storage/UuidConverter.kt @@ -0,0 +1,15 @@ +package org.winlogon.minechat.storage + +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() + } +} diff --git a/src/main/resources/config.yml b/src/main/resources/config.yml index d8587c1..ff981a4 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: true + # The name of the keystore file in the plugin's data folder. + 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