mirror of
https://github.com/yairm210/Unciv.git
synced 2025-08-03 04:27:56 -04:00
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:
parent
01a4025287
commit
95eb97d517
3
.gitignore
vendored
3
.gitignore
vendored
@ -166,6 +166,9 @@ music/
|
||||
SaveFiles/
|
||||
scenarios/
|
||||
|
||||
# server artifacts
|
||||
server.auth
|
||||
MultiplayerFiles/
|
||||
|
||||
/.kotlin/errors/*
|
||||
/.kotlin/sessions/*
|
||||
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -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
|
||||
|
@ -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())
|
||||
}
|
||||
|
13
core/src/com/unciv/utils/StringExtensions.kt
Normal file
13
core/src/com/unciv/utils/StringExtensions.kt
Normal 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
|
||||
}
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
Loading…
x
Reference in New Issue
Block a user