Compare commits

..

2 Commits

Author SHA1 Message Date
Janis a20bee3b93 fix(security): guard against null UriInfo in rate limit log
Build & Test (NowChessSystems) TeamCity build finished
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-05 10:33:40 +02:00
Janis 1ae455eb99 feat(security): add per-IP rate limiting to account API endpoints
Build & Test (NowChessSystems) TeamCity build failed
Adds a fixed-window rate limiter (default 60 req/60s per IP) to all
public account endpoints (AccountResource, ChallengeResource,
OfficialChallengeResource). Implemented as a JAX-RS @NameBinding
ContainerRequestFilter in the shared security module.

IP is resolved from X-Forwarded-For > X-Real-IP > "unknown".
Load-test traffic can bypass via X-Gatling-Secret header matched
against the optional nowchess.rate-limit.gatling-secret config.
Exceeded requests receive HTTP 429 with a warn-level log.

Closes NCS-65
https://knockoutwhist.youtrack.cloud/issue/NCS-65

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-02 15:03:16 +02:00
17 changed files with 225 additions and 278 deletions
@@ -30,8 +30,15 @@ nowchess:
prefix: nowchess
internal:
secret: 123abc
rate-limit:
enabled: true
requests-per-window: 60
window-seconds: 60
"%test":
nowchess:
rate-limit:
enabled: false
quarkus:
datasource:
db-kind: h2
@@ -89,6 +96,8 @@ nowchess:
prefix: ${REDIS_PREFIX:nowchess}
internal:
secret: ${INTERNAL_SECRET}
rate-limit:
gatling-secret: ${GATLING_SECRET}
mp:
jwt:
verify:
@@ -4,6 +4,7 @@ import de.nowchess.account.domain.{BotAccount, OfficialBotAccount, UserAccount}
import de.nowchess.account.dto.*
import de.nowchess.account.error.AccountError
import de.nowchess.account.service.AccountService
import de.nowchess.security.RateLimited
import jakarta.annotation.security.RolesAllowed
import jakarta.enterprise.context.ApplicationScoped
import jakarta.inject.Inject
@@ -16,6 +17,7 @@ import java.util.UUID
@Path("/api/account")
@ApplicationScoped
@RateLimited
@Consumes(Array(MediaType.APPLICATION_JSON))
@Produces(Array(MediaType.APPLICATION_JSON))
class AccountResource:
@@ -3,6 +3,7 @@ package de.nowchess.account.resource
import de.nowchess.account.dto.*
import de.nowchess.account.error.ChallengeError
import de.nowchess.account.service.ChallengeService
import de.nowchess.security.RateLimited
import jakarta.annotation.security.RolesAllowed
import jakarta.enterprise.context.ApplicationScoped
import jakarta.inject.Inject
@@ -16,6 +17,7 @@ import java.util.UUID
@Path("/api/challenge")
@ApplicationScoped
@RolesAllowed(Array("**"))
@RateLimited
@Consumes(Array(MediaType.APPLICATION_JSON))
@Produces(Array(MediaType.APPLICATION_JSON))
class ChallengeResource:
@@ -3,6 +3,7 @@ package de.nowchess.account.resource
import de.nowchess.account.client.{CoreCreateGameRequest, CoreGameClient, CorePlayerInfo}
import de.nowchess.account.dto.{ErrorDto, OfficialChallengeResponse}
import de.nowchess.account.service.{AccountService, EventPublisher}
import de.nowchess.security.RateLimited
import jakarta.annotation.security.RolesAllowed
import jakarta.enterprise.context.ApplicationScoped
import jakarta.inject.Inject
@@ -19,6 +20,7 @@ import java.util.concurrent.ThreadLocalRandom
@Path("/api/challenge/official")
@ApplicationScoped
@RolesAllowed(Array("**"))
@RateLimited
@Consumes(Array(MediaType.APPLICATION_JSON))
@Produces(Array(MediaType.APPLICATION_JSON))
class OfficialChallengeResource:
@@ -34,3 +34,17 @@ nowchess:
secret: test-secret
auth:
enabled: false
rate-limit:
enabled: false
"%rate-limit-test":
nowchess:
internal:
secret: test-secret
auth:
enabled: false
rate-limit:
enabled: true
requests-per-window: 3
window-seconds: 60
gatling-secret: gatling-test-secret
@@ -0,0 +1,89 @@
package de.nowchess.account.resource
import de.nowchess.security.RateLimitFilter
import jakarta.ws.rs.container.ContainerRequestContext
import jakarta.ws.rs.core.Response
import org.junit.jupiter.api.Assertions.*
import org.junit.jupiter.api.Test
import org.mockito.ArgumentCaptor
import org.mockito.ArgumentMatchers.any
import org.mockito.Mockito.*
import java.util.Optional
class RateLimitFilterTest:
private def newFilter(limit: Long = 2L): RateLimitFilter =
val f = new RateLimitFilter()
f.enabled = true
f.requestsPerWindow = limit
f.windowSeconds = 60
f.gatlingSecret = Optional.of("gatling-secret")
f
private def ctx(ip: String = "10.0.0.1", gatlingSecret: String = "") =
val c = mock(classOf[ContainerRequestContext])
when(c.getHeaderString("X-Forwarded-For")).thenReturn(ip)
if gatlingSecret.nonEmpty then when(c.getHeaderString("X-Gatling-Secret")).thenReturn(gatlingSecret)
c
@Test
def allowsRequestsUpToLimit(): Unit =
val filter = newFilter()
for _ <- 1 to 2 do
val c = ctx()
filter.filter(c)
verify(c, never()).abortWith(any())
@Test
def blocks429WhenLimitExceeded(): Unit =
val filter = newFilter(limit = 2L)
filter.filter(ctx("1.2.3.4"))
filter.filter(ctx("1.2.3.4"))
val c = ctx("1.2.3.4")
filter.filter(c)
val captor = ArgumentCaptor.forClass(classOf[Response])
verify(c).abortWith(captor.capture())
assertEquals(429, captor.getValue.getStatus)
@Test
def gatlingSecretBypasses(): Unit =
val filter = newFilter(limit = 1L)
for _ <- 1 to 5 do
val c = ctx("1.2.3.5", "gatling-secret")
filter.filter(c)
verify(c, never()).abortWith(any())
@Test
def emptyGatlingSecretDisablesGatlingBypass(): Unit =
val filter = newFilter(limit = 1L)
filter.gatlingSecret = Optional.empty()
filter.filter(ctx("2.2.2.2", "gatling-secret"))
val c = ctx("2.2.2.2", "gatling-secret")
filter.filter(c)
verify(c).abortWith(any())
@Test
def doesNothingWhenDisabled(): Unit =
val filter = newFilter()
filter.enabled = false
for _ <- 1 to 5 do
val c = ctx()
filter.filter(c)
verify(c, never()).abortWith(any())
@Test
def tracksDifferentIpsSeparately(): Unit =
val filter = newFilter(limit = 1L)
for ip <- List("10.1.1.1", "10.1.1.2", "10.1.1.3") do
val c = ctx(ip)
filter.filter(c)
verify(c, never()).abortWith(any())
@Test
def usesXForwardedForFirstSegment(): Unit =
val filter = newFilter(limit = 1L)
filter.filter(ctx("203.0.113.1, 10.0.0.1"))
val c = ctx("203.0.113.1, 10.0.0.1")
filter.filter(c)
verify(c).abortWith(any())
-68
View File
@@ -1759,71 +1759,3 @@
* Revert "feat: add authentication permissions for metrics endpoints in application.yml" ([a298417](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/a298417b9e4d68dc73bbf40be63d9484536e9f83))
* Revert "refactor: update metrics paths formatting in application.yml for clarity" ([3870566](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/38705663498d5f47c40dafe2f26198589ede8656))
## (2026-06-02)
### Features
* add authentication permissions for metrics endpoints in application.yml ([04edd4d](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/04edd4d6fd8a63196c36f6d67992832febc9bebb))
* add CORS configuration and reorder JWT settings in application.yml ([a49f9be](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/a49f9be146f04c14561c305d980846a92f8c12b2))
* add GameRules stub with PositionStatus enum ([76d4168](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/76d4168038de23e5d6083d4e8f0504fbf31d15a3))
* add initialization metrics for various services ([d438e97](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/d438e97f32bdde0bfc63c1b4a8cc810cdd093166))
* add MovedInCheck/Checkmate/Stalemate MoveResult variants (stub dispatch) ([8b7ec57](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/8b7ec57e5ea6ee1615a1883848a426dc07d26364))
* add OpenTelemetry trace configuration with parentbased sampler ([3904d5a](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/3904d5ad8ad4930ddee65287a7bfab785a6148f5))
* **config:** add GameWritebackEventDto to reflection targets ([87f29a7](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/87f29a720422f538ef70699533500e060337b8ea))
* **config:** update application.yml for PostgreSQL and remove staging/production configurations ([2404e61](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/2404e6164c3b50ffccbea5238d636060d6abe4d6))
* **config:** update application.yml for staging and production environments ([6113432](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/6113432a14c476a3a0dfc0d449e17d023697f2ba))
* configure logging and add OpenTelemetry support ([#49](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/49)) ([d57c488](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/d57c4886612d1d92da0e1b79209fc83e6ef537a1))
* implement clock expiry scanning and handling for game records ([#53](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/53)) ([8f9eb12](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/8f9eb12f663efabe4dc72b94394438652ad0ef02))
* implement GameRules with isInCheck, legalMoves, gameStatus ([94a02ff](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/94a02ff6849436d9496c70a0f16c21666dae8e4e))
* implement legal castling ([#1](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/1)) ([00d326c](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/00d326c1ba67711fbe180f04e1100c3f01dd0254))
* implement periodic scaling checks and enhance instance management in AutoScaler ([3f12f69](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/3f12f695f132b92f634d98df2c037292498b6e86))
* **logging:** add DEBUG/INFO/WARN logging across services (NCS-72) ([#41](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/41)) ([804a4bf](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/804a4bf179e3dfb19e2be4390e7e543caf5237c6))
* NCS-10 Implement Pawn Promotion ([#12](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/12)) ([13bfc16](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/13bfc16cfe25db78ec607db523ca6d993c13430c))
* NCS-11 50-move rule ([#9](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/9)) ([412ed98](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/412ed986a95703a3b282276540153480ceed229d))
* NCS-13 Implement Threefold Repetition ([#31](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/31)) ([767d305](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/767d3051a76c266050b6335774d66e2db2273c16))
* NCS-14 implemented insufficient moves rule ([#30](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/30)) ([b0399a4](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/b0399a4e489950083066c9538df9a84dcc7a4613))
* NCS-16 Core Separation via Patterns ([#10](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/10)) ([1361dfc](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/1361dfc89553b146864fb8ff3526cf12cf3f293a))
* NCS-17 Implement basic ScalaFX UI ([#14](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/14)) ([3ff8031](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/3ff80318b4f16c59733a46498581a5c27f048287))
* NCS-21 Write Scripts to automate certain tasks ([#15](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/15)) ([8051871](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/80518719d536a087d339fe02530825dc07f8b388))
* NCS-25 Add linters to keep quality up ([#27](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/27)) ([fd4e67d](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/fd4e67d4f782a7e955822d90cb909d0a81676fb2))
* NCS-37 Quarkus integration ([#35](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/35)) ([f088c4e](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/f088c4e9ffcc498d3d1b6f01e8f50042d5830d55))
* NCS-40 Rework Draw System ([#34](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/34)) ([33e785d](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/33e785d22af87724839b62ae91dfe74a05b398c3))
* NCS-41 Bot Platform ([#33](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/33)) ([8744bee](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/8744bee2dd20966dae90a09c21a43d5b06f59e00))
* NCS-53 changed IO to MicroService for easier scaling ([#37](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/37)) ([b5a2966](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/b5a2966adafa9650f0f7d601bdeb8fdd13710327))
* NCS-6 Implementing FEN & PGN ([#7](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/7)) ([f28e69d](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/f28e69dc181416aa2f221fdc4b45c2cda5efbf07))
* NCS-78 Add Traceability to the Applications ([#48](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/48)) ([c96a09b](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/c96a09bb5cee59fc23205bb63baa8b217a7e1b00))
* NCS-9 En passant implementation ([#8](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/8)) ([919beb3](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/919beb3b4bfa8caf2f90976a415fe9b19b7e9747))
* **redis:** implement game writeback stream processing with error handling and retries ([ae3ef76](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/ae3ef766e8b7596a09e466cd4fb386119f17ca5c))
* **rule:** Rules as a microservice ([#39](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/39)) ([093134d](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/093134d36c6844ba02a36a28d5d044f09291cd1d))
* true-microservices ([#40](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/40)) ([5909242](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/590924254e8a2754de661a57a03e43f89ceb6299))
* update application.yml with new API root paths and add Micrometer and OpenTelemetry dependencies ([72ce262](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/72ce262bc491f94297700e6002fb5d0812e2cc2a))
* wire check/checkmate/stalemate into processMove and gameLoop ([5264a22](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/5264a225418b885c5e6ea6411b96f85e38837f6c))
### Bug Fixes
* add missing kings to gameLoop capture test board ([aedd787](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/aedd787b77203c2af934751dba7b784eaf165032))
* **auth:** change InternalAuthFilter to use @Singleton and add HTTP tests for secret validation ([c08d530](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/c08d5303eb9e70d36c8eebf6a061ccb71e118fe5))
* **auth:** update InternalAuthFilter to use @ApplicationScoped and add index-dependency configuration ([6e0fd95](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/6e0fd9523e001756ce7109e639ebb54be4fcdabf))
* **core:** add logs to trace subscribeGame call in createGame ([f5614c3](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/f5614c358255598ba1230e42a56b22934d79183c))
* correct test board positions and captureOutput/withInput interaction ([f0481e2](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/f0481e2561b779df00925b46ee281dc36a795150))
* **heartbeat:** inject ObjectMapper into InstanceHeartbeatService ([#42](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/42)) ([0c98151](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/0c981517da1f94cd10ae396e47bde2b35d0b3ba0))
* IO microservice ([#38](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/38)) ([a386f57](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/a386f57c21d34ead6cc6f92836c52b714597e289))
* Lints ([dc224ab](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/dc224abe26acf5361c56956006e1cc51b75b0b7e))
* NCS-84 More Verbose Logging ([#51](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/51)) ([4ad92ab](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/4ad92ab23698267f8faa59c4e18388d4a0042cca))
* NCS-85 Database Writeback fails without Logs ([#52](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/52)) ([7323908](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/73239088d985f01aa6b1067ed9097a845e471d4f))
* **pgn:** add SAN disambiguation and check/checkmate suffixes [NCS-42] ([#56](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/56)) ([2579539](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/2579539084152178f4482ddb7b84b7f1162f10da))
* **redis:** add max pool wait time and switch to ReactiveRedisDataSource for heartbeat updates ([33e5017](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/33e5017f51a998327b180f778f73964cc10c05d3))
* **redis:** enhance GameRedisSubscriberManager to use ReactiveRedisDataSource and improve subscription handling ([0eb752d](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/0eb752d4935377f75aab710b7f4eda4b29098e6a))
* **redis:** prevent concurrent Redis heartbeat refreshes using AtomicBoolean ([847b132](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/847b13202cb909d18ca3304c27ebe17ce2312b8e))
* **redis:** simplify refreshRedisHeartbeat logic and ensure proper error handling ([1813ea1](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/1813ea1d2d5d093f7925f87371b5e29820bf1136))
* **redis:** update Redis configuration with max pool size and waiting parameters ([5baf6a7](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/5baf6a7cdbea484fc49c02e2b5a1c3919b7fa2c4))
* remove unused HTTP root-path configurations from application.yml ([3ed3e59](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/3ed3e59ee456d54cd3d65ece4f36623e256b9736))
* resolve 6 coordinator bugs (cache eviction, rebalance race, pod matching, lookup inefficiency) ([5619c82](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/5619c8223ad7091706909eda8c907a29d215fd30))
* update documentation to reflect new functions in CoordinatorGrpcServer and InstanceRegistry ([f7ce4df](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/f7ce4df595cbdc2ef84122781f4851ff140c0f44))
* update main class path in build configuration and adjust VCS directory mapping ([7b1f8b1](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/7b1f8b117623d327232a1a92a8a44d18582e0189))
* update move validation to check for king safety ([#13](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/13)) ([e5e20c5](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/e5e20c566e368b12ca1dc59680c34e9112bf6762))
### Reverts
* Revert "feat: add authentication permissions for metrics endpoints in application.yml" ([a298417](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/a298417b9e4d68dc73bbf40be63d9484536e9f83))
* Revert "refactor: update metrics paths formatting in application.yml for clarity" ([3870566](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/38705663498d5f47c40dafe2f26198589ede8656))
@@ -418,6 +418,7 @@ class GameEngine(
val contextBefore = currentContext
val nextContext = ruleSet.applyMove(currentContext)(move)
val captured = computeCaptured(currentContext, move)
val notation = translateMoveToNotation(move, contextBefore.board)
currentContext = nextContext
advanceClock(contextBefore.turn)
@@ -462,18 +463,13 @@ class GameEngine(
redoStack = Nil
else if status.isCheck then notifyObservers(CheckDetectedEvent(currentContext))
private def translateMoveToNotation(move: Move, ctxBefore: GameContext, status: PostMoveStatus): String =
val suffix =
if status.isCheckmate then "#"
else if status.isCheck then "+"
else ""
val base = move.moveType match
private def translateMoveToNotation(move: Move, boardBefore: Board): String =
move.moveType match
case MoveType.CastleKingside => "O-O"
case MoveType.CastleQueenside => "O-O-O"
case MoveType.EnPassant => enPassantNotation(move)
case MoveType.Promotion(pp) => promotionNotation(move, pp)
case MoveType.Normal(isCapture) => normalMoveNotation(move, ctxBefore, isCapture)
base + suffix
case MoveType.Normal(isCapture) => normalMoveNotation(move, boardBefore, isCapture)
private def enPassantNotation(move: Move): String =
s"${move.from.file.toString.toLowerCase}x${move.to}"
@@ -486,31 +482,16 @@ class GameEngine(
case PromotionPiece.Knight => "N"
s"${move.to}=$ppChar"
private[engine] def normalMoveNotation(move: Move, ctxBefore: GameContext, isCapture: Boolean): String =
ctxBefore.board.pieceAt(move.from).map(_.pieceType) match
private[engine] def normalMoveNotation(move: Move, boardBefore: Board, isCapture: Boolean): String =
boardBefore.pieceAt(move.from).map(_.pieceType) match
case Some(PieceType.Pawn) =>
if isCapture then s"${move.from.file.toString.toLowerCase}x${move.to}"
else move.to.toString
case Some(pt) =>
val letter = pieceNotation(pt)
val d = disambiguatePiece(move.from, move.to, pt, ctxBefore)
if isCapture then s"$letter${d}x${move.to}" else s"$letter$d${move.to}"
if isCapture then s"${letter}x${move.to}" else s"$letter${move.to}"
case None => move.to.toString
private def disambiguatePiece(from: Square, to: Square, pieceType: PieceType, ctx: GameContext): String =
if pieceType == PieceType.King then ""
else
val competitors = ruleSet
.allLegalMoves(ctx)
.filter(m => m.to == to && m.from != from && ctx.board.pieceAt(m.from).exists(_.pieceType == pieceType))
if competitors.isEmpty then ""
else
val sameFile = competitors.exists(_.from.file == from.file)
val sameRank = competitors.exists(_.from.rank == from.rank)
if !sameFile then from.file.toString.toLowerCase
else if !sameRank then (from.rank.ordinal + 1).toString
else from.toString
private[engine] def pieceNotation(pieceType: PieceType): String =
pieceType match
case PieceType.Knight => "N"
@@ -538,10 +519,9 @@ class GameEngine(
if currentContext.moves.isEmpty then
notifyObservers(InvalidMoveEvent(currentContext, InvalidMoveReason.NothingToUndo))
else
val lastMove = currentContext.moves.last
val prevCtx = replayContextFromMoves(currentContext.moves.dropRight(1))
val postStatus = ruleSet.postMoveStatus(currentContext)
val notation = translateMoveToNotation(lastMove, prevCtx, postStatus)
val lastMove = currentContext.moves.last
val prevCtx = replayContextFromMoves(currentContext.moves.dropRight(1))
val notation = translateMoveToNotation(lastMove, prevCtx.board)
redoStack = lastMove :: redoStack
currentContext = prevCtx
notifyObservers(MoveUndoneEvent(currentContext, notation))
@@ -1,6 +1,6 @@
package de.nowchess.chess.engine
import de.nowchess.api.board.{Color, File, PieceType, Rank, Square}
import de.nowchess.api.board.{Board, Color, File, PieceType, Rank, Square}
import de.nowchess.api.game.GameContext
import de.nowchess.api.move.{Move, MoveType, PromotionPiece}
import de.nowchess.chess.observer.{GameEvent, InvalidMoveEvent, InvalidMoveReason, Observer}
@@ -137,7 +137,7 @@ class GameEngineIntegrationTest extends AnyFunSuite with Matchers:
test("normalMoveNotation handles missing source piece"):
val engine = new GameEngine(ruleSet = DefaultRules)
val result = engine.normalMoveNotation(Move(sq("e3"), sq("e4")), GameContext.initial, isCapture = false)
val result = engine.normalMoveNotation(Move(sq("e3"), sq("e4")), Board.initial, isCapture = false)
result shouldBe "e4"
@@ -106,7 +106,7 @@ class GameEngineNotationTest extends AnyFunSuite with Matchers:
engine.undo()
val evt = events.collect { case e: MoveUndoneEvent => e }.head
evt.pgnNotation shouldBe "e8=B+"
evt.pgnNotation shouldBe "e8=B"
// ── King normal move notation (line 246) ───────────────────────────
@@ -134,87 +134,3 @@ class GameEngineNotationTest extends AnyFunSuite with Matchers:
val evt = events.collect { case e: MoveUndoneEvent => e }.head
evt.pgnNotation should startWith("K")
evt.pgnNotation should include("f1")
// ── Disambiguation: two knights same rank (file suffix) ────────────
test("undo with two knights on same rank disambiguates by file"):
// White knights on b1 and f3, black pawn on h7 prevents draw, both knights can reach d2
val board = FenParser.parseBoard("k7/7p/8/8/8/5N2/8/1N5K").get
val ctx = GameContext.initial
.withBoard(board)
.withTurn(Color.White)
.withCastlingRights(de.nowchess.api.board.CastlingRights(false, false, false, false))
val engine = new GameEngine(ctx, DefaultRules)
val events = captureEvents(engine)
// Knight on b1 moves to d2; notation must be "Nbd2" to disambiguate from Nf3
engine.processUserInput("b1d2")
events.clear()
engine.undo()
val evt = events.collect { case e: MoveUndoneEvent => e }.head
evt.pgnNotation should startWith("Nb")
evt.pgnNotation should include("d2")
// ── Disambiguation: two knights same file (rank suffix) ────────────
test("undo with two knights on same file disambiguates by rank"):
// White knights on b1 and b3, both can reach d2 or c5; use b1->d2
val board = FenParser.parseBoard("k7/7p/8/8/8/1N6/8/1N5K").get
val ctx = GameContext.initial
.withBoard(board)
.withTurn(Color.White)
.withCastlingRights(de.nowchess.api.board.CastlingRights(false, false, false, false))
val engine = new GameEngine(ctx, DefaultRules)
val events = captureEvents(engine)
// Knight on b1 moves to d2; notation must be "N1d2" to disambiguate from b3
engine.processUserInput("b1d2")
events.clear()
engine.undo()
val evt = events.collect { case e: MoveUndoneEvent => e }.head
evt.pgnNotation should include("1")
evt.pgnNotation should include("d2")
// ── Check suffix (+) ───────────────────────────────────────────────
test("undo after move that gives check emits notation with + suffix"):
// White rook a1, white king h1, black king e8; Ra1-e1 gives check on e-file
val board = FenParser.parseBoard("4k3/8/8/8/8/8/8/R6K").get
val ctx = GameContext.initial
.withBoard(board)
.withTurn(Color.White)
.withCastlingRights(de.nowchess.api.board.CastlingRights(false, false, false, false))
val engine = new GameEngine(ctx, DefaultRules)
val events = captureEvents(engine)
engine.processUserInput("a1e1")
events.clear()
engine.undo()
val evt = events.collect { case e: MoveUndoneEvent => e }.head
evt.pgnNotation should endWith("+")
// ── Checkmate suffix (#) ──────────────────────────────────────────
test("undo after checkmate move emits notation with # suffix"):
// Fool's mate setup (before final move): 1.f3 e5 2.g4 -- black plays Qd8-h4#
val board = FenParser.parseBoard("rnbqkbnr/pppp1ppp/8/4p3/6P1/5P2/PPPPP2P/RNBQKBNR").get
val ctx = GameContext.initial
.withBoard(board)
.withTurn(Color.Black)
.withCastlingRights(de.nowchess.api.board.CastlingRights(true, true, true, true))
val engine = new GameEngine(ctx, DefaultRules)
val events = captureEvents(engine)
engine.processUserInput("d8h4")
events.clear()
engine.undo()
val evt = events.collect { case e: MoveUndoneEvent => e }.head
evt.pgnNotation should endWith("#")
+1 -1
View File
@@ -1,3 +1,3 @@
MAJOR=0
MINOR=47
MINOR=46
PATCH=0
-28
View File
@@ -296,31 +296,3 @@
* Revert "feat: add authentication permissions for metrics endpoints in application.yml" ([a298417](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/a298417b9e4d68dc73bbf40be63d9484536e9f83))
* Revert "refactor: update metrics paths formatting in application.yml for clarity" ([3870566](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/38705663498d5f47c40dafe2f26198589ede8656))
## (2026-06-02)
### Features
* add authentication permissions for metrics endpoints in application.yml ([04edd4d](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/04edd4d6fd8a63196c36f6d67992832febc9bebb))
* add OpenTelemetry trace configuration with parentbased sampler ([3904d5a](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/3904d5ad8ad4930ddee65287a7bfab785a6148f5))
* **config:** update application.yml for PostgreSQL and remove staging/production configurations ([2404e61](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/2404e6164c3b50ffccbea5238d636060d6abe4d6))
* **config:** update application.yml for staging and production environments ([6113432](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/6113432a14c476a3a0dfc0d449e17d023697f2ba))
* NCS-14 implemented insufficient moves rule ([#30](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/30)) ([b0399a4](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/b0399a4e489950083066c9538df9a84dcc7a4613))
* NCS-25 Add linters to keep quality up ([#27](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/27)) ([fd4e67d](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/fd4e67d4f782a7e955822d90cb909d0a81676fb2))
* NCS-29 JSON - Cherry Picked ([#28](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/28)) ([dbcafd2](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/dbcafd286993e0604a6fa286c5543581a149439e))
* NCS-30 FEN Parser using ParserCombinators ([#21](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/21)) ([b4bc72f](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/b4bc72f7e49f94d6e1bc805c68680e5fe8ef8e36))
* NCS-31 FastParse FEN ([#22](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/22)) ([7a045d3](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/7a045d31d757bbc5aa6f4bad2664ebe8b8519cac))
* NCS-37 Quarkus integration ([#35](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/35)) ([f088c4e](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/f088c4e9ffcc498d3d1b6f01e8f50042d5830d55))
* NCS-41 Bot Platform ([#33](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/33)) ([8744bee](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/8744bee2dd20966dae90a09c21a43d5b06f59e00))
* NCS-53 changed IO to MicroService for easier scaling ([#37](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/37)) ([b5a2966](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/b5a2966adafa9650f0f7d601bdeb8fdd13710327))
* true-microservices ([#40](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/40)) ([5909242](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/590924254e8a2754de661a57a03e43f89ceb6299))
* update application.yml with new API root paths and add Micrometer and OpenTelemetry dependencies ([72ce262](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/72ce262bc491f94297700e6002fb5d0812e2cc2a))
### Bug Fixes
* IO microservice ([#38](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/38)) ([a386f57](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/a386f57c21d34ead6cc6f92836c52b714597e289))
* **pgn:** add SAN disambiguation and check/checkmate suffixes [NCS-42] ([#56](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/56)) ([2579539](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/2579539084152178f4482ddb7b84b7f1162f10da))
### Reverts
* Revert "feat: add authentication permissions for metrics endpoints in application.yml" ([a298417](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/a298417b9e4d68dc73bbf40be63d9484536e9f83))
* Revert "refactor: update metrics paths formatting in application.yml for clarity" ([3870566](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/38705663498d5f47c40dafe2f26198589ede8656))
@@ -31,9 +31,7 @@ object PgnExporter extends GameContextExport:
if moves.isEmpty then ""
else
val contexts = moves.scanLeft(GameContext.initial)((ctx, move) => DefaultRules.applyMove(ctx)(move))
val sanMoves = moves.zip(contexts).zip(contexts.tail).map { case ((move, ctxBefore), ctxAfter) =>
moveToAlgebraic(move, ctxBefore, ctxAfter)
}
val sanMoves = moves.zip(contexts).map { case (move, ctx) => moveToAlgebraic(move, ctx.board) }
val groupedMoves = sanMoves.zipWithIndex.groupBy(_._2 / 2)
val moveLines = for (moveNumber, movePairs) <- groupedMoves.toList.sortBy(_._1) yield
@@ -50,24 +48,9 @@ object PgnExporter extends GameContextExport:
else if moveText.isEmpty then headerLines
else s"$headerLines\n\n$moveText"
private def disambiguate(from: Square, to: Square, pieceType: PieceType, ctx: GameContext): String =
val competitors = DefaultRules
.allLegalMoves(ctx)
.filter(m => m.to == to && m.from != from && ctx.board.pieceAt(m.from).exists(_.pieceType == pieceType))
if competitors.isEmpty then ""
else
val sameFile = competitors.exists(_.from.file == from.file)
val sameRank = competitors.exists(_.from.rank == from.rank)
if !sameFile then from.file.toString.toLowerCase
else if !sameRank then (from.rank.ordinal + 1).toString
else from.toString
private def moveToAlgebraic(move: Move, ctxBefore: GameContext, ctxAfter: GameContext): String =
val suffix =
if DefaultRules.isCheckmate(ctxAfter) then "#"
else if DefaultRules.isCheck(ctxAfter) then "+"
else ""
val base = move.moveType match
/** Convert a Move to Standard Algebraic Notation using the board state before the move. */
private def moveToAlgebraic(move: Move, boardBefore: Board): String =
move.moveType match
case MoveType.CastleKingside => "O-O"
case MoveType.CastleQueenside => "O-O-O"
case MoveType.EnPassant => s"${move.from.file.toString.toLowerCase}x${move.to}"
@@ -77,19 +60,18 @@ object PgnExporter extends GameContextExport:
case PromotionPiece.Rook => "=R"
case PromotionPiece.Bishop => "=B"
case PromotionPiece.Knight => "=N"
val isCapture = ctxBefore.board.pieceAt(move.to).isDefined
val isCapture = boardBefore.pieceAt(move.to).isDefined
if isCapture then s"${move.from.file.toString.toLowerCase}x${move.to}$promSuffix"
else s"${move.to}$promSuffix"
case MoveType.Normal(isCapture) =>
val dest = move.to.toString
val capStr = if isCapture then "x" else ""
ctxBefore.board.pieceAt(move.from).map(_.pieceType).getOrElse(PieceType.Pawn) match
boardBefore.pieceAt(move.from).map(_.pieceType).getOrElse(PieceType.Pawn) match
case PieceType.Pawn =>
if isCapture then s"${move.from.file.toString.toLowerCase}x$dest"
else dest
case PieceType.Knight => s"N${disambiguate(move.from, move.to, PieceType.Knight, ctxBefore)}$capStr$dest"
case PieceType.Bishop => s"B${disambiguate(move.from, move.to, PieceType.Bishop, ctxBefore)}$capStr$dest"
case PieceType.Rook => s"R${disambiguate(move.from, move.to, PieceType.Rook, ctxBefore)}$capStr$dest"
case PieceType.Queen => s"Q${disambiguate(move.from, move.to, PieceType.Queen, ctxBefore)}$capStr$dest"
case PieceType.Knight => s"N$capStr$dest"
case PieceType.Bishop => s"B$capStr$dest"
case PieceType.Rook => s"R$capStr$dest"
case PieceType.Queen => s"Q$capStr$dest"
case PieceType.King => s"K$capStr$dest"
base + suffix
@@ -112,38 +112,3 @@ class PgnExporterTest extends AnyFunSuite with Matchers:
pgn should include("exf8=Q")
pawnCapturePgn should include("exd3")
quietPromotionPgn should include("e8=Q")
test("exportGame disambiguates when two knights can reach same square"):
// 1.Nf3 a6 2.d3 a5 3.Nfd2: d3 clears d2 so both Nb1 and Nf3 can reach d2; must emit "Nfd2"
val moves = List(
Move(sq("g1"), sq("f3")),
Move(sq("a7"), sq("a6")),
Move(sq("d2"), sq("d3")),
Move(sq("a6"), sq("a5")),
Move(sq("f3"), sq("d2")),
)
val pgn = PgnExporter.exportGame(Map.empty, moves)
pgn should include("Nfd2")
test("exportGame appends + after move that gives check"):
// 1.e4 e5 2.Qh5 Nc6 3.Qxf7+ — queen captures f7, gives check to black king on e8
val moves = List(
Move(sq("e2"), sq("e4")),
Move(sq("e7"), sq("e5")),
Move(sq("d1"), sq("h5")),
Move(sq("b8"), sq("c6")),
Move(sq("h5"), sq("f7"), MoveType.Normal(isCapture = true)),
)
val pgn = PgnExporter.exportGame(Map.empty, moves)
pgn should include("Qxf7+")
test("exportGame appends # after checkmate move"):
// Fool's mate: 1.f3 e5 2.g4 Qh4#
val moves = List(
Move(sq("f2"), sq("f3")),
Move(sq("e7"), sq("e5")),
Move(sq("g2"), sq("g4")),
Move(sq("d8"), sq("h4")),
)
val pgn = PgnExporter.exportGame(Map("Result" -> "*"), moves)
pgn should include("Qh4#")
+1 -1
View File
@@ -1,3 +1,3 @@
MAJOR=0
MINOR=23
MINOR=22
PATCH=0
@@ -0,0 +1,12 @@
package de.nowchess.security;
import jakarta.ws.rs.NameBinding;
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
@NameBinding
@Retention(RetentionPolicy.RUNTIME)
@Target({ElementType.TYPE, ElementType.METHOD})
public @interface RateLimited {}
@@ -0,0 +1,70 @@
package de.nowchess.security
import jakarta.enterprise.context.ApplicationScoped
import jakarta.ws.rs.container.{ContainerRequestContext, ContainerRequestFilter}
import jakarta.ws.rs.core.Response
import jakarta.ws.rs.ext.Provider
import org.eclipse.microprofile.config.inject.ConfigProperty
import org.jboss.logging.Logger
import scala.compiletime.uninitialized
import java.time.{Duration, Instant}
import java.util.Optional
import java.util.concurrent.ConcurrentHashMap
import java.util.concurrent.atomic.AtomicLong
@Provider
@RateLimited
@ApplicationScoped
class RateLimitFilter extends ContainerRequestFilter:
@ConfigProperty(name = "nowchess.rate-limit.enabled", defaultValue = "true")
// scalafix:off DisableSyntax.var
var enabled: Boolean = uninitialized
@ConfigProperty(name = "nowchess.rate-limit.requests-per-window", defaultValue = "60")
var requestsPerWindow: Long = uninitialized
@ConfigProperty(name = "nowchess.rate-limit.window-seconds", defaultValue = "60")
var windowSeconds: Long = uninitialized
@ConfigProperty(name = "nowchess.rate-limit.gatling-secret")
var gatlingSecret: Optional[String] = uninitialized
// scalafix:on DisableSyntax.var
private val log = Logger.getLogger(classOf[RateLimitFilter])
private val counters = new ConcurrentHashMap[String, RateWindow]()
override def filter(ctx: ContainerRequestContext): Unit =
val ip = clientIp(ctx)
if enabled && !isGatlingRequest(ctx) && isOverLimit(ip) then
val path = Option(ctx.getUriInfo).map(_.getPath).getOrElse("-")
log.warnf("Rate limit exceeded for IP %s on %s %s", ip, ctx.getMethod, path)
ctx.abortWith(Response.status(429).build())
private def isGatlingRequest(ctx: ContainerRequestContext): Boolean =
gatlingSecret.isPresent &&
Option(ctx.getHeaderString("X-Gatling-Secret")).contains(gatlingSecret.get())
private def isOverLimit(ip: String): Boolean =
val now = Instant.now()
val window = counters.compute(
ip,
(_, current) =>
// scalafix:off DisableSyntax.null
if current == null || isExpired(current.start, now) then RateWindow(now, new AtomicLong(0))
// scalafix:on DisableSyntax.null
else current,
)
window.count.incrementAndGet() > requestsPerWindow
private def clientIp(ctx: ContainerRequestContext): String =
Option(ctx.getHeaderString("X-Forwarded-For"))
.map(_.split(",").head.trim)
.orElse(Option(ctx.getHeaderString("X-Real-IP")))
.getOrElse("unknown")
private def isExpired(start: Instant, now: Instant): Boolean =
Duration.between(start, now).getSeconds >= windowSeconds
private final case class RateWindow(start: Instant, count: AtomicLong)