feat(security): add internal secret handling and Redis integration for bot events

This commit is contained in:
2026-04-28 09:29:05 +02:00
parent c10a4d7e64
commit 1ab6532b0a
50 changed files with 951 additions and 214 deletions
@@ -1,8 +0,0 @@
package de.nowchess.botplatform.registry
case class BotGameInfo(
botId: String,
difficulty: Int,
playingAs: String,
botAccountId: String,
)
@@ -1,29 +1,44 @@
package de.nowchess.botplatform.registry
import de.nowchess.botplatform.config.RedisConfig
import io.smallrye.mutiny.subscription.MultiEmitter
import jakarta.enterprise.context.ApplicationScoped
import jakarta.inject.Inject
import org.redisson.api.RedissonClient
import org.redisson.api.listener.MessageListener
import scala.compiletime.uninitialized
import java.util.concurrent.ConcurrentHashMap
@ApplicationScoped
class BotRegistry:
private val connections = ConcurrentHashMap[String, MultiEmitter[?]]()
// scalafix:off DisableSyntax.var
@Inject var redisson: RedissonClient = uninitialized
@Inject var redisConfig: RedisConfig = uninitialized
// scalafix:on DisableSyntax.var
private val connections = ConcurrentHashMap[String, (MultiEmitter[?], Int)]()
def register(botId: String, emitter: MultiEmitter[? >: String]): Unit =
connections.put(botId, emitter)
val topic = redisson.getTopic(s"${redisConfig.prefix}:bot:$botId:events")
val listenerId = topic.addListener(
classOf[String],
new MessageListener[String]:
def onMessage(channel: CharSequence, msg: String): Unit =
emitter.asInstanceOf[MultiEmitter[String]].emit(msg),
)
connections.put(botId, (emitter, listenerId))
()
def unregister(botId: String): Unit =
connections.remove(botId)
()
Option(connections.remove(botId)).foreach { (_, listenerId) =>
redisson.getTopic(s"${redisConfig.prefix}:bot:$botId:events").removeListener(listenerId)
}
def dispatch(botId: String, event: String): Boolean =
Option(connections.get(botId)) match
case Some(emitter) =>
emitter.asInstanceOf[MultiEmitter[String]].emit(event)
true
case None => false
def dispatch(botId: String, event: String): Unit =
redisson.getTopic(s"${redisConfig.prefix}:bot:$botId:events").publish(event)
()
def registeredBots: List[String] =
import scala.jdk.CollectionConverters.*
@@ -1,7 +1,7 @@
package de.nowchess.botplatform.resource
import de.nowchess.botplatform.registry.{BotGameInfo, BotRegistry}
import de.nowchess.botplatform.service.GameBotMonitor
import de.nowchess.botplatform.config.RedisConfig
import de.nowchess.botplatform.registry.BotRegistry
import io.smallrye.mutiny.Multi
import jakarta.annotation.security.RolesAllowed
import jakarta.enterprise.context.ApplicationScoped
@@ -9,6 +9,8 @@ import jakarta.inject.Inject
import jakarta.ws.rs.*
import jakarta.ws.rs.core.{MediaType, Response}
import org.eclipse.microprofile.jwt.JsonWebToken
import org.redisson.api.RedissonClient
import org.redisson.api.listener.MessageListener
import scala.compiletime.uninitialized
@Path("/api/bot")
@@ -17,14 +19,10 @@ import scala.compiletime.uninitialized
class BotEventResource:
// scalafix:off DisableSyntax.var
@Inject
var registry: BotRegistry = uninitialized
@Inject
var jwt: JsonWebToken = uninitialized
@Inject
var gameMonitor: GameBotMonitor = uninitialized
@Inject var registry: BotRegistry = uninitialized
@Inject var jwt: JsonWebToken = uninitialized
@Inject var redisson: RedissonClient = uninitialized
@Inject var redisConfig: RedisConfig = uninitialized
// scalafix:on DisableSyntax.var
@GET
@@ -34,7 +32,7 @@ class BotEventResource:
val tokenType = Option(jwt.getClaim[AnyRef]("type")).map(_.toString).getOrElse("")
val subject = Option(jwt.getSubject).getOrElse("")
if tokenType != "bot" || subject != botId then
Multi.createFrom().failure(new jakarta.ws.rs.ForbiddenException("Not authorized for this bot"))
Multi.createFrom().failure(new ForbiddenException("Not authorized for this bot"))
else
Multi.createFrom().emitter[String] { emitter =>
registry.register(botId, emitter)
@@ -46,22 +44,24 @@ class BotEventResource:
@Produces(Array(MediaType.SERVER_SENT_EVENTS))
def streamGame(@PathParam("gameId") gameId: String): Multi[String] =
Multi.createFrom().emitter[String] { emitter =>
registry.register(s"game-$gameId", emitter)
emitter.onTermination(() => registry.unregister(s"game-$gameId"))
val topicName = s"${redisConfig.prefix}:game:$gameId:s2c"
val topic = redisson.getTopic(topicName)
val listenerId = topic.addListener(
classOf[String],
new MessageListener[String]:
def onMessage(channel: CharSequence, msg: String): Unit = emitter.emit(msg),
)
emitter.onTermination(() => topic.removeListener(listenerId))
}
@POST
@Path("/game/{gameId}/assign")
@Path("/game/{gameId}/move/{uci}")
@Produces(Array(MediaType.APPLICATION_JSON))
def assignBot(
@PathParam("gameId") gameId: String,
@QueryParam("botId") botId: String,
@QueryParam("difficulty") difficulty: Int,
@QueryParam("playingAs") playingAs: String,
@QueryParam("botAccountId") botAccountId: String,
def makeMove(
@PathParam("gameId") gameId: String,
@PathParam("uci") uci: String,
): Response =
val info = BotGameInfo(botId, difficulty, playingAs, botAccountId)
gameMonitor.watchGame(gameId, info)
val event = s"""{"type":"gameStart","gameId":"$gameId","botId":"$botId"}"""
registry.dispatch(botId, event)
val playerId = Option(jwt.getSubject).getOrElse("")
val moveMsg = s"""{"type":"MOVE","uci":"$uci","playerId":"$playerId"}"""
redisson.getTopic(s"${redisConfig.prefix}:game:$gameId:c2s").publish(moveMsg)
Response.ok().build()
@@ -1,56 +0,0 @@
package de.nowchess.botplatform.service
import com.fasterxml.jackson.databind.ObjectMapper
import de.nowchess.botplatform.config.RedisConfig
import de.nowchess.botplatform.registry.BotGameInfo
import jakarta.enterprise.context.ApplicationScoped
import jakarta.inject.Inject
import org.redisson.api.{RedissonClient, RBlockingQueue}
import org.redisson.api.listener.MessageListener
import scala.compiletime.uninitialized
import java.util.concurrent.ConcurrentHashMap
@ApplicationScoped
class GameBotMonitor:
// scalafix:off DisableSyntax.var
@Inject var redisson: RedissonClient = uninitialized
@Inject var redisConfig: RedisConfig = uninitialized
@Inject var objectMapper: ObjectMapper = uninitialized
// scalafix:on DisableSyntax.var
private val listeners = ConcurrentHashMap[String, Int]()
def watchGame(gameId: String, info: BotGameInfo): Unit =
val topicName = s"${redisConfig.prefix}:game:$gameId:s2c"
val topic = redisson.getTopic(topicName)
val listenerId = topic.addListener(
classOf[String],
new MessageListener[String]:
def onMessage(channel: CharSequence, msg: String): Unit =
handleS2cEvent(gameId, msg, info),
)
listeners.put(gameId, listenerId)
def unwatchGame(gameId: String): Unit =
Option(listeners.remove(gameId)).foreach { listenerId =>
val topicName = s"${redisConfig.prefix}:game:$gameId:s2c"
redisson.getTopic(topicName).removeListener(listenerId)
}
private val terminalStatuses = Set("checkmate", "resign", "timeout", "stalemate", "insufficientMaterial", "draw")
private def handleS2cEvent(gameId: String, msg: String, info: BotGameInfo): Unit =
try
val node = objectMapper.readTree(msg)
val status = Option(node.path("state").path("status").asText()).getOrElse("")
if terminalStatuses.contains(status) then
unwatchGame(gameId)
else
val turn = Option(node.path("state").path("turn").asText()).getOrElse("")
if turn == info.playingAs then
val fen = node.path("state").path("fen").asText()
val req = s"""{"gameId":"$gameId","fen":"${fen.replace("\"", "\\\"")}","turn":"$turn","playingAs":"${info.playingAs}","difficulty":${info.difficulty},"botAccountId":"${info.botAccountId}"}"""
val queue: RBlockingQueue[String] = redisson.getBlockingQueue("nowchess:bot:move-queue")
queue.put(req)
catch case _: Exception => ()