diff --git a/.gitignore b/.gitignore index 48df14629d..9d6f0b9355 100644 --- a/.gitignore +++ b/.gitignore @@ -166,6 +166,9 @@ music/ SaveFiles/ scenarios/ +# server artifacts +server.auth +MultiplayerFiles/ /.kotlin/errors/* /.kotlin/sessions/* diff --git a/core/src/com/unciv/logic/multiplayer/chat/ChatStore.kt b/core/src/com/unciv/logic/multiplayer/chat/ChatStore.kt index bd143c2eec..910df82725 100644 --- a/core/src/com/unciv/logic/multiplayer/chat/ChatStore.kt +++ b/core/src/com/unciv/logic/multiplayer/chat/ChatStore.kt @@ -2,11 +2,13 @@ package com.unciv.logic.multiplayer.chat import com.badlogic.gdx.Gdx import com.unciv.ui.screens.worldscreen.chat.ChatPopup +import java.util.Collections.synchronizedMap import java.util.LinkedList import java.util.Queue +import java.util.UUID data class Chat( - val gameId: String, + val gameId: UUID, ) { // pairs private val messages: MutableList> = mutableListOf(INITIAL_MESSAGE) @@ -22,7 +24,7 @@ data class Chat( */ fun requestMessageSend(civName: String, message: String) { Gdx.app.postRunnable { - ChatWebSocket.requestMessageSend(Message.Chat(gameId, civName, message)) + ChatWebSocket.requestMessageSend(Message.Chat(civName, message, gameId.toString())) } } @@ -50,16 +52,17 @@ object ChatStore { */ var chatPopup: ChatPopup? = null - private var gameIdToChat = mutableMapOf() + private var gameIdToChat: MutableMap = synchronizedMap(mutableMapOf()) /** When no [ChatPopup] is open to receive these oddities, we keep them here. * Certainly better than not knowing why the socket closed. */ private var globalMessages: Queue> = LinkedList() - fun getChatByGameId(gameId: String) = gameIdToChat.getOrPut(gameId) { Chat(gameId) } + fun getChatByGameId(gameId: UUID): Chat = gameIdToChat.getOrPut(gameId) { Chat(gameId) } + fun getChatByGameId(gameId: String): Chat = getChatByGameId(UUID.fromString(gameId)) - fun getGameIds() = gameIdToChat.keys.toSet() + fun getGameIds() = gameIdToChat.keys.map { uuid -> uuid.toString() } /** * Clears chat by triggering a garbage collection. @@ -71,12 +74,21 @@ object ChatStore { fun relayChatMessage(chat: Response.Chat) { Gdx.app.postRunnable { - if (chat.gameId.isNotEmpty()) { - getChatByGameId(chat.gameId).addMessage(chat.civName, chat.message) - if (chatPopup?.chat?.gameId == chat.gameId) { + if (chat.gameId == null || chat.gameId.isBlank()) { + relayGlobalMessage(chat.message, chat.civName) + } else { + val gameId = try { + UUID.fromString(chat.gameId) + } catch (_: Throwable) { + // Discard messages with invalid UUID + return@postRunnable + } + + getChatByGameId(gameId).addMessage(chat.civName, chat.message) + if (chatPopup?.chat?.gameId == gameId) { chatPopup?.addMessage(chat.civName, chat.message) } - } else relayGlobalMessage(chat.message, chat.civName) + } } } diff --git a/core/src/com/unciv/logic/multiplayer/chat/ChatWebSocket.kt b/core/src/com/unciv/logic/multiplayer/chat/ChatWebSocket.kt index 64c667fc0f..3ec2f2ac79 100644 --- a/core/src/com/unciv/logic/multiplayer/chat/ChatWebSocket.kt +++ b/core/src/com/unciv/logic/multiplayer/chat/ChatWebSocket.kt @@ -35,7 +35,7 @@ sealed class Message { @Serializable @SerialName("chat") data class Chat( - val gameId: String, val civName: String, val message: String + val civName: String, val message: String, val gameId: String ) : Message() @Serializable @@ -57,7 +57,7 @@ sealed class Response { @Serializable @SerialName("chat") data class Chat( - val gameId: String, val civName: String, val message: String + val civName: String, val message: String, val gameId: String? = null ) : Response() @Serializable diff --git a/core/src/com/unciv/ui/popups/options/AdvancedTab.kt b/core/src/com/unciv/ui/popups/options/AdvancedTab.kt index 9a486ab1e5..b16fca6a27 100644 --- a/core/src/com/unciv/ui/popups/options/AdvancedTab.kt +++ b/core/src/com/unciv/ui/popups/options/AdvancedTab.kt @@ -22,7 +22,11 @@ import com.unciv.models.metadata.ModCategories import com.unciv.models.translations.TranslationFileWriter import com.unciv.models.translations.tr import com.unciv.ui.components.UncivTooltip.Companion.addTooltip -import com.unciv.ui.components.extensions.* +import com.unciv.ui.components.extensions.addSeparator +import com.unciv.ui.components.extensions.disable +import com.unciv.ui.components.extensions.setFontColor +import com.unciv.ui.components.extensions.toLabel +import com.unciv.ui.components.extensions.toTextButton import com.unciv.ui.components.fonts.FontFamilyData import com.unciv.ui.components.fonts.Fonts import com.unciv.ui.components.input.keyShortcuts @@ -34,12 +38,12 @@ import com.unciv.ui.popups.ConfirmPopup import com.unciv.ui.screens.basescreen.BaseScreen import com.unciv.utils.Concurrency import com.unciv.utils.Display +import com.unciv.utils.isUUID import com.unciv.utils.launchOnGLThread import com.unciv.utils.withoutItem import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext -import java.util.* import java.util.zip.Deflater class AdvancedTab( @@ -345,25 +349,23 @@ class AdvancedTab( private fun addSetUserId() { val idSetLabel = "".toLabel() - val takeUserIdFromClipboardButton = "Take user ID from clipboard".toTextButton() - .onClick { - try { - val clipboardContents = Gdx.app.clipboard.contents.trim() - UUID.fromString(clipboardContents) - ConfirmPopup( - stage, - "Doing this will reset your current user ID to the clipboard contents - are you sure?", - "Take user ID from clipboard" - ) { - settings.multiplayer.setUserId(clipboardContents) - idSetLabel.setFontColor(Color.WHITE).setText("ID successfully set!".tr()) - }.open(true) - idSetLabel.isVisible = true - } catch (_: Exception) { - idSetLabel.isVisible = true - idSetLabel.setFontColor(Color.RED).setText("Invalid ID!".tr()) - } + val takeUserIdFromClipboardButton = "Take user ID from clipboard".toTextButton().onClick { + val clipboardContents = Gdx.app.clipboard.contents.trim() + if (clipboardContents.isUUID()) { + ConfirmPopup( + stage, + "Doing this will reset your current user ID to the clipboard contents - are you sure?", + "Take user ID from clipboard" + ) { + settings.multiplayer.setUserId(clipboardContents) + idSetLabel.setFontColor(Color.WHITE).setText("ID successfully set!".tr()) + }.open(true) + idSetLabel.isVisible = true + } else { + idSetLabel.isVisible = true + idSetLabel.setFontColor(Color.RED).setText("Invalid ID!".tr()) } + } add(takeUserIdFromClipboardButton).pad(5f).colspan(2).row() add(idSetLabel).colspan(2).row() } diff --git a/core/src/com/unciv/utils/StringExtensions.kt b/core/src/com/unciv/utils/StringExtensions.kt new file mode 100644 index 0000000000..9bb2031696 --- /dev/null +++ b/core/src/com/unciv/utils/StringExtensions.kt @@ -0,0 +1,13 @@ +package com.unciv.utils + +import java.util.UUID + +/** + * Checks if a [String] is a valid UUID + */ +fun String.isUUID(): Boolean = try { + UUID.fromString(this) + true +} catch (_: Throwable) { + false +} diff --git a/server/src/com/unciv/app/server/UncivServer.kt b/server/src/com/unciv/app/server/UncivServer.kt index a4e3488b10..cd96955116 100644 --- a/server/src/com/unciv/app/server/UncivServer.kt +++ b/server/src/com/unciv/app/server/UncivServer.kt @@ -49,7 +49,7 @@ sealed class Message { @Serializable @SerialName("chat") data class Chat( - val gameId: String, val civName: String, val message: String + val civName: String, val message: String, val gameId: String ) : Message() @Serializable @@ -71,7 +71,7 @@ sealed class Response { @Serializable @SerialName("chat") data class Chat( - val gameId: String, val civName: String, val message: String + val civName: String, val message: String, val gameId: String? = null ) : Response() @Serializable @@ -87,37 +87,34 @@ sealed class Response { ) : Response() } +@OptIn(ExperimentalUuidApi::class) private class WebSocketSessionManager { - private val userId2GameIds = synchronizedMap(mutableMapOf>()) + private val gameId2WSSessions = synchronizedMap(mutableMapOf>()) + private val wsSession2GameIds = synchronizedMap(mutableMapOf>()) - private val gameId2WSSessions = - synchronizedMap(mutableMapOf>()) + fun isSubscribed(session: DefaultWebSocketServerSession, gameId: Uuid): Boolean = + gameId2WSSessions.getOrPut(gameId) { synchronizedSet(mutableSetOf()) }.contains(session) - fun removeSession(userId: String, session: DefaultWebSocketServerSession) { - val gameIds = userId2GameIds.remove(userId) - for (gameId in gameIds ?: emptyList()) { - gameId2WSSessions[gameId]?.remove(session) + fun subscribe(session: DefaultWebSocketServerSession, gameIds: List): List { + val uuids = gameIds.mapNotNull { it.toUuidOrNull() } + + wsSession2GameIds.getOrPut(session) { synchronizedSet(mutableSetOf()) }.addAll(uuids) + for (uuid in uuids) { + gameId2WSSessions.getOrPut(uuid) { synchronizedSet(mutableSetOf()) }.add(session) + } + + return uuids.map { it.toString() } + } + + fun unsubscribe(session: DefaultWebSocketServerSession, gameIds: List) { + val uuids = gameIds.mapNotNull { it.toUuidOrNull() } + wsSession2GameIds[session]?.removeAll(uuids) + for (uuid in uuids) { + gameId2WSSessions[uuid]?.remove(session) } } - fun subscribe(userId: String, gameIds: List, session: DefaultWebSocketServerSession) { - userId2GameIds.getOrPut(userId) { synchronizedSet(mutableSetOf()) }.addAll(gameIds) - for (gameId in gameIds) { - gameId2WSSessions.getOrPut(gameId) { synchronizedSet(mutableSetOf()) }.add(session) - } - } - - fun unsubscribe(userId: String, gameIds: List, session: DefaultWebSocketServerSession) { - userId2GameIds[userId]?.removeAll(gameIds) - for (gameId in gameIds) { - gameId2WSSessions[gameId]?.remove(session) - } - } - - fun hasGameId(userId: String, gameId: String) = - userId2GameIds.getOrPut(userId) { synchronizedSet(mutableSetOf()) }.contains(gameId) - - suspend fun publish(gameId: String, message: Response) { + suspend fun publish(gameId: Uuid, message: Response) { val sessions = gameId2WSSessions.getOrPut(gameId) { synchronizedSet(mutableSetOf()) } for (session in sessions) { if (!session.isActive) { @@ -127,14 +124,34 @@ private class WebSocketSessionManager { session.sendSerialized(message) } } + + fun cleanupSession(session: DefaultWebSocketServerSession) { + for (gameId in wsSession2GameIds.remove(session) ?: emptyList()) { + val gameIds = gameId2WSSessions[gameId] ?: continue + gameIds.remove(session) + if (gameIds.isEmpty()) { + gameId2WSSessions.remove(gameId) + } + } + } } +@OptIn(ExperimentalUuidApi::class) data class BasicAuthInfo( - val userId: String, + val userId: Uuid, val password: String, - val isValidUUID: Boolean = false ) : Principal +/** + * Checks if a [String] is a valid UUID + */ +@OptIn(ExperimentalUuidApi::class) +private fun String.toUuidOrNull() = try { + Uuid.parse(this) +} catch (_: Throwable) { + null +} + private class UncivServerRunner : CliktCommand() { private val port by option( "-p", "-port", @@ -177,10 +194,12 @@ private class UncivServerRunner : CliktCommand() { } // region Auth - private val authMap: MutableMap = mutableMapOf() + @OptIn(ExperimentalUuidApi::class) + private val authMap: MutableMap = mutableMapOf() private val wsSessionManager = WebSocketSessionManager() + @OptIn(ExperimentalUuidApi::class) private fun loadAuthFile() { val authFile = File("server.auth") if (!authFile.exists()) { @@ -188,11 +207,13 @@ private class UncivServerRunner : CliktCommand() { authFile.createNewFile() } else { authMap.putAll( - authFile.readLines().map { it.split(":") }.associate { it[0] to it[1] } + authFile.readLines().map { it.split(":") } + .associate { Uuid.parse(it[0]) to it[1] } ) } } + @OptIn(ExperimentalUuidApi::class) private fun saveAuthFile() { val authFile = File("server.auth") authFile.writeText(authMap.map { "${it.key}:${it.value}" }.joinToString("\n")) @@ -212,10 +233,9 @@ private class UncivServerRunner : CliktCommand() { } private fun validateAuth(authInfo: BasicAuthInfo): Boolean { - if (!authV1Enabled) - return true + if (!authV1Enabled) return true - val password = authMap[authInfo.userId] + @OptIn(ExperimentalUuidApi::class) val password = authMap[authInfo.userId] return password == null || password == authInfo.password } // endregion Auth @@ -233,15 +253,12 @@ private class UncivServerRunner : CliktCommand() { basic { realm = "Optional for /files and /auth, Mandatory for /chat" - @OptIn(ExperimentalUuidApi::class) - validate { creds -> - val isValidUUID = try { - Uuid.parse(creds.name) - true + @OptIn(ExperimentalUuidApi::class) validate { + return@validate try { + BasicAuthInfo(userId = Uuid.parse(it.name), password = it.password) } catch (_: Throwable) { - false + null } - BasicAuthInfo(creds.name, creds.password, isValidUUID) } } } @@ -265,21 +282,16 @@ private class UncivServerRunner : CliktCommand() { call.respond(isAliveInfo) } - authenticate { + @OptIn(ExperimentalUuidApi::class) authenticate { put("/files/{fileName}") { val fileName = call.parameters["fileName"] ?: return@put call.respond( - HttpStatusCode.BadRequest, - "Missing filename!" + HttpStatusCode.BadRequest, "Missing filename!" ) val authInfo = call.principal() ?: return@put call.respond( - HttpStatusCode.BadRequest, - "Possibly malformed authentication header!" + HttpStatusCode.BadRequest, "Possibly malformed authentication header!" ) - if (!authInfo.isValidUUID) - return@put call.respond(HttpStatusCode.BadRequest, "Bad userId") - // If IdentifyOperators is enabled an Operator IP is displayed if (identifyOperators) { call.application.log.info("Receiving file: $fileName --Operation sourced from ${call.request.local.remoteHost}") @@ -288,8 +300,7 @@ private class UncivServerRunner : CliktCommand() { } val file = File(fileFolderName, fileName) - if (!validateGameAccess(file, authInfo)) - return@put call.respond(HttpStatusCode.Unauthorized) + if (!validateGameAccess(file, authInfo)) return@put call.respond(HttpStatusCode.Unauthorized) withContext(Dispatchers.IO) { file.outputStream().use { @@ -300,8 +311,7 @@ private class UncivServerRunner : CliktCommand() { } get("/files/{fileName}") { val fileName = call.parameters["fileName"] ?: return@get call.respond( - HttpStatusCode.BadRequest, - "Missing filename!" + HttpStatusCode.BadRequest, "Missing filename!" ) // If IdentifyOperators is enabled an Operator IP is displayed @@ -330,14 +340,9 @@ private class UncivServerRunner : CliktCommand() { get("/auth") { call.application.log.info("Received auth request from ${call.request.local.remoteHost}") - val authInfo = - call.principal() ?: return@get call.respond( - HttpStatusCode.BadRequest, - "Possibly malformed authentication header!" - ) - - if (!authInfo.isValidUUID) - return@get call.respond(HttpStatusCode.BadRequest, "Bad userId") + val authInfo = call.principal() ?: return@get call.respond( + HttpStatusCode.BadRequest, "Possibly malformed authentication header!" + ) when (authMap[authInfo.userId]) { null -> call.respond(HttpStatusCode.NoContent) @@ -348,23 +353,16 @@ private class UncivServerRunner : CliktCommand() { put("/auth") { call.application.log.info("Received auth password set from ${call.request.local.remoteHost}") - val authInfo = - call.principal() ?: return@put call.respond( - HttpStatusCode.BadRequest, - "Possibly malformed authentication header!" - ) - - if (!authInfo.isValidUUID) - return@put call.respond(HttpStatusCode.BadRequest, "Bad userId") + val authInfo = call.principal() ?: return@put call.respond( + HttpStatusCode.BadRequest, "Possibly malformed authentication header!" + ) val password = authMap[authInfo.userId] if (password == null || password == authInfo.password) { val newPassword = call.receiveText() - if (newPassword.length < 6) - return@put call.respond( - HttpStatusCode.BadRequest, - "Password should be at least 6 characters long" - ) + if (newPassword.length < 6) return@put call.respond( + HttpStatusCode.BadRequest, "Password should be at least 6 characters long" + ) authMap[authInfo.userId] = newPassword call.respond(HttpStatusCode.OK) } else { @@ -386,19 +384,28 @@ private class UncivServerRunner : CliktCommand() { return@webSocket close() } - val userId = authInfo.userId try { - while (true) { + while (isActive) { val message = receiveDeserialized() when (message) { is Message.Chat -> { - if (wsSessionManager.hasGameId(userId, message.gameId)) { - wsSessionManager.publish( - message.gameId, + val gameId = message.gameId.toUuidOrNull() + if (gameId == null) { + sendSerialized( Response.Chat( - message.gameId, - message.civName, - message.message + civName = "Server", + message = "Invalid gameId: '${message.gameId}'. Cannot relay the message!", + ) + ) + continue + } + + if (wsSessionManager.isSubscribed(this, gameId)) { + wsSessionManager.publish( + gameId = gameId, message = Response.Chat( + civName = message.civName, + message = message.message, + gameId = message.gameId, ) ) } else { @@ -407,21 +414,25 @@ private class UncivServerRunner : CliktCommand() { } is Message.Join -> { - wsSessionManager.subscribe(userId, message.gameIds, this) - sendSerialized(Response.JoinSuccess(gameIds = message.gameIds)) + sendSerialized( + Response.JoinSuccess( + gameIds = wsSessionManager.subscribe( + this, message.gameIds + ) + ) + ) } - is Message.Leave -> - wsSessionManager.unsubscribe(userId, message.gameIds, this) + is Message.Leave -> wsSessionManager.unsubscribe(this, message.gameIds) } yield() } } catch (err: Throwable) { println("An WebSocket session closed due to ${err.message}") - wsSessionManager.removeSession(userId, this) + wsSessionManager.cleanupSession(this) } finally { println("An WebSocket session closed normally.") - wsSessionManager.removeSession(userId, this) + wsSessionManager.cleanupSession(this) } } }