From aaf2f281c7b297e37d8063a957cce7a085bee307 Mon Sep 17 00:00:00 2001 From: Amir Rajabi Date: Tue, 30 Dec 2025 16:15:00 +0330 Subject: [PATCH 1/2] Add services --- api/api-app/pom.xml | 9 ++ .../nilin/opex/api/app/config/CacheConfig.kt | 24 ++++ .../opex/api/app/config/RateLimitConfig.kt | 104 ++++++++++++++++++ .../api/app/config/RateLimitConfigLoader.kt | 21 ++++ .../api/app/controller/RateLimitController.kt | 17 +++ .../co/nilin/opex/api/app/data/BlockResult.kt | 6 + .../api/app/data/RateLimitPenaltyState.kt | 7 ++ .../nilin/opex/api/app/data/RateLimitState.kt | 8 ++ .../service/RateLimitCoordinatorService.kt | 42 +++++++ .../app/service/RateLimitPenaltyService.kt | 103 +++++++++++++++++ .../api/app/service/RateLimiterService.kt | 53 +++++++++ .../src/main/resources/application.yml | 18 ++- .../opex/api/core/inout/RateLimitEndpoint.kt | 10 ++ .../opex/api/core/inout/RateLimitGroup.kt | 11 ++ .../opex/api/core/inout/RateLimitPenalty.kt | 8 ++ .../api/core/spi/RateLimitConfigService.kt | 12 ++ .../ports/binance/config/SecurityConfig.kt | 1 + api/api-ports/api-persister-postgres/pom.xml | 12 ++ .../ports/postgres/config/PostgresConfig.kt | 59 +++++++--- .../dao/RateLimitEndpointRepository.kt | 12 ++ .../postgres/dao/RateLimitGroupRepository.kt | 12 ++ .../dao/RateLimitPenaltyRepository.kt | 11 ++ .../postgres/impl/RateLimitConfigImpl.kt | 70 ++++++++++++ .../postgres/model/RateLimitEndpointModel.kt | 15 +++ .../postgres/model/RateLimitGroupModel.kt | 16 +++ .../postgres/model/RateLimitPenaltyModel.kt | 13 +++ .../ports/postgres/util/RedisCacheHelper.kt | 104 ++++++++++++++++++ .../db/migration/V1__init_schema.sql | 1 + .../db/migration/V2__create_tables.sql | 53 +++++++++ .../migration/V3__insert_rate_limit_data.sql | 92 ++++++++++++++++ .../src/main/resources/schema.sql | 23 ---- docker-compose.yml | 4 +- .../controller/PaymentGatewayController.kt | 6 +- .../opex/wallet/app/dto/PaymentCurrency.kt | 5 - .../wallet/app/dto/PaymentDepositRequest.kt | 6 +- .../opex/wallet/core/inout/TransferMethod.kt | 2 +- 36 files changed, 920 insertions(+), 50 deletions(-) create mode 100644 api/api-app/src/main/kotlin/co/nilin/opex/api/app/config/RateLimitConfig.kt create mode 100644 api/api-app/src/main/kotlin/co/nilin/opex/api/app/config/RateLimitConfigLoader.kt create mode 100644 api/api-app/src/main/kotlin/co/nilin/opex/api/app/controller/RateLimitController.kt create mode 100644 api/api-app/src/main/kotlin/co/nilin/opex/api/app/data/BlockResult.kt create mode 100644 api/api-app/src/main/kotlin/co/nilin/opex/api/app/data/RateLimitPenaltyState.kt create mode 100644 api/api-app/src/main/kotlin/co/nilin/opex/api/app/data/RateLimitState.kt create mode 100644 api/api-app/src/main/kotlin/co/nilin/opex/api/app/service/RateLimitCoordinatorService.kt create mode 100644 api/api-app/src/main/kotlin/co/nilin/opex/api/app/service/RateLimitPenaltyService.kt create mode 100644 api/api-app/src/main/kotlin/co/nilin/opex/api/app/service/RateLimiterService.kt create mode 100644 api/api-core/src/main/kotlin/co/nilin/opex/api/core/inout/RateLimitEndpoint.kt create mode 100644 api/api-core/src/main/kotlin/co/nilin/opex/api/core/inout/RateLimitGroup.kt create mode 100644 api/api-core/src/main/kotlin/co/nilin/opex/api/core/inout/RateLimitPenalty.kt create mode 100644 api/api-core/src/main/kotlin/co/nilin/opex/api/core/spi/RateLimitConfigService.kt create mode 100644 api/api-ports/api-persister-postgres/src/main/kotlin/co/nilin/opex/api/ports/postgres/dao/RateLimitEndpointRepository.kt create mode 100644 api/api-ports/api-persister-postgres/src/main/kotlin/co/nilin/opex/api/ports/postgres/dao/RateLimitGroupRepository.kt create mode 100644 api/api-ports/api-persister-postgres/src/main/kotlin/co/nilin/opex/api/ports/postgres/dao/RateLimitPenaltyRepository.kt create mode 100644 api/api-ports/api-persister-postgres/src/main/kotlin/co/nilin/opex/api/ports/postgres/impl/RateLimitConfigImpl.kt create mode 100644 api/api-ports/api-persister-postgres/src/main/kotlin/co/nilin/opex/api/ports/postgres/model/RateLimitEndpointModel.kt create mode 100644 api/api-ports/api-persister-postgres/src/main/kotlin/co/nilin/opex/api/ports/postgres/model/RateLimitGroupModel.kt create mode 100644 api/api-ports/api-persister-postgres/src/main/kotlin/co/nilin/opex/api/ports/postgres/model/RateLimitPenaltyModel.kt create mode 100644 api/api-ports/api-persister-postgres/src/main/kotlin/co/nilin/opex/api/ports/postgres/util/RedisCacheHelper.kt create mode 100644 api/api-ports/api-persister-postgres/src/main/resources/db/migration/V1__init_schema.sql create mode 100644 api/api-ports/api-persister-postgres/src/main/resources/db/migration/V2__create_tables.sql create mode 100644 api/api-ports/api-persister-postgres/src/main/resources/db/migration/V3__insert_rate_limit_data.sql delete mode 100644 api/api-ports/api-persister-postgres/src/main/resources/schema.sql delete mode 100644 wallet/wallet-app/src/main/kotlin/co/nilin/opex/wallet/app/dto/PaymentCurrency.kt diff --git a/api/api-app/pom.xml b/api/api-app/pom.xml index b2a2e11aa..164918767 100644 --- a/api/api-app/pom.xml +++ b/api/api-app/pom.xml @@ -74,6 +74,15 @@ micrometer-registry-prometheus runtime + + com.github.vladimir-bukhtoyarov + bucket4j-core + 8.0.1 + + + org.springframework.boot + spring-boot-starter-data-redis + diff --git a/api/api-app/src/main/kotlin/co/nilin/opex/api/app/config/CacheConfig.kt b/api/api-app/src/main/kotlin/co/nilin/opex/api/app/config/CacheConfig.kt index 5127560d8..564772a2e 100644 --- a/api/api-app/src/main/kotlin/co/nilin/opex/api/app/config/CacheConfig.kt +++ b/api/api-app/src/main/kotlin/co/nilin/opex/api/app/config/CacheConfig.kt @@ -1,15 +1,39 @@ package co.nilin.opex.api.app.config +import com.fasterxml.jackson.databind.ObjectMapper +import com.fasterxml.jackson.module.kotlin.registerKotlinModule import org.springframework.cache.CacheManager import org.springframework.cache.annotation.EnableCaching import org.springframework.cache.concurrent.ConcurrentMapCacheManager import org.springframework.context.annotation.Bean import org.springframework.context.annotation.Configuration +import org.springframework.data.redis.connection.RedisConnectionFactory +import org.springframework.data.redis.core.RedisTemplate +import org.springframework.data.redis.serializer.GenericJackson2JsonRedisSerializer +import org.springframework.data.redis.serializer.StringRedisSerializer @Configuration @EnableCaching class CacheConfig { + @Bean + fun redisTemplate(connectionFactory: RedisConnectionFactory, mapper: ObjectMapper): RedisTemplate { + val newMapper = mapper.copy().apply { + activateDefaultTyping(mapper.polymorphicTypeValidator, ObjectMapper.DefaultTyping.EVERYTHING) + findAndRegisterModules() + registerKotlinModule() + } + return RedisTemplate().apply { + setConnectionFactory(connectionFactory) + val ser = GenericJackson2JsonRedisSerializer(newMapper) + valueSerializer = ser + hashValueSerializer = ser + keySerializer = StringRedisSerializer() + hashKeySerializer = StringRedisSerializer() + afterPropertiesSet() + } + } + @Bean fun apiKeyCacheManager(): CacheManager { return ConcurrentMapCacheManager("apiKey") diff --git a/api/api-app/src/main/kotlin/co/nilin/opex/api/app/config/RateLimitConfig.kt b/api/api-app/src/main/kotlin/co/nilin/opex/api/app/config/RateLimitConfig.kt new file mode 100644 index 000000000..f56888aef --- /dev/null +++ b/api/api-app/src/main/kotlin/co/nilin/opex/api/app/config/RateLimitConfig.kt @@ -0,0 +1,104 @@ +package co.nilin.opex.api.app.config + +import co.nilin.opex.api.app.service.RateLimitCoordinatorService +import co.nilin.opex.api.core.inout.RateLimitEndpoint +import co.nilin.opex.api.core.spi.RateLimitConfigService +import org.springframework.http.HttpStatus +import org.springframework.security.core.context.ReactiveSecurityContextHolder +import org.springframework.stereotype.Component +import org.springframework.web.server.ServerWebExchange +import org.springframework.web.server.WebFilter +import org.springframework.web.server.WebFilterChain +import org.springframework.web.util.pattern.PathPatternParser +import reactor.core.publisher.Mono + +@Component +class RateLimitConfig( + private val rateLimitConfig: RateLimitConfigService, + private val coordinator: RateLimitCoordinatorService + +) : WebFilter { + private val parser = PathPatternParser() + + override fun filter(exchange: ServerWebExchange, chain: WebFilterChain): Mono { + + val endpoint = rateLimitConfig.getEndpoints() + .asSequence() + .filter { it.enabled } + .filter { it.method.equals(exchange.request.method.name(), true) } + .sortedByDescending { it.priority } + .firstOrNull { endpoint -> + val pattern = parser.parse(endpoint.url) + pattern.matches(exchange.request.path) + } + + if (endpoint == null) { + return chain.filter(exchange) + } + + return applyRateLimitIfAuthenticated(exchange, chain, endpoint) + } + + + private fun applyRateLimitIfAuthenticated( + exchange: ServerWebExchange, + chain: WebFilterChain, + endpoint: RateLimitEndpoint + ): Mono { + + return ReactiveSecurityContextHolder.getContext() + .mapNotNull { it.authentication } + .filter { it.isAuthenticated } + .flatMap { auth -> + if (auth != null && !auth.name.isNullOrBlank()) + applyRateLimit(auth.name, exchange, chain, endpoint) + else + chain.filter(exchange) + } + + } + + + private fun applyRateLimit( + identity: String, + exchange: ServerWebExchange, + chain: WebFilterChain, + endpoint: RateLimitEndpoint + ): Mono { + + val group = rateLimitConfig.getGroup(endpoint.groupId) + ?: return chain.filter(exchange) + + val result = coordinator.check( + identity = identity, + groupId = endpoint.groupId, + maxRequests = group.requestCount, + windowSeconds = group.requestWindowSeconds, + apiPath = endpoint.url, + apiMethod = endpoint.method + ) + + return if (result.blocked) { + tooManyRequests(exchange, identity, endpoint.url, endpoint.method, result.retryAfterSeconds) + } else { + chain.filter(exchange) + } + } + + //TODO should throw opex error + private fun tooManyRequests( + exchange: ServerWebExchange, + identity: String, + url: String, + method: String, + retryAfterSeconds: Int + ): Mono { + exchange.response.statusCode = HttpStatus.TOO_MANY_REQUESTS + return exchange.response.writeWith( + Mono.just( + exchange.response.bufferFactory() + .wrap("Rate limit exceeded ($identity) -- $method:$url -- Retry-After, $retryAfterSeconds".toByteArray()) + ) + ) + } +} \ No newline at end of file diff --git a/api/api-app/src/main/kotlin/co/nilin/opex/api/app/config/RateLimitConfigLoader.kt b/api/api-app/src/main/kotlin/co/nilin/opex/api/app/config/RateLimitConfigLoader.kt new file mode 100644 index 000000000..447bbeb4d --- /dev/null +++ b/api/api-app/src/main/kotlin/co/nilin/opex/api/app/config/RateLimitConfigLoader.kt @@ -0,0 +1,21 @@ +package co.nilin.opex.api.app.config + +import co.nilin.opex.api.core.spi.RateLimitConfigService +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import org.springframework.boot.context.event.ApplicationReadyEvent +import org.springframework.context.event.EventListener +import org.springframework.stereotype.Component + +@Component +class RateLimitConfigLoader( + private val rateLimitConfig: RateLimitConfigService +) { + @EventListener(ApplicationReadyEvent::class) + fun preload() { + CoroutineScope(Dispatchers.Default).launch { + rateLimitConfig.loadConfig() + } + } +} \ No newline at end of file diff --git a/api/api-app/src/main/kotlin/co/nilin/opex/api/app/controller/RateLimitController.kt b/api/api-app/src/main/kotlin/co/nilin/opex/api/app/controller/RateLimitController.kt new file mode 100644 index 000000000..e4a8f679c --- /dev/null +++ b/api/api-app/src/main/kotlin/co/nilin/opex/api/app/controller/RateLimitController.kt @@ -0,0 +1,17 @@ +package co.nilin.opex.api.app.controller + +import co.nilin.opex.api.core.spi.RateLimitConfigService +import org.springframework.web.bind.annotation.PostMapping +import org.springframework.web.bind.annotation.RequestMapping +import org.springframework.web.bind.annotation.RestController + +@RestController +@RequestMapping("/v1/rate-limit") +class RateLimitController( + private val rateLimitConfig: RateLimitConfigService, +) { + @PostMapping + suspend fun reloadRateLimits() { + rateLimitConfig.loadConfig() + } +} \ No newline at end of file diff --git a/api/api-app/src/main/kotlin/co/nilin/opex/api/app/data/BlockResult.kt b/api/api-app/src/main/kotlin/co/nilin/opex/api/app/data/BlockResult.kt new file mode 100644 index 000000000..a84dd5e73 --- /dev/null +++ b/api/api-app/src/main/kotlin/co/nilin/opex/api/app/data/BlockResult.kt @@ -0,0 +1,6 @@ +package co.nilin.opex.api.app.data + +data class BlockResult( + val blocked: Boolean, + val retryAfterSeconds: Int = 0 +) \ No newline at end of file diff --git a/api/api-app/src/main/kotlin/co/nilin/opex/api/app/data/RateLimitPenaltyState.kt b/api/api-app/src/main/kotlin/co/nilin/opex/api/app/data/RateLimitPenaltyState.kt new file mode 100644 index 000000000..e7d1f5f94 --- /dev/null +++ b/api/api-app/src/main/kotlin/co/nilin/opex/api/app/data/RateLimitPenaltyState.kt @@ -0,0 +1,7 @@ +package co.nilin.opex.api.app.data + +data class RateLimitPenaltyState( + var violationCount: Int = 0, + var lastViolationAt: Long? = null, + var bannedUntil: Long? = null +) \ No newline at end of file diff --git a/api/api-app/src/main/kotlin/co/nilin/opex/api/app/data/RateLimitState.kt b/api/api-app/src/main/kotlin/co/nilin/opex/api/app/data/RateLimitState.kt new file mode 100644 index 000000000..15172b495 --- /dev/null +++ b/api/api-app/src/main/kotlin/co/nilin/opex/api/app/data/RateLimitState.kt @@ -0,0 +1,8 @@ +package co.nilin.opex.api.app.data + +data class RateLimitState( + var violationCount: Int = 0, + var blockedUntil: Long? = null, + var lastViolationAt: Long? = null, + var graceRemaining: Int = 0 +) diff --git a/api/api-app/src/main/kotlin/co/nilin/opex/api/app/service/RateLimitCoordinatorService.kt b/api/api-app/src/main/kotlin/co/nilin/opex/api/app/service/RateLimitCoordinatorService.kt new file mode 100644 index 000000000..61744d114 --- /dev/null +++ b/api/api-app/src/main/kotlin/co/nilin/opex/api/app/service/RateLimitCoordinatorService.kt @@ -0,0 +1,42 @@ +package co.nilin.opex.api.app.service + +import co.nilin.opex.api.app.data.BlockResult +import org.springframework.stereotype.Component + +@Component +class RateLimitCoordinatorService( + private val rateLimiterService: RateLimiterService, + private val penaltyService: RateLimitPenaltyService +) { + + + fun check( + identity: String, + groupId: Long, + maxRequests: Int, + windowSeconds: Int, + apiPath: String, + apiMethod: String + ): BlockResult { + + val blocked = penaltyService.isBlocked(identity, apiPath, apiMethod) + if (blocked.blocked) { + return blocked + } + + val allowed = rateLimiterService.checkRateLimit( + identity = identity, + maxRequests = maxRequests, + windowInSeconds = windowSeconds, + apiPath = apiPath, + apiMethod = apiMethod + ) + + return if (allowed) { + penaltyService.onAllowed(identity, groupId, apiPath, apiMethod) + BlockResult(blocked = false) + } else { + penaltyService.onLimit(identity, groupId, apiPath, apiMethod) + } + } +} diff --git a/api/api-app/src/main/kotlin/co/nilin/opex/api/app/service/RateLimitPenaltyService.kt b/api/api-app/src/main/kotlin/co/nilin/opex/api/app/service/RateLimitPenaltyService.kt new file mode 100644 index 000000000..f0d483478 --- /dev/null +++ b/api/api-app/src/main/kotlin/co/nilin/opex/api/app/service/RateLimitPenaltyService.kt @@ -0,0 +1,103 @@ +package co.nilin.opex.api.app.service + +import co.nilin.opex.api.app.data.BlockResult +import co.nilin.opex.api.app.data.RateLimitPenaltyState +import co.nilin.opex.api.core.spi.RateLimitConfigService +import co.nilin.opex.api.ports.postgres.util.RedisCacheHelper +import co.nilin.opex.common.utils.DynamicInterval +import org.springframework.stereotype.Component +import java.time.Duration +import java.util.concurrent.TimeUnit +import kotlin.math.min + +@Component +class RateLimitPenaltyService(private val config: RateLimitConfigService, private val redis: RedisCacheHelper) { + + fun isBlocked(identity: String, apiPath: String, apiMethod: String): BlockResult { + val state = getPenaltyState(identity, apiPath, apiMethod) ?: return BlockResult(false) + + val now = System.currentTimeMillis() + val bannedUntil = state.bannedUntil ?: return BlockResult(false) + + return if (bannedUntil > now) { + BlockResult( + blocked = true, + retryAfterSeconds = ((bannedUntil - now) / 1000).toInt() + ) + } else { + BlockResult(false) + } + } + + fun onLimit(identity: String, groupId: Long, apiPath: String, apiMethod: String): BlockResult { + val now = System.currentTimeMillis() + val group = config.getGroup(groupId) ?: return BlockResult(false) + val penalties = config.getPenalties(groupId).sortedBy { it.blockStep } + + val current = getPenaltyState(identity, apiPath, apiMethod) + val nextViolationCount = (current?.violationCount ?: 0) + 1 + + val level = min(nextViolationCount, penalties.size) + val penalty = penalties[level - 1] + + val bannedUntil = now + Duration.ofSeconds(penalty.blockDurationSeconds.toLong()).toMillis() + + val newState = RateLimitPenaltyState( + violationCount = nextViolationCount, + lastViolationAt = now, + bannedUntil = bannedUntil + ) + + val ttl = penalty.blockDurationSeconds + group.cooldownSeconds + + savePenaltyState(identity, apiPath, apiMethod, newState, ttl) + + return BlockResult( + blocked = true, + retryAfterSeconds = penalty.blockDurationSeconds + ) + } + + fun onAllowed(identity: String, groupId: Long, apiPath: String, apiMethod: String) { + val state = getPenaltyState(identity, apiPath, apiMethod) ?: return + val group = config.getGroup(groupId) ?: return + val now = System.currentTimeMillis() + + val lastViolation = state.lastViolationAt ?: return + val cooldownMillis = Duration.ofSeconds(group.cooldownSeconds.toLong()).toMillis() + + if (now - lastViolation >= cooldownMillis && state.violationCount > 0) { + val newState = state.copy( + violationCount = state.violationCount - 1 + ) + savePenaltyState(identity, apiPath, apiMethod, newState, group.cooldownSeconds) + } + } + + private fun getPenaltyState( + identity: String, + apiPath: String, + apiMethod: String + ): RateLimitPenaltyState? { + return redis.get(buildPenaltyStateKey(identity, apiPath, apiMethod)) + } + + private fun savePenaltyState( + identity: String, + apiPath: String, + apiMethod: String, + state: RateLimitPenaltyState, + ttlSeconds: Int + ) { + redis.put( + buildPenaltyStateKey(identity, apiPath, apiMethod), + state, + DynamicInterval(ttlSeconds, TimeUnit.SECONDS) + ) + } + + private fun buildPenaltyStateKey(identity: String, apiPath: String, apiMethod: String): String { + val key = "$identity:$apiMethod:$apiPath" + return "rl:penalty:${key.hashCode()}" + } +} \ No newline at end of file diff --git a/api/api-app/src/main/kotlin/co/nilin/opex/api/app/service/RateLimiterService.kt b/api/api-app/src/main/kotlin/co/nilin/opex/api/app/service/RateLimiterService.kt new file mode 100644 index 000000000..b39431cbd --- /dev/null +++ b/api/api-app/src/main/kotlin/co/nilin/opex/api/app/service/RateLimiterService.kt @@ -0,0 +1,53 @@ +package co.nilin.opex.api.app.service + +import co.nilin.opex.api.ports.postgres.util.RedisCacheHelper +import co.nilin.opex.common.utils.DynamicInterval +import io.github.bucket4j.Bandwidth +import io.github.bucket4j.Bucket +import io.github.bucket4j.Refill +import org.springframework.stereotype.Component +import java.time.Duration +import java.util.concurrent.TimeUnit + + +@Component +class RateLimiterService( + private val redisCacheHelper: RedisCacheHelper +) { + + private val buckets: MutableMap = mutableMapOf() + + private fun createBucket(maxRequests: Int, windowInSeconds: Long): Bucket { + val limit = Bandwidth.classic( + maxRequests.toLong(), + Refill.greedy(maxRequests.toLong(), Duration.ofSeconds(windowInSeconds)) + ) + return Bucket.builder().addLimit(limit).build() + } + + fun checkRateLimit(identity: String, maxRequests: Int, windowInSeconds: Int, apiPath: String, apiMethod : String): Boolean { + val key = "$identity:$apiMethod:$apiPath" + val redisKey = "rl:${key.hashCode()}" + + val storedTokenCount: Long? = redisCacheHelper.get(redisKey) + val bucket = buckets.computeIfAbsent(redisKey) { createBucket(maxRequests, windowInSeconds.toLong()) } + + if (storedTokenCount == null) { + bucket.reset() + redisCacheHelper.put(redisKey, maxRequests.toLong(), DynamicInterval(windowInSeconds, TimeUnit.SECONDS)) + } else { + val tokensToAdd = storedTokenCount - bucket.availableTokens + if (tokensToAdd > 0) { + bucket.addTokens(tokensToAdd) + } + } + + val allowed = bucket.tryConsume(1) + + if (allowed) { + redisCacheHelper.put(redisKey, bucket.availableTokens, DynamicInterval(windowInSeconds, TimeUnit.SECONDS)) + } + + return allowed + } +} \ No newline at end of file diff --git a/api/api-app/src/main/resources/application.yml b/api/api-app/src/main/resources/application.yml index 80a1b7cc9..8b654ad05 100644 --- a/api/api-app/src/main/resources/application.yml +++ b/api/api-app/src/main/resources/application.yml @@ -10,7 +10,19 @@ spring: url: r2dbc:postgresql://${DB_IP_PORT:localhost}/opex username: ${dbusername:opex} password: ${dbpassword:hiopex} - initialization-mode: always +# initialization-mode: always + pool: + enabled: true + initial-size: 5 + max-size: 20 + max-idle-time: 60s + validation-query: SELECT 1 + datasource: + url: jdbc:postgresql://${DB_IP_PORT:localhost}/opex + username: ${dbusername:opex} + password: ${dbpassword:hiopex} + cache: + type: redis cloud: bootstrap: enabled: true @@ -37,6 +49,10 @@ spring: prefer-ip-address: true config: import: vault://secret/${spring.application.name} + data: + redis: + port: 6379 + host: redis-cache management: endpoints: web: diff --git a/api/api-core/src/main/kotlin/co/nilin/opex/api/core/inout/RateLimitEndpoint.kt b/api/api-core/src/main/kotlin/co/nilin/opex/api/core/inout/RateLimitEndpoint.kt new file mode 100644 index 000000000..928a6c805 --- /dev/null +++ b/api/api-core/src/main/kotlin/co/nilin/opex/api/core/inout/RateLimitEndpoint.kt @@ -0,0 +1,10 @@ +package co.nilin.opex.api.core.inout + +data class RateLimitEndpoint( + val id: Long? = null, + val url: String, + val method: String, + val groupId: Long, + val priority: Int, + val enabled: Boolean = true +) \ No newline at end of file diff --git a/api/api-core/src/main/kotlin/co/nilin/opex/api/core/inout/RateLimitGroup.kt b/api/api-core/src/main/kotlin/co/nilin/opex/api/core/inout/RateLimitGroup.kt new file mode 100644 index 000000000..e9e3db23e --- /dev/null +++ b/api/api-core/src/main/kotlin/co/nilin/opex/api/core/inout/RateLimitGroup.kt @@ -0,0 +1,11 @@ +package co.nilin.opex.api.core.inout + +data class RateLimitGroup( + val id: Long? = null, + val name: String, + val requestCount: Int, + val requestWindowSeconds: Int, + val cooldownSeconds: Int, + val maxPenaltyLevel: Int, + val enabled: Boolean = true +) diff --git a/api/api-core/src/main/kotlin/co/nilin/opex/api/core/inout/RateLimitPenalty.kt b/api/api-core/src/main/kotlin/co/nilin/opex/api/core/inout/RateLimitPenalty.kt new file mode 100644 index 000000000..fb078266b --- /dev/null +++ b/api/api-core/src/main/kotlin/co/nilin/opex/api/core/inout/RateLimitPenalty.kt @@ -0,0 +1,8 @@ +package co.nilin.opex.api.core.inout + +data class RateLimitPenalty( + val id: Long? = null, + val groupId: Long, + val blockStep: Int, + val blockDurationSeconds: Int +) diff --git a/api/api-core/src/main/kotlin/co/nilin/opex/api/core/spi/RateLimitConfigService.kt b/api/api-core/src/main/kotlin/co/nilin/opex/api/core/spi/RateLimitConfigService.kt new file mode 100644 index 000000000..c8fc9fe2b --- /dev/null +++ b/api/api-core/src/main/kotlin/co/nilin/opex/api/core/spi/RateLimitConfigService.kt @@ -0,0 +1,12 @@ +package co.nilin.opex.api.core.spi + +import co.nilin.opex.api.core.inout.RateLimitEndpoint +import co.nilin.opex.api.core.inout.RateLimitGroup +import co.nilin.opex.api.core.inout.RateLimitPenalty + +interface RateLimitConfigService { + suspend fun loadConfig() + fun getGroup(groupId: Long): RateLimitGroup? + fun getPenalties(groupId: Long): List + fun getEndpoints(): List +} \ No newline at end of file diff --git a/api/api-ports/api-binance-rest/src/main/kotlin/co/nilin/opex/api/ports/binance/config/SecurityConfig.kt b/api/api-ports/api-binance-rest/src/main/kotlin/co/nilin/opex/api/ports/binance/config/SecurityConfig.kt index 972a90826..8ebdb1767 100644 --- a/api/api-ports/api-binance-rest/src/main/kotlin/co/nilin/opex/api/ports/binance/config/SecurityConfig.kt +++ b/api/api-ports/api-binance-rest/src/main/kotlin/co/nilin/opex/api/ports/binance/config/SecurityConfig.kt @@ -35,6 +35,7 @@ class SecurityConfig( it.pathMatchers("/actuator/**").permitAll() .pathMatchers("/swagger-ui/**").permitAll() .pathMatchers("/swagger-resources/**").permitAll() + .pathMatchers("/v1/rate-limit").hasAuthority("ROLE_admin") .pathMatchers("/v2/api-docs").permitAll() .pathMatchers("/v3/depth").permitAll() .pathMatchers("/v3/trades").permitAll() diff --git a/api/api-ports/api-persister-postgres/pom.xml b/api/api-ports/api-persister-postgres/pom.xml index 695c5d793..02746c921 100644 --- a/api/api-ports/api-persister-postgres/pom.xml +++ b/api/api-ports/api-persister-postgres/pom.xml @@ -67,5 +67,17 @@ io.mockk mockk + + org.springframework.boot + spring-boot-starter-data-redis + + + org.flywaydb + flyway-core + + + org.flywaydb + flyway-database-postgresql + diff --git a/api/api-ports/api-persister-postgres/src/main/kotlin/co/nilin/opex/api/ports/postgres/config/PostgresConfig.kt b/api/api-ports/api-persister-postgres/src/main/kotlin/co/nilin/opex/api/ports/postgres/config/PostgresConfig.kt index 47e7038ae..8b3551fba 100644 --- a/api/api-ports/api-persister-postgres/src/main/kotlin/co/nilin/opex/api/ports/postgres/config/PostgresConfig.kt +++ b/api/api-ports/api-persister-postgres/src/main/kotlin/co/nilin/opex/api/ports/postgres/config/PostgresConfig.kt @@ -1,24 +1,55 @@ package co.nilin.opex.api.ports.postgres.config +import org.flywaydb.core.Flyway +import org.slf4j.LoggerFactory import org.springframework.beans.factory.annotation.Value +import org.springframework.context.annotation.Bean import org.springframework.context.annotation.Configuration -import org.springframework.core.io.Resource +import org.springframework.context.annotation.Profile import org.springframework.data.r2dbc.repository.config.EnableR2dbcRepositories -import org.springframework.r2dbc.core.DatabaseClient @Configuration @EnableR2dbcRepositories(basePackages = ["co.nilin.opex"]) -class PostgresConfig( - db: DatabaseClient, - @Value("classpath:schema.sql") private val schemaResource: Resource -) { +@Profile("!test") +class PostgresConfig { + private val logger = LoggerFactory.getLogger(PostgresConfig::class.java) + init { - val schemaReader = schemaResource.inputStream.reader() - val schema = schemaReader.readText().trim() - schemaReader.close() - val initDb = db.sql { schema } - initDb // initialize the database - .then() - .subscribe() // execute + logger.info("🔍 PostgresConfig loaded") + } + + @Bean + fun flywayConfig( + @Value("\${spring.datasource.url}") url: String, + @Value("\${spring.datasource.username}") user: String, + @Value("\${spring.datasource.password}") password: String + ): Flyway? { + val flyway: Flyway = Flyway.configure() + .dataSource(url, user, password) + .locations("classpath:db/migration") + .baselineOnMigrate(true) + .baselineVersion("1") + .load() + try { + retry(6, 5000) { + flyway.migrate() + } + } catch (e: Exception) { + logger.error("❌ Flyway migration failed", e) + } + return flyway + } + + fun retry(times: Int, delayMs: Long, block: () -> Unit) { + var attempt = 0 + while (true) { + try { + block() + return + } catch (e: Exception) { + if (++attempt >= times) throw e + Thread.sleep(delayMs) + } + } } -} +} \ No newline at end of file diff --git a/api/api-ports/api-persister-postgres/src/main/kotlin/co/nilin/opex/api/ports/postgres/dao/RateLimitEndpointRepository.kt b/api/api-ports/api-persister-postgres/src/main/kotlin/co/nilin/opex/api/ports/postgres/dao/RateLimitEndpointRepository.kt new file mode 100644 index 000000000..aca1a7fc2 --- /dev/null +++ b/api/api-ports/api-persister-postgres/src/main/kotlin/co/nilin/opex/api/ports/postgres/dao/RateLimitEndpointRepository.kt @@ -0,0 +1,12 @@ +package co.nilin.opex.api.ports.postgres.dao + +import co.nilin.opex.api.ports.postgres.model.RateLimitEndpointModel +import org.springframework.data.repository.reactive.ReactiveCrudRepository +import org.springframework.stereotype.Repository +import reactor.core.publisher.Flux + +@Repository +interface RateLimitEndpointRepository : ReactiveCrudRepository { + fun findByEnabledTrue(): Flux + +} \ No newline at end of file diff --git a/api/api-ports/api-persister-postgres/src/main/kotlin/co/nilin/opex/api/ports/postgres/dao/RateLimitGroupRepository.kt b/api/api-ports/api-persister-postgres/src/main/kotlin/co/nilin/opex/api/ports/postgres/dao/RateLimitGroupRepository.kt new file mode 100644 index 000000000..3b7dfcc76 --- /dev/null +++ b/api/api-ports/api-persister-postgres/src/main/kotlin/co/nilin/opex/api/ports/postgres/dao/RateLimitGroupRepository.kt @@ -0,0 +1,12 @@ +package co.nilin.opex.api.ports.postgres.dao + +import co.nilin.opex.api.ports.postgres.model.RateLimitGroupModel +import org.springframework.data.repository.reactive.ReactiveCrudRepository +import org.springframework.stereotype.Repository +import reactor.core.publisher.Flux + +@Repository +interface RateLimitGroupRepository : ReactiveCrudRepository { + fun findByEnabledTrue(): Flux + +} diff --git a/api/api-ports/api-persister-postgres/src/main/kotlin/co/nilin/opex/api/ports/postgres/dao/RateLimitPenaltyRepository.kt b/api/api-ports/api-persister-postgres/src/main/kotlin/co/nilin/opex/api/ports/postgres/dao/RateLimitPenaltyRepository.kt new file mode 100644 index 000000000..a84d8c95a --- /dev/null +++ b/api/api-ports/api-persister-postgres/src/main/kotlin/co/nilin/opex/api/ports/postgres/dao/RateLimitPenaltyRepository.kt @@ -0,0 +1,11 @@ +package co.nilin.opex.api.ports.postgres.dao + +import co.nilin.opex.api.ports.postgres.model.RateLimitPenaltyModel +import org.springframework.data.repository.reactive.ReactiveCrudRepository +import org.springframework.stereotype.Repository +import reactor.core.publisher.Flux + +@Repository +interface RateLimitPenaltyRepository : ReactiveCrudRepository { + fun findByGroupIdOrderByBlockStepAsc(groupId: Long): Flux +} diff --git a/api/api-ports/api-persister-postgres/src/main/kotlin/co/nilin/opex/api/ports/postgres/impl/RateLimitConfigImpl.kt b/api/api-ports/api-persister-postgres/src/main/kotlin/co/nilin/opex/api/ports/postgres/impl/RateLimitConfigImpl.kt new file mode 100644 index 000000000..4f21a7139 --- /dev/null +++ b/api/api-ports/api-persister-postgres/src/main/kotlin/co/nilin/opex/api/ports/postgres/impl/RateLimitConfigImpl.kt @@ -0,0 +1,70 @@ +package co.nilin.opex.api.ports.postgres.impl + +import co.nilin.opex.api.core.inout.RateLimitEndpoint +import co.nilin.opex.api.core.inout.RateLimitGroup +import co.nilin.opex.api.core.inout.RateLimitPenalty +import co.nilin.opex.api.core.spi.RateLimitConfigService +import co.nilin.opex.api.ports.postgres.dao.RateLimitEndpointRepository +import co.nilin.opex.api.ports.postgres.dao.RateLimitGroupRepository +import co.nilin.opex.api.ports.postgres.dao.RateLimitPenaltyRepository +import co.nilin.opex.api.ports.postgres.model.RateLimitEndpointModel +import co.nilin.opex.api.ports.postgres.model.RateLimitGroupModel +import co.nilin.opex.api.ports.postgres.model.RateLimitPenaltyModel +import kotlinx.coroutines.reactive.awaitFirstOrElse +import org.springframework.stereotype.Component + +@Component +class RateLimitConfigImpl( + private val groupRepo: RateLimitGroupRepository, + private val penaltyRepo: RateLimitPenaltyRepository, + private val endpointRepo: RateLimitEndpointRepository +) : RateLimitConfigService { + + private val groupCache = mutableMapOf() + private val penaltyCache = mutableMapOf>() + private val endpointCache = mutableListOf() + + override suspend fun loadConfig() { + val groups = groupRepo.findByEnabledTrue().collectList().awaitFirstOrElse { emptyList() } + groupCache.clear() + groups.forEach { groupCache[it.id!!] = it.toRateLimitGroup() } + + penaltyCache.clear() + groups.forEach { group -> + val penalties = penaltyRepo.findByGroupIdOrderByBlockStepAsc(group.id!!).collectList() + .awaitFirstOrElse { emptyList() }.map { it.toRateLimitPenalty() } + penaltyCache[group.id] = penalties + } + + endpointCache.clear() + endpointCache.addAll(endpointRepo.findByEnabledTrue().collectList().awaitFirstOrElse { emptyList() } + .map { it.toRateLimitEndpoint() }) + } + + override fun getGroup(groupId: Long): RateLimitGroup? = groupCache[groupId] + override fun getPenalties(groupId: Long): List = penaltyCache[groupId] ?: emptyList() + override fun getEndpoints(): List = endpointCache + + + private fun RateLimitGroupModel.toRateLimitGroup(): RateLimitGroup = + RateLimitGroup(id, name, requestCount, requestWindowSeconds, cooldownSeconds, maxPenaltyLevel, enabled) + + + private fun RateLimitPenaltyModel.toRateLimitPenalty(): RateLimitPenalty = + RateLimitPenalty( + id, + groupId, + blockStep, + blockDurationSeconds + ) + + private fun RateLimitEndpointModel.toRateLimitEndpoint(): RateLimitEndpoint = + RateLimitEndpoint( + id, + url, + method, + groupId, + priority, + enabled + ) +} \ No newline at end of file diff --git a/api/api-ports/api-persister-postgres/src/main/kotlin/co/nilin/opex/api/ports/postgres/model/RateLimitEndpointModel.kt b/api/api-ports/api-persister-postgres/src/main/kotlin/co/nilin/opex/api/ports/postgres/model/RateLimitEndpointModel.kt new file mode 100644 index 000000000..f8e0368b1 --- /dev/null +++ b/api/api-ports/api-persister-postgres/src/main/kotlin/co/nilin/opex/api/ports/postgres/model/RateLimitEndpointModel.kt @@ -0,0 +1,15 @@ +package co.nilin.opex.api.ports.postgres.model + +import org.springframework.data.annotation.Id +import org.springframework.data.relational.core.mapping.Table + +@Table(name = "rate_limit_endpoint") +data class RateLimitEndpointModel( + @Id + val id: Long? = null, + val url: String, + val method: String, + val groupId: Long, + val priority: Int, + val enabled: Boolean = true +) \ No newline at end of file diff --git a/api/api-ports/api-persister-postgres/src/main/kotlin/co/nilin/opex/api/ports/postgres/model/RateLimitGroupModel.kt b/api/api-ports/api-persister-postgres/src/main/kotlin/co/nilin/opex/api/ports/postgres/model/RateLimitGroupModel.kt new file mode 100644 index 000000000..506687988 --- /dev/null +++ b/api/api-ports/api-persister-postgres/src/main/kotlin/co/nilin/opex/api/ports/postgres/model/RateLimitGroupModel.kt @@ -0,0 +1,16 @@ +package co.nilin.opex.api.ports.postgres.model + +import org.springframework.data.annotation.Id +import org.springframework.data.relational.core.mapping.Table + +@Table(name = "rate_limit_group") +data class RateLimitGroupModel( + @Id + val id: Long? = null, + val name: String, + val requestCount: Int, + val requestWindowSeconds: Int, + val cooldownSeconds: Int, + val maxPenaltyLevel: Int, + val enabled: Boolean = true +) diff --git a/api/api-ports/api-persister-postgres/src/main/kotlin/co/nilin/opex/api/ports/postgres/model/RateLimitPenaltyModel.kt b/api/api-ports/api-persister-postgres/src/main/kotlin/co/nilin/opex/api/ports/postgres/model/RateLimitPenaltyModel.kt new file mode 100644 index 000000000..82cd01fbc --- /dev/null +++ b/api/api-ports/api-persister-postgres/src/main/kotlin/co/nilin/opex/api/ports/postgres/model/RateLimitPenaltyModel.kt @@ -0,0 +1,13 @@ +package co.nilin.opex.api.ports.postgres.model + +import org.springframework.data.annotation.Id +import org.springframework.data.relational.core.mapping.Table + +@Table(name = "rate_limit_penalty") +data class RateLimitPenaltyModel( + @Id + val id: Long? = null, + val groupId: Long, + val blockStep: Int, + val blockDurationSeconds: Int +) diff --git a/api/api-ports/api-persister-postgres/src/main/kotlin/co/nilin/opex/api/ports/postgres/util/RedisCacheHelper.kt b/api/api-ports/api-persister-postgres/src/main/kotlin/co/nilin/opex/api/ports/postgres/util/RedisCacheHelper.kt new file mode 100644 index 000000000..cde37154e --- /dev/null +++ b/api/api-ports/api-persister-postgres/src/main/kotlin/co/nilin/opex/api/ports/postgres/util/RedisCacheHelper.kt @@ -0,0 +1,104 @@ +package co.nilin.opex.api.ports.postgres.util + +import co.nilin.opex.common.utils.DynamicInterval +import co.nilin.opex.common.utils.LoggerDelegate +import org.springframework.data.redis.core.RedisTemplate +import org.springframework.stereotype.Component + +@Component +class RedisCacheHelper(private val redisTemplate: RedisTemplate) { + + private val logger by LoggerDelegate() + + private val valueOps = redisTemplate.opsForValue() + private val listOps = redisTemplate.opsForList() + + fun put(key: String, value: Any?, expireAt: DynamicInterval? = null) { + value ?: return + try { + valueOps.set(key, value) + expireAt?.let { redisTemplate.expireAt(key, it.dateInFuture()) } + } catch (e: Exception) { + logger.warn("Unable to put cache with key '$key'") + } + } + + fun putList(key: String, values: List, expireAt: DynamicInterval? = null) { + try { + values.forEach { listOps.rightPush(key, it) } + expireAt?.let { redisTemplate.expireAt(key, it.dateInFuture()) } + } catch (e: Exception) { + logger.warn("Unable to put list cache with key '$key'") + } + } + + fun putListItem(key: String, value: Any, rightPush: Boolean = true) { + try { + if (rightPush) + listOps.rightPush(key, value) + else + listOps.leftPush(key, value) + } catch (e: Exception) { + logger.warn("Unable to put list item cache with key '$key'") + } + } + + @Suppress("UNCHECKED_CAST") + fun get(key: String): T? { + return try { + valueOps.get(key) as T + } catch (e: Exception) { + logger.warn("Unable to get cache value with key '$key'") + null + } + } + + @Suppress("UNCHECKED_CAST") + fun getList(key: String): Collection? { + return try { + listOps.range(key, 0, -1) as Collection? + } catch (e: Exception) { + logger.warn("Unable to get cache list with key '$key'") + null + } + } + + suspend fun getOrElse(key: String, expireAt: DynamicInterval? = null, action: suspend () -> T): T { + val cacheValue = get(key) + return if (cacheValue != null) + cacheValue + else { + val value = action() + if (value != null) { + put(key, value) + expireAt?.let { setExpiration(key, it) } + } + return value + } + } + + fun evict(key: String) { + try { + redisTemplate.delete(key) + } catch (e: Exception) { + logger.warn("Unable to evict cache with key '$key'") + } + } + + fun setExpiration(key: String, interval: DynamicInterval) { + try { + redisTemplate.expireAt(key, interval.dateInFuture()) + } catch (e: Exception) { + logger.warn("Unable to set expiration date for cache with key '$key'") + } + } + + fun hasKey(key: String): Boolean { + return try { + redisTemplate.hasKey(key) + } catch (e: Exception) { + logger.warn("Unable fetch info of cache with key '$key'") + false + } + } +} \ No newline at end of file diff --git a/api/api-ports/api-persister-postgres/src/main/resources/db/migration/V1__init_schema.sql b/api/api-ports/api-persister-postgres/src/main/resources/db/migration/V1__init_schema.sql new file mode 100644 index 000000000..d762a3f5b --- /dev/null +++ b/api/api-ports/api-persister-postgres/src/main/resources/db/migration/V1__init_schema.sql @@ -0,0 +1 @@ +CREATE TABLE IF NOT EXISTS test(id SERIAL PRIMARY KEY); \ No newline at end of file diff --git a/api/api-ports/api-persister-postgres/src/main/resources/db/migration/V2__create_tables.sql b/api/api-ports/api-persister-postgres/src/main/resources/db/migration/V2__create_tables.sql new file mode 100644 index 000000000..9fac4f934 --- /dev/null +++ b/api/api-ports/api-persister-postgres/src/main/resources/db/migration/V2__create_tables.sql @@ -0,0 +1,53 @@ +CREATE TABLE IF NOT EXISTS symbol_maps +( + id SERIAL PRIMARY KEY, + symbol VARCHAR(72) NOT NULL, + alias_key VARCHAR(72) NOT NULL, + alias VARCHAR(72) NOT NULL, + UNIQUE (symbol, alias_key, alias) +); + +CREATE TABLE IF NOT EXISTS api_key +( + id SERIAL PRIMARY KEY, + user_id VARCHAR(36) NOT NULL, + label VARCHAR(200) NOT NULL, + access_token TEXT NOT NULL, + refresh_token TEXT NOT NULL, + expiration_time TIMESTAMP, + allowed_ips TEXT, + token_expiration_time TIMESTAMP NOT NULL, + key VARCHAR(36) NOT NULL UNIQUE, + is_enabled BOOLEAN NOT NULL DEFAULT true, + is_expired BOOLEAN NOT NULL DEFAULT false +); +CREATE TABLE IF NOT EXISTS rate_limit_group +( + id BIGSERIAL PRIMARY KEY, + name VARCHAR(50) NOT NULL, + request_count INT NOT NULL, + request_window_seconds INT NOT NULL, + cooldown_seconds INT NOT NULL, + max_penalty_level INT NOT NULL, + enabled BOOLEAN NOT NULL +); + +CREATE TABLE IF NOT EXISTS rate_limit_penalty +( + id BIGSERIAL PRIMARY KEY, + group_id BIGINT NOT NULL REFERENCES rate_limit_group (id), + block_step INT NOT NULL, + block_duration_seconds INT NOT NULL, + unique (group_id, block_step) +); + +CREATE TABLE IF NOT EXISTS rate_limit_endpoint +( + id BIGSERIAL PRIMARY KEY, + url VARCHAR(255) NOT NULL, + method VARCHAR(10) NOT NULL, + group_id BIGINT NOT NULL REFERENCES rate_limit_group (id), + priority INT NOT NULL, + enabled BOOLEAN NOT NULL, + unique (url, method) +); diff --git a/api/api-ports/api-persister-postgres/src/main/resources/db/migration/V3__insert_rate_limit_data.sql b/api/api-ports/api-persister-postgres/src/main/resources/db/migration/V3__insert_rate_limit_data.sql new file mode 100644 index 000000000..c255b5e81 --- /dev/null +++ b/api/api-ports/api-persister-postgres/src/main/resources/db/migration/V3__insert_rate_limit_data.sql @@ -0,0 +1,92 @@ +-- ________________ Admin Services ________________ +INSERT INTO rate_limit_group (id, name, request_count, request_window_seconds, cooldown_seconds, max_penalty_level, + enabled) +VALUES (1, 'ADMIN', 15, 30, 120, 1, true); + +INSERT INTO rate_limit_penalty (group_id, block_step, block_duration_seconds) +VALUES (1, 1, 60); + +INSERT INTO rate_limit_endpoint (url, method, group_id, enabled, priority) +VALUES ('/opex/v1/admin/**', 'GET', 1, true, 1000); +INSERT INTO rate_limit_endpoint (url, method, group_id, enabled, priority) +VALUES ('/opex/v1/admin/**', 'POST', 1, true, 1000); +INSERT INTO rate_limit_endpoint (url, method, group_id, enabled, priority) +VALUES ('/opex/v1/admin/**', 'PUT', 1, true, 1000); +INSERT INTO rate_limit_endpoint (url, method, group_id, enabled, priority) +VALUES ('/opex/v1/admin/**', 'DELETE', 1, true, 1000); +-- __________________________________________________________________________________________________________________________ + +INSERT INTO rate_limit_group (id, name, request_count, request_window_seconds, cooldown_seconds, max_penalty_level, + enabled) +VALUES (2, 'HIGH_IMPACT', 5, 60, 600, 3, true); + +INSERT INTO rate_limit_penalty (group_id, block_step, block_duration_seconds) +VALUES (2, 3, 300); +INSERT INTO rate_limit_penalty (group_id, block_step, block_duration_seconds) +VALUES (2, 2, 180); +INSERT INTO rate_limit_penalty (group_id, block_step, block_duration_seconds) +VALUES (2, 1, 120); + + +INSERT INTO rate_limit_group (id, name, request_count, request_window_seconds, cooldown_seconds, max_penalty_level, + enabled) +VALUES (3, 'LOW_IMPACT', 10, 60, 300, 3, true); + +INSERT INTO rate_limit_penalty (group_id, block_step, block_duration_seconds) +VALUES (3, 3, 180); +INSERT INTO rate_limit_penalty (group_id, block_step, block_duration_seconds) +VALUES (3, 2, 120); +INSERT INTO rate_limit_penalty (group_id, block_step, block_duration_seconds) +VALUES (3, 1, 60); + +INSERT INTO rate_limit_group (id, name, request_count, request_window_seconds, cooldown_seconds, max_penalty_level, + enabled) +VALUES (4, 'BOT', 100, 60, 120, 1, true); + +INSERT INTO rate_limit_penalty (group_id, block_step, block_duration_seconds) +VALUES (4, 1, 60); + + +-- WithdrawController +INSERT INTO rate_limit_endpoint (url, method, group_id, enabled, priority) +VALUES ('/opex/v1/withdraw/**', 'POST', 2, true, 1); +INSERT INTO rate_limit_endpoint (url, method, group_id, enabled, priority) +VALUES ('/opex/v1/withdraw/**', 'PUT', 3, true, 1); +INSERT INTO rate_limit_endpoint (url, method, group_id, enabled, priority) +VALUES ('/opex/v1/withdraw/**', 'GET', 3, true, 1); + +-- WalletController +INSERT INTO rate_limit_endpoint (url, method, group_id, enabled, priority) +VALUES ('/opex/v1/wallet/**', 'GET', 3, true, 1); +INSERT INTO rate_limit_endpoint (url, method, group_id, enabled, priority) +VALUES ('/opex/v1/wallet/deposit/address', 'GET', 2, true, 2); + +-- VoucherController +INSERT INTO rate_limit_endpoint (url, method, group_id, enabled, priority) +VALUES ('/opex/v1/voucher/**', 'PUT', 2, true, 1); + +-- UserHistoryController && UserDataController +INSERT INTO rate_limit_endpoint (url, method, group_id, enabled, priority) +VALUES ('/opex/v1/user/**', 'GET', 3, true, 1); +INSERT INTO rate_limit_endpoint (url, method, group_id, enabled, priority) +VALUES ('/opex/v1/user/**', 'POST', 3, true, 1); + +-- OrderController +INSERT INTO rate_limit_endpoint (url, method, group_id, enabled, priority) +VALUES ('/opex/v1/order/**', 'GET', 3, true, 1); +INSERT INTO rate_limit_endpoint (url, method, group_id, enabled, priority) +VALUES ('/opex/v1/order/**', 'PUT', 3, true, 1); +INSERT INTO rate_limit_endpoint (url, method, group_id, enabled, priority) +VALUES ('/opex/v1/order/**', 'POST', 3, true, 1); + +-- DepositController +INSERT INTO rate_limit_endpoint (url, method, group_id, enabled, priority) +VALUES ('/opex/v1/deposit/**', 'POST', 3, true, 1); + +-- RateController +INSERT INTO rate_limit_endpoint (url, method, group_id, enabled, priority) +VALUES ('/opex/v1/otc/**', 'POST', 1, true, 1); +INSERT INTO rate_limit_endpoint (url, method, group_id, enabled, priority) +VALUES ('/opex/v1/otc/**', 'PUT', 4, true, 1); +INSERT INTO rate_limit_endpoint (url, method, group_id, enabled, priority) +VALUES ('/opex/v1/otc/**', 'DELETE', 1, true, 1); \ No newline at end of file diff --git a/api/api-ports/api-persister-postgres/src/main/resources/schema.sql b/api/api-ports/api-persister-postgres/src/main/resources/schema.sql deleted file mode 100644 index e1a77a3bc..000000000 --- a/api/api-ports/api-persister-postgres/src/main/resources/schema.sql +++ /dev/null @@ -1,23 +0,0 @@ -CREATE TABLE IF NOT EXISTS symbol_maps -( - id SERIAL PRIMARY KEY, - symbol VARCHAR(72) NOT NULL, - alias_key VARCHAR(72) NOT NULL, - alias VARCHAR(72) NOT NULL, - UNIQUE (symbol, alias_key, alias) -); -DROP TABLE IF EXISTS api_key; - -CREATE TABLE IF NOT EXISTS api_key_registry -( - api_key_id VARCHAR(128) PRIMARY KEY, - label VARCHAR(200), - encrypted_secret TEXT NOT NULL, - enabled BOOLEAN NOT NULL DEFAULT TRUE, - allowed_ips TEXT, - allowed_endpoints TEXT, - keycloak_user_id VARCHAR(128), - keycloak_username VARCHAR(256), - created_at TIMESTAMP NOT NULL, - updated_at TIMESTAMP NOT NULL -); diff --git a/docker-compose.yml b/docker-compose.yml index d45da092e..fb352af93 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -220,7 +220,7 @@ services: postgres-keycloak: <<: *postgres-db volumes: - - keycloak-data-new:/var/lib/postgresql/data/ + - keycloak-data:/var/lib/postgresql/data/ postgres-wallet: <<: *postgres-db volumes: @@ -622,7 +622,7 @@ volumes: accountant-data: eventlog-data: auth-data: - keycloak-data-new: + keycloak-data: wallet-data: market-data: api-data: diff --git a/wallet/wallet-app/src/main/kotlin/co/nilin/opex/wallet/app/controller/PaymentGatewayController.kt b/wallet/wallet-app/src/main/kotlin/co/nilin/opex/wallet/app/controller/PaymentGatewayController.kt index 737c40e13..1e154a587 100644 --- a/wallet/wallet-app/src/main/kotlin/co/nilin/opex/wallet/app/controller/PaymentGatewayController.kt +++ b/wallet/wallet-app/src/main/kotlin/co/nilin/opex/wallet/app/controller/PaymentGatewayController.kt @@ -41,7 +41,7 @@ class PaymentGatewayController( val receiverWalletType = WalletType.MAIN val currency = - currencyService.fetchCurrency(FetchCurrency(symbol = request.currency.name)) + currencyService.fetchCurrency(FetchCurrency(symbol = request.currency)) ?: throw OpexError.CurrencyNotFound.exception() val sourceOwner = walletOwnerManager.findWalletOwner(walletOwnerManager.systemUuid) ?: throw OpexError.WalletOwnerNotFound.exception() @@ -82,7 +82,9 @@ class PaymentGatewayController( depositType = DepositType.OFF_CHAIN, network = null, attachment = null, - transferMethod = if (request.isIPG == true) TransferMethod.IPG else TransferMethod.MPG + transferMethod = if (request.transferMethod == TransferMethod.REWARD) TransferMethod.REWARD else { + if (request.isIPG == true) TransferMethod.IPG else TransferMethod.MPG + } ) traceDepositService.saveDepositInNewTransaction(depositCommand) diff --git a/wallet/wallet-app/src/main/kotlin/co/nilin/opex/wallet/app/dto/PaymentCurrency.kt b/wallet/wallet-app/src/main/kotlin/co/nilin/opex/wallet/app/dto/PaymentCurrency.kt deleted file mode 100644 index 4b02a3a40..000000000 --- a/wallet/wallet-app/src/main/kotlin/co/nilin/opex/wallet/app/dto/PaymentCurrency.kt +++ /dev/null @@ -1,5 +0,0 @@ -package co.nilin.opex.wallet.app.dto - -enum class PaymentCurrency { - IRR, IRT -} \ No newline at end of file diff --git a/wallet/wallet-app/src/main/kotlin/co/nilin/opex/wallet/app/dto/PaymentDepositRequest.kt b/wallet/wallet-app/src/main/kotlin/co/nilin/opex/wallet/app/dto/PaymentDepositRequest.kt index 304bc5fc7..20f4a1922 100644 --- a/wallet/wallet-app/src/main/kotlin/co/nilin/opex/wallet/app/dto/PaymentDepositRequest.kt +++ b/wallet/wallet-app/src/main/kotlin/co/nilin/opex/wallet/app/dto/PaymentDepositRequest.kt @@ -1,12 +1,14 @@ package co.nilin.opex.wallet.app.dto +import co.nilin.opex.wallet.core.inout.TransferMethod import java.math.BigDecimal data class PaymentDepositRequest( val userId: String, // user uuid val amount: BigDecimal, - val currency: PaymentCurrency, + val currency: String, val reference: String, val description: String?, - val isIPG: Boolean? = true + val isIPG: Boolean? = true, + val transferMethod: TransferMethod? = null ) \ No newline at end of file diff --git a/wallet/wallet-core/src/main/kotlin/co/nilin/opex/wallet/core/inout/TransferMethod.kt b/wallet/wallet-core/src/main/kotlin/co/nilin/opex/wallet/core/inout/TransferMethod.kt index b34d705d2..30fceb6d2 100644 --- a/wallet/wallet-core/src/main/kotlin/co/nilin/opex/wallet/core/inout/TransferMethod.kt +++ b/wallet/wallet-core/src/main/kotlin/co/nilin/opex/wallet/core/inout/TransferMethod.kt @@ -1,5 +1,5 @@ package co.nilin.opex.wallet.core.inout enum class TransferMethod { - CARD, SHEBA, IPG, EXCHANGE , MANUALLY , VOUCHER, MPG + CARD, SHEBA, IPG, EXCHANGE , MANUALLY , VOUCHER, MPG , REWARD } From cdb89d9117e776adb9fd555df7194180c9b78706 Mon Sep 17 00:00:00 2001 From: Amir Rajabi Date: Sun, 4 Jan 2026 12:22:47 +0330 Subject: [PATCH 2/2] Update V2__create_tables.sql --- .../db/migration/V2__create_tables.sql | 25 ++++++++++--------- 1 file changed, 13 insertions(+), 12 deletions(-) diff --git a/api/api-ports/api-persister-postgres/src/main/resources/db/migration/V2__create_tables.sql b/api/api-ports/api-persister-postgres/src/main/resources/db/migration/V2__create_tables.sql index 9fac4f934..0c954b9fe 100644 --- a/api/api-ports/api-persister-postgres/src/main/resources/db/migration/V2__create_tables.sql +++ b/api/api-ports/api-persister-postgres/src/main/resources/db/migration/V2__create_tables.sql @@ -7,19 +7,20 @@ CREATE TABLE IF NOT EXISTS symbol_maps UNIQUE (symbol, alias_key, alias) ); -CREATE TABLE IF NOT EXISTS api_key +DROP TABLE IF EXISTS api_key; + +CREATE TABLE IF NOT EXISTS api_key_registry ( - id SERIAL PRIMARY KEY, - user_id VARCHAR(36) NOT NULL, - label VARCHAR(200) NOT NULL, - access_token TEXT NOT NULL, - refresh_token TEXT NOT NULL, - expiration_time TIMESTAMP, - allowed_ips TEXT, - token_expiration_time TIMESTAMP NOT NULL, - key VARCHAR(36) NOT NULL UNIQUE, - is_enabled BOOLEAN NOT NULL DEFAULT true, - is_expired BOOLEAN NOT NULL DEFAULT false + api_key_id VARCHAR(128) PRIMARY KEY, + label VARCHAR(200), + encrypted_secret TEXT NOT NULL, + enabled BOOLEAN NOT NULL DEFAULT TRUE, + allowed_ips TEXT, + allowed_endpoints TEXT, + keycloak_user_id VARCHAR(128), + keycloak_username VARCHAR(256), + created_at TIMESTAMP NOT NULL, + updated_at TIMESTAMP NOT NULL ); CREATE TABLE IF NOT EXISTS rate_limit_group (