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/
scenarios/
# server artifacts
server.auth
MultiplayerFiles/
/.kotlin/errors/*
/.kotlin/sessions/*

View File

@ -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,
) {
// <civName, message> pairs
private val messages: MutableList<Pair<String, String>> = 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<String, Chat>()
private var gameIdToChat: MutableMap<UUID, Chat> = 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<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.
@ -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)
}
}
}

View File

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

View File

@ -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,11 +349,9 @@ class AdvancedTab(
private fun addSetUserId() {
val idSetLabel = "".toLabel()
val takeUserIdFromClipboardButton = "Take user ID from clipboard".toTextButton()
.onClick {
try {
val takeUserIdFromClipboardButton = "Take user ID from clipboard".toTextButton().onClick {
val clipboardContents = Gdx.app.clipboard.contents.trim()
UUID.fromString(clipboardContents)
if (clipboardContents.isUUID()) {
ConfirmPopup(
stage,
"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())
}.open(true)
idSetLabel.isVisible = true
} catch (_: Exception) {
} else {
idSetLabel.isVisible = true
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
@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<String, MutableSet<String>>())
private val gameId2WSSessions = synchronizedMap(mutableMapOf<Uuid, MutableSet<DefaultWebSocketServerSession>>())
private val wsSession2GameIds = synchronizedMap(mutableMapOf<DefaultWebSocketServerSession, MutableSet<Uuid>>())
private val gameId2WSSessions =
synchronizedMap(mutableMapOf<String, MutableSet<DefaultWebSocketServerSession>>())
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<String>): List<String> {
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<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) {
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) {
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<String, String> = mutableMapOf()
@OptIn(ExperimentalUuidApi::class)
private val authMap: MutableMap<Uuid, String> = 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<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")
// 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,15 +340,10 @@ private class UncivServerRunner : CliktCommand() {
get("/auth") {
call.application.log.info("Received auth request from ${call.request.local.remoteHost}")
val authInfo =
call.principal<BasicAuthInfo>() ?: return@get call.respond(
HttpStatusCode.BadRequest,
"Possibly malformed authentication header!"
val authInfo = call.principal<BasicAuthInfo>() ?: return@get call.respond(
HttpStatusCode.BadRequest, "Possibly malformed authentication header!"
)
if (!authInfo.isValidUUID)
return@get call.respond(HttpStatusCode.BadRequest, "Bad userId")
when (authMap[authInfo.userId]) {
null -> call.respond(HttpStatusCode.NoContent)
authInfo.password -> call.respond(HttpStatusCode.OK)
@ -348,22 +353,15 @@ private class UncivServerRunner : CliktCommand() {
put("/auth") {
call.application.log.info("Received auth password set from ${call.request.local.remoteHost}")
val authInfo =
call.principal<BasicAuthInfo>() ?: return@put call.respond(
HttpStatusCode.BadRequest,
"Possibly malformed authentication header!"
val authInfo = call.principal<BasicAuthInfo>() ?: return@put call.respond(
HttpStatusCode.BadRequest, "Possibly malformed authentication header!"
)
if (!authInfo.isValidUUID)
return@put call.respond(HttpStatusCode.BadRequest, "Bad userId")
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)
@ -386,19 +384,28 @@ private class UncivServerRunner : CliktCommand() {
return@webSocket close()
}
val userId = authInfo.userId
try {
while (true) {
while (isActive) {
val message = receiveDeserialized<Message>()
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)
}
}
}