Goodies before chat retention & UncivServer.jar Chat Session Management fixes (#13735)

* add `String.isUUID()` and game `gameId` in Chat nullable

* refactor `String.isUUID()`

* use `java.util.UUID` for better validation

* use `UUID` as `ChatStore` key

* make `gameId` nullable only for responses

* fix reversed logics

* introduce `isValidPlayerUuid()` and `isValidGameUuid()`

* fix `WebSocketSessionManager` session management

* refactor & simplify `UncivServer.jar`

* make `gameIdToChat` a `synchronizedMap`

* make `checkAndReturnUuiId()` nullable

* remove redundant imports

* revert `IDChecker` changes
This commit is contained in:
Md. Touhidur Rahman 2025-08-01 16:11:37 +06:00 committed by GitHub
parent 01a4025287
commit 95eb97d517
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 160 additions and 119 deletions

3
.gitignore vendored
View File

@ -166,6 +166,9 @@ music/
SaveFiles/ SaveFiles/
scenarios/ scenarios/
# server artifacts
server.auth
MultiplayerFiles/
/.kotlin/errors/* /.kotlin/errors/*
/.kotlin/sessions/* /.kotlin/sessions/*

View File

@ -2,11 +2,13 @@ package com.unciv.logic.multiplayer.chat
import com.badlogic.gdx.Gdx import com.badlogic.gdx.Gdx
import com.unciv.ui.screens.worldscreen.chat.ChatPopup import com.unciv.ui.screens.worldscreen.chat.ChatPopup
import java.util.Collections.synchronizedMap
import java.util.LinkedList import java.util.LinkedList
import java.util.Queue import java.util.Queue
import java.util.UUID
data class Chat( data class Chat(
val gameId: String, val gameId: UUID,
) { ) {
// <civName, message> pairs // <civName, message> pairs
private val messages: MutableList<Pair<String, String>> = mutableListOf(INITIAL_MESSAGE) private val messages: MutableList<Pair<String, String>> = mutableListOf(INITIAL_MESSAGE)
@ -22,7 +24,7 @@ data class Chat(
*/ */
fun requestMessageSend(civName: String, message: String) { fun requestMessageSend(civName: String, message: String) {
Gdx.app.postRunnable { 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 var chatPopup: ChatPopup? = null
private var gameIdToChat = mutableMapOf<String, Chat>() private var gameIdToChat: MutableMap<UUID, Chat> = synchronizedMap(mutableMapOf())
/** When no [ChatPopup] is open to receive these oddities, we keep them here. /** When no [ChatPopup] is open to receive these oddities, we keep them here.
* Certainly better than not knowing why the socket closed. * Certainly better than not knowing why the socket closed.
*/ */
private var globalMessages: Queue<Pair<String, String>> = LinkedList() private var globalMessages: Queue<Pair<String, String>> = 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. * Clears chat by triggering a garbage collection.
@ -71,12 +74,21 @@ object ChatStore {
fun relayChatMessage(chat: Response.Chat) { fun relayChatMessage(chat: Response.Chat) {
Gdx.app.postRunnable { Gdx.app.postRunnable {
if (chat.gameId.isNotEmpty()) { if (chat.gameId == null || chat.gameId.isBlank()) {
getChatByGameId(chat.gameId).addMessage(chat.civName, chat.message) relayGlobalMessage(chat.message, chat.civName)
if (chatPopup?.chat?.gameId == chat.gameId) { } 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) chatPopup?.addMessage(chat.civName, chat.message)
} }
} else relayGlobalMessage(chat.message, chat.civName) }
} }
} }

View File

@ -35,7 +35,7 @@ sealed class Message {
@Serializable @Serializable
@SerialName("chat") @SerialName("chat")
data class Chat( data class Chat(
val gameId: String, val civName: String, val message: String val civName: String, val message: String, val gameId: String
) : Message() ) : Message()
@Serializable @Serializable
@ -57,7 +57,7 @@ sealed class Response {
@Serializable @Serializable
@SerialName("chat") @SerialName("chat")
data class Chat( data class Chat(
val gameId: String, val civName: String, val message: String val civName: String, val message: String, val gameId: String? = null
) : Response() ) : Response()
@Serializable @Serializable

View File

@ -22,7 +22,11 @@ import com.unciv.models.metadata.ModCategories
import com.unciv.models.translations.TranslationFileWriter import com.unciv.models.translations.TranslationFileWriter
import com.unciv.models.translations.tr import com.unciv.models.translations.tr
import com.unciv.ui.components.UncivTooltip.Companion.addTooltip 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.FontFamilyData
import com.unciv.ui.components.fonts.Fonts import com.unciv.ui.components.fonts.Fonts
import com.unciv.ui.components.input.keyShortcuts 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.ui.screens.basescreen.BaseScreen
import com.unciv.utils.Concurrency import com.unciv.utils.Concurrency
import com.unciv.utils.Display import com.unciv.utils.Display
import com.unciv.utils.isUUID
import com.unciv.utils.launchOnGLThread import com.unciv.utils.launchOnGLThread
import com.unciv.utils.withoutItem import com.unciv.utils.withoutItem
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import java.util.*
import java.util.zip.Deflater import java.util.zip.Deflater
class AdvancedTab( class AdvancedTab(
@ -345,11 +349,9 @@ class AdvancedTab(
private fun addSetUserId() { private fun addSetUserId() {
val idSetLabel = "".toLabel() val idSetLabel = "".toLabel()
val takeUserIdFromClipboardButton = "Take user ID from clipboard".toTextButton() val takeUserIdFromClipboardButton = "Take user ID from clipboard".toTextButton().onClick {
.onClick {
try {
val clipboardContents = Gdx.app.clipboard.contents.trim() val clipboardContents = Gdx.app.clipboard.contents.trim()
UUID.fromString(clipboardContents) if (clipboardContents.isUUID()) {
ConfirmPopup( ConfirmPopup(
stage, stage,
"Doing this will reset your current user ID to the clipboard contents - are you sure?", "Doing this will reset your current user ID to the clipboard contents - are you sure?",
@ -359,7 +361,7 @@ class AdvancedTab(
idSetLabel.setFontColor(Color.WHITE).setText("ID successfully set!".tr()) idSetLabel.setFontColor(Color.WHITE).setText("ID successfully set!".tr())
}.open(true) }.open(true)
idSetLabel.isVisible = true idSetLabel.isVisible = true
} catch (_: Exception) { } else {
idSetLabel.isVisible = true idSetLabel.isVisible = true
idSetLabel.setFontColor(Color.RED).setText("Invalid ID!".tr()) idSetLabel.setFontColor(Color.RED).setText("Invalid ID!".tr())
} }

View File

@ -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
}

View File

@ -49,7 +49,7 @@ sealed class Message {
@Serializable @Serializable
@SerialName("chat") @SerialName("chat")
data class Chat( data class Chat(
val gameId: String, val civName: String, val message: String val civName: String, val message: String, val gameId: String
) : Message() ) : Message()
@Serializable @Serializable
@ -71,7 +71,7 @@ sealed class Response {
@Serializable @Serializable
@SerialName("chat") @SerialName("chat")
data class Chat( data class Chat(
val gameId: String, val civName: String, val message: String val civName: String, val message: String, val gameId: String? = null
) : Response() ) : Response()
@Serializable @Serializable
@ -87,37 +87,34 @@ sealed class Response {
) : Response() ) : Response()
} }
@OptIn(ExperimentalUuidApi::class)
private class WebSocketSessionManager { private class WebSocketSessionManager {
private val userId2GameIds = synchronizedMap(mutableMapOf<String, MutableSet<String>>()) private val gameId2WSSessions = synchronizedMap(mutableMapOf<Uuid, MutableSet<DefaultWebSocketServerSession>>())
private val wsSession2GameIds = synchronizedMap(mutableMapOf<DefaultWebSocketServerSession, MutableSet<Uuid>>())
private val gameId2WSSessions = fun isSubscribed(session: DefaultWebSocketServerSession, gameId: Uuid): Boolean =
synchronizedMap(mutableMapOf<String, MutableSet<DefaultWebSocketServerSession>>()) gameId2WSSessions.getOrPut(gameId) { synchronizedSet(mutableSetOf()) }.contains(session)
fun removeSession(userId: String, session: DefaultWebSocketServerSession) { fun subscribe(session: DefaultWebSocketServerSession, gameIds: List<String>): List<String> {
val gameIds = userId2GameIds.remove(userId) val uuids = gameIds.mapNotNull { it.toUuidOrNull() }
for (gameId in gameIds ?: emptyList()) {
gameId2WSSessions[gameId]?.remove(session) 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<String>) {
val uuids = gameIds.mapNotNull { it.toUuidOrNull() }
wsSession2GameIds[session]?.removeAll(uuids)
for (uuid in uuids) {
gameId2WSSessions[uuid]?.remove(session)
} }
} }
fun subscribe(userId: String, gameIds: List<String>, session: DefaultWebSocketServerSession) { suspend fun publish(gameId: Uuid, message: Response) {
userId2GameIds.getOrPut(userId) { synchronizedSet(mutableSetOf()) }.addAll(gameIds)
for (gameId in gameIds) {
gameId2WSSessions.getOrPut(gameId) { synchronizedSet(mutableSetOf()) }.add(session)
}
}
fun unsubscribe(userId: String, gameIds: List<String>, 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) {
val sessions = gameId2WSSessions.getOrPut(gameId) { synchronizedSet(mutableSetOf()) } val sessions = gameId2WSSessions.getOrPut(gameId) { synchronizedSet(mutableSetOf()) }
for (session in sessions) { for (session in sessions) {
if (!session.isActive) { if (!session.isActive) {
@ -127,14 +124,34 @@ private class WebSocketSessionManager {
session.sendSerialized(message) 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( data class BasicAuthInfo(
val userId: String, val userId: Uuid,
val password: String, val password: String,
val isValidUUID: Boolean = false
) : Principal ) : 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 class UncivServerRunner : CliktCommand() {
private val port by option( private val port by option(
"-p", "-port", "-p", "-port",
@ -177,10 +194,12 @@ private class UncivServerRunner : CliktCommand() {
} }
// region Auth // region Auth
private val authMap: MutableMap<String, String> = mutableMapOf() @OptIn(ExperimentalUuidApi::class)
private val authMap: MutableMap<Uuid, String> = mutableMapOf()
private val wsSessionManager = WebSocketSessionManager() private val wsSessionManager = WebSocketSessionManager()
@OptIn(ExperimentalUuidApi::class)
private fun loadAuthFile() { private fun loadAuthFile() {
val authFile = File("server.auth") val authFile = File("server.auth")
if (!authFile.exists()) { if (!authFile.exists()) {
@ -188,11 +207,13 @@ private class UncivServerRunner : CliktCommand() {
authFile.createNewFile() authFile.createNewFile()
} else { } else {
authMap.putAll( 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() { private fun saveAuthFile() {
val authFile = File("server.auth") val authFile = File("server.auth")
authFile.writeText(authMap.map { "${it.key}:${it.value}" }.joinToString("\n")) authFile.writeText(authMap.map { "${it.key}:${it.value}" }.joinToString("\n"))
@ -212,10 +233,9 @@ private class UncivServerRunner : CliktCommand() {
} }
private fun validateAuth(authInfo: BasicAuthInfo): Boolean { private fun validateAuth(authInfo: BasicAuthInfo): Boolean {
if (!authV1Enabled) if (!authV1Enabled) return true
return true
val password = authMap[authInfo.userId] @OptIn(ExperimentalUuidApi::class) val password = authMap[authInfo.userId]
return password == null || password == authInfo.password return password == null || password == authInfo.password
} }
// endregion Auth // endregion Auth
@ -233,15 +253,12 @@ private class UncivServerRunner : CliktCommand() {
basic { basic {
realm = "Optional for /files and /auth, Mandatory for /chat" realm = "Optional for /files and /auth, Mandatory for /chat"
@OptIn(ExperimentalUuidApi::class) @OptIn(ExperimentalUuidApi::class) validate {
validate { creds -> return@validate try {
val isValidUUID = try { BasicAuthInfo(userId = Uuid.parse(it.name), password = it.password)
Uuid.parse(creds.name)
true
} catch (_: Throwable) { } catch (_: Throwable) {
false null
} }
BasicAuthInfo(creds.name, creds.password, isValidUUID)
} }
} }
} }
@ -265,21 +282,16 @@ private class UncivServerRunner : CliktCommand() {
call.respond(isAliveInfo) call.respond(isAliveInfo)
} }
authenticate { @OptIn(ExperimentalUuidApi::class) authenticate {
put("/files/{fileName}") { put("/files/{fileName}") {
val fileName = call.parameters["fileName"] ?: return@put call.respond( val fileName = call.parameters["fileName"] ?: return@put call.respond(
HttpStatusCode.BadRequest, HttpStatusCode.BadRequest, "Missing filename!"
"Missing filename!"
) )
val authInfo = call.principal<BasicAuthInfo>() ?: return@put call.respond( val authInfo = call.principal<BasicAuthInfo>() ?: return@put call.respond(
HttpStatusCode.BadRequest, HttpStatusCode.BadRequest, "Possibly malformed authentication header!"
"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 is enabled an Operator IP is displayed
if (identifyOperators) { if (identifyOperators) {
call.application.log.info("Receiving file: $fileName --Operation sourced from ${call.request.local.remoteHost}") 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) val file = File(fileFolderName, fileName)
if (!validateGameAccess(file, authInfo)) if (!validateGameAccess(file, authInfo)) return@put call.respond(HttpStatusCode.Unauthorized)
return@put call.respond(HttpStatusCode.Unauthorized)
withContext(Dispatchers.IO) { withContext(Dispatchers.IO) {
file.outputStream().use { file.outputStream().use {
@ -300,8 +311,7 @@ private class UncivServerRunner : CliktCommand() {
} }
get("/files/{fileName}") { get("/files/{fileName}") {
val fileName = call.parameters["fileName"] ?: return@get call.respond( val fileName = call.parameters["fileName"] ?: return@get call.respond(
HttpStatusCode.BadRequest, HttpStatusCode.BadRequest, "Missing filename!"
"Missing filename!"
) )
// If IdentifyOperators is enabled an Operator IP is displayed // If IdentifyOperators is enabled an Operator IP is displayed
@ -330,15 +340,10 @@ private class UncivServerRunner : CliktCommand() {
get("/auth") { get("/auth") {
call.application.log.info("Received auth request from ${call.request.local.remoteHost}") call.application.log.info("Received auth request from ${call.request.local.remoteHost}")
val authInfo = val authInfo = call.principal<BasicAuthInfo>() ?: return@get call.respond(
call.principal<BasicAuthInfo>() ?: return@get call.respond( HttpStatusCode.BadRequest, "Possibly malformed authentication header!"
HttpStatusCode.BadRequest,
"Possibly malformed authentication header!"
) )
if (!authInfo.isValidUUID)
return@get call.respond(HttpStatusCode.BadRequest, "Bad userId")
when (authMap[authInfo.userId]) { when (authMap[authInfo.userId]) {
null -> call.respond(HttpStatusCode.NoContent) null -> call.respond(HttpStatusCode.NoContent)
authInfo.password -> call.respond(HttpStatusCode.OK) authInfo.password -> call.respond(HttpStatusCode.OK)
@ -348,22 +353,15 @@ private class UncivServerRunner : CliktCommand() {
put("/auth") { put("/auth") {
call.application.log.info("Received auth password set from ${call.request.local.remoteHost}") call.application.log.info("Received auth password set from ${call.request.local.remoteHost}")
val authInfo = val authInfo = call.principal<BasicAuthInfo>() ?: return@put call.respond(
call.principal<BasicAuthInfo>() ?: 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")
val password = authMap[authInfo.userId] val password = authMap[authInfo.userId]
if (password == null || password == authInfo.password) { if (password == null || password == authInfo.password) {
val newPassword = call.receiveText() val newPassword = call.receiveText()
if (newPassword.length < 6) if (newPassword.length < 6) return@put call.respond(
return@put call.respond( HttpStatusCode.BadRequest, "Password should be at least 6 characters long"
HttpStatusCode.BadRequest,
"Password should be at least 6 characters long"
) )
authMap[authInfo.userId] = newPassword authMap[authInfo.userId] = newPassword
call.respond(HttpStatusCode.OK) call.respond(HttpStatusCode.OK)
@ -386,19 +384,28 @@ private class UncivServerRunner : CliktCommand() {
return@webSocket close() return@webSocket close()
} }
val userId = authInfo.userId
try { try {
while (true) { while (isActive) {
val message = receiveDeserialized<Message>() val message = receiveDeserialized<Message>()
when (message) { when (message) {
is Message.Chat -> { is Message.Chat -> {
if (wsSessionManager.hasGameId(userId, message.gameId)) { val gameId = message.gameId.toUuidOrNull()
wsSessionManager.publish( if (gameId == null) {
message.gameId, sendSerialized(
Response.Chat( Response.Chat(
message.gameId, civName = "Server",
message.civName, message = "Invalid gameId: '${message.gameId}'. Cannot relay the message!",
message.message )
)
continue
}
if (wsSessionManager.isSubscribed(this, gameId)) {
wsSessionManager.publish(
gameId = gameId, message = Response.Chat(
civName = message.civName,
message = message.message,
gameId = message.gameId,
) )
) )
} else { } else {
@ -407,21 +414,25 @@ private class UncivServerRunner : CliktCommand() {
} }
is Message.Join -> { is Message.Join -> {
wsSessionManager.subscribe(userId, message.gameIds, this) sendSerialized(
sendSerialized(Response.JoinSuccess(gameIds = message.gameIds)) Response.JoinSuccess(
gameIds = wsSessionManager.subscribe(
this, message.gameIds
)
)
)
} }
is Message.Leave -> is Message.Leave -> wsSessionManager.unsubscribe(this, message.gameIds)
wsSessionManager.unsubscribe(userId, message.gameIds, this)
} }
yield() yield()
} }
} catch (err: Throwable) { } catch (err: Throwable) {
println("An WebSocket session closed due to ${err.message}") println("An WebSocket session closed due to ${err.message}")
wsSessionManager.removeSession(userId, this) wsSessionManager.cleanupSession(this)
} finally { } finally {
println("An WebSocket session closed normally.") println("An WebSocket session closed normally.")
wsSessionManager.removeSession(userId, this) wsSessionManager.cleanupSession(this)
} }
} }
} }