Skip to content

Commit

Permalink
Merge pull request #4 from DDD-Community/POLABO-38
Browse files Browse the repository at this point in the history
feat: 하루 요청 100개 제한
  • Loading branch information
dldmsql authored Jul 9, 2024
2 parents 9382244 + 0153962 commit b499015
Show file tree
Hide file tree
Showing 18 changed files with 474 additions and 11 deletions.
20 changes: 17 additions & 3 deletions .github/workflows/cd-dev.yml
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,21 @@ jobs:
- name: Build, tag, and push image to Amazon ECR
run: |
docker build --build-arg PASSWORD=$PASSWORD -t polabo:${{steps.current-time.outputs.formattedTime}} .
docker tag polabo:${{steps.current-time.outputs.formattedTime}} 058264417437.dkr.ecr.ap-northeast-2.amazonaws.com/polabo:${{steps.current-time.outputs.formattedTime}}
docker push 058264417437.dkr.ecr.ap-northeast-2.amazonaws.com/polabo:${{steps.current-time.outputs.formattedTime}}
docker tag polabo:${{steps.current-time.outputs.formattedTime}} ${{ secrets.ECR_REGISTRY }}/polabo:${{steps.current-time.outputs.formattedTime}}
docker push ${{ secrets.ECR_REGISTRY }}/polabo:${{steps.current-time.outputs.formattedTime}}
env:
PASSWORD: ${{ secrets.JASYPT_ENCRYPTOR_PASSWORD }}
PASSWORD: ${{ secrets.JASYPT_ENCRYPTOR_PASSWORD }}

- name: Deploy
uses: appleboy/ssh-action@master
with:
host: ${{ secrets.EC2_HOST }}
username: ${{ secrets.EC2_USERNAME }}
key: ${{ secrets.EC2_KEY }}
script: |
aws ecr get-login-password --region ap-northeast-2 | docker login --username AWS --password-stdin ${{ steps.login-ecr.outputs.registry }}/${{ secrets.ECR_REPOSITORY }}
docker stop polabo-dev
docker rm polabo-dev
docker pull ${{ secrets.ECR_REGISTRY }}/polabo:${{steps.current-time.outputs.formattedTime}}
docker run -d -v /etc/localtime:/etc/localtime:ro -v /usr/share/zoneinfo/Asia/Seoul:/etc/timezone:ro -e ENVIRONMENT_VALUE=-Dspring.profiles.active=dev --name polabo-dev -p 8080:8080 --restart=always --network host ${{ secrets.ECR_REGISTRY }}/polabo:${{steps.current-time.outputs.formattedTime}}
3 changes: 2 additions & 1 deletion build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -89,7 +89,8 @@ dependencies {
implementation("software.amazon.awssdk:s3:2.20.68")
implementation("com.amazonaws:aws-java-sdk-s3:1.12.561")
implementation("com.github.ulisesbocchio:jasypt-spring-boot-starter:3.0.5")

implementation("org.springframework.boot:spring-boot-starter-security")
implementation("io.netty:netty-resolver-dns-native-macos:4.1.68.Final:osx-aarch_64")
}

tasks.withType<KotlinCompile> {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,9 @@ package com.ddd.sonnypolabobe
import org.slf4j.LoggerFactory
import org.springframework.boot.autoconfigure.SpringBootApplication
import org.springframework.boot.runApplication
import org.springframework.scheduling.annotation.EnableScheduling

@EnableScheduling
@SpringBootApplication
class SonnyPolaboBeApplication
inline fun <reified T> T.logger() = LoggerFactory.getLogger(T::class.java)!!
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package com.ddd.sonnypolabobe.domain.board.repository

import com.ddd.sonnypolabobe.domain.board.controller.dto.BoardCreateRequest
import com.ddd.sonnypolabobe.domain.board.controller.dto.BoardGetResponse
import com.ddd.sonnypolabobe.global.util.DateConverter
import com.ddd.sonnypolabobe.global.util.UuidConverter
import com.ddd.sonnypolabobe.global.util.UuidGenerator
import com.ddd.sonnypolabobe.jooq.polabo.tables.Board
Expand All @@ -22,7 +23,7 @@ class BoardJooqRepositoryImpl(
val insertValue = jBoard.newRecord().apply {
this.id = id
this.title = request.title
this.createdAt = LocalDateTime.now()
this.createdAt = DateConverter.convertToKst(LocalDateTime.now())
this.yn = 1
this.activeyn = 1
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package com.ddd.sonnypolabobe.domain.polaroid.repository
import com.ddd.sonnypolabobe.domain.polaroid.controller.dto.PolaroidCreateRequest
import com.ddd.sonnypolabobe.global.exception.ApplicationException
import com.ddd.sonnypolabobe.global.exception.CustomErrorCode
import com.ddd.sonnypolabobe.global.util.DateConverter
import com.ddd.sonnypolabobe.jooq.polabo.tables.Polaroid
import com.ddd.sonnypolabobe.jooq.polabo.tables.records.PolaroidRecord
import org.jooq.DSLContext
Expand All @@ -17,7 +18,7 @@ class PolaroidJooqRepositoryImpl(private val dslContext: DSLContext) : PolaroidJ
this.boardId = boardId
this.imageKey = request.imageKey
this.oneLineMessage = request.oneLineMessage
this.createdAt = LocalDateTime.now()
this.createdAt = DateConverter.convertToKst(LocalDateTime.now())
this.yn = 1
this.activeyn = 1
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
package com.ddd.sonnypolabobe.global.config

import org.springframework.context.annotation.Bean
import org.springframework.context.annotation.Configuration
import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity
import org.springframework.security.config.annotation.web.builders.HttpSecurity
import org.springframework.security.web.SecurityFilterChain
import org.springframework.security.web.util.matcher.AntPathRequestMatcher
import org.springframework.security.web.util.matcher.RequestMatcher
import org.springframework.web.cors.CorsConfiguration
import org.springframework.web.cors.UrlBasedCorsConfigurationSource

@Configuration
@EnableMethodSecurity
class SecurityConfig() {

@Bean
fun filterChain(http: HttpSecurity): SecurityFilterChain {
return http
.cors {
it.configurationSource(corsConfigurationSource())
}
.csrf{
it.disable()
}
.httpBasic {
it.disable()
}
.formLogin { it.disable() }
.authorizeHttpRequests {
it.anyRequest().permitAll()
}
.build()
}

fun corsConfigurationSource(): UrlBasedCorsConfigurationSource {
val configuration = CorsConfiguration()
configuration.allowedOrigins = listOf("http://localhost:3000") // Allow all origins
configuration.allowedMethods =
listOf("GET", "POST", "PUT", "DELETE", "OPTIONS") // Allow common methods
configuration.allowedHeaders = listOf("*") // Allow all headers
configuration.allowCredentials = true // Allow credentials
val source = UrlBasedCorsConfigurationSource()
source.registerCorsConfiguration(
"/**",
configuration
) // Apply configuration to all endpoints
return source
}
}
16 changes: 16 additions & 0 deletions src/main/kotlin/com/ddd/sonnypolabobe/global/config/WebConfig.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
package com.ddd.sonnypolabobe.global.config

import com.ddd.sonnypolabobe.global.security.RateLimitingInterceptor
import org.springframework.context.annotation.Configuration
import org.springframework.web.servlet.config.annotation.InterceptorRegistry
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer

@Configuration
class WebConfig(private val rateLimitingInterceptor: RateLimitingInterceptor) : WebMvcConfigurer {

override fun addInterceptors(registry: InterceptorRegistry) {
registry.addInterceptor(rateLimitingInterceptor)
.addPathPatterns("/api/v1/boards")
}

}
Original file line number Diff line number Diff line change
@@ -1,30 +1,46 @@
package com.ddd.sonnypolabobe.global.exception

import com.ddd.sonnypolabobe.global.response.ApplicationResponse
import com.ddd.sonnypolabobe.global.util.DiscordApiClient
import com.ddd.sonnypolabobe.logger
import org.springframework.http.ResponseEntity
import org.springframework.web.bind.MethodArgumentNotValidException
import org.springframework.web.bind.annotation.ExceptionHandler
import org.springframework.web.bind.annotation.RestControllerAdvice

@RestControllerAdvice
class GlobalExceptionHandler {
class GlobalExceptionHandler(
private val discordApiClient: DiscordApiClient
) {
@ExceptionHandler(ApplicationException::class)
fun applicationException(ex: ApplicationException): ResponseEntity<ApplicationResponse<Error>> {
logger().info("error : ${ex.error}")
this.discordApiClient.sendErrorTrace(
ex.error.code, ex.message,
ex.stackTrace.contentToString()
)
return ResponseEntity.status(ex.error.status).body(ApplicationResponse.error(ex.error))
}

@ExceptionHandler(MethodArgumentNotValidException::class)
fun validationException(ex: MethodArgumentNotValidException): ResponseEntity<ApplicationResponse<Error>> {
logger().info("error : ${ex.bindingResult.allErrors[0].defaultMessage}")
return ResponseEntity.status(CustomErrorCode.INVALID_VALUE_EXCEPTION.status)
.body(ApplicationResponse.error(CustomErrorCode.INVALID_VALUE_EXCEPTION, ex.bindingResult.allErrors[0].defaultMessage!!))
.body(
ApplicationResponse.error(
CustomErrorCode.INVALID_VALUE_EXCEPTION,
ex.bindingResult.allErrors[0].defaultMessage!!
)
)
}

@ExceptionHandler(RuntimeException::class)
fun runtimeException(ex: RuntimeException): ResponseEntity<ApplicationResponse<Error>> {
logger().info("error : ${ex.message}")
this.discordApiClient.sendErrorTrace(
"500", ex.message,
ex.stackTrace.contentToString()
)
return ResponseEntity.status(CustomErrorCode.INTERNAL_SERVER_EXCEPTION.status)
.body(ApplicationResponse.error(CustomErrorCode.INTERNAL_SERVER_EXCEPTION))
}
Expand Down
124 changes: 124 additions & 0 deletions src/main/kotlin/com/ddd/sonnypolabobe/global/security/LoggingFilter.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
package com.ddd.sonnypolabobe.global.security

import com.ddd.sonnypolabobe.global.util.DiscordApiClient
import com.ddd.sonnypolabobe.global.util.HttpLog
import com.ddd.sonnypolabobe.logger
import jakarta.servlet.FilterChain
import jakarta.servlet.ServletRequest
import jakarta.servlet.ServletResponse
import jakarta.servlet.http.HttpServletRequest
import jakarta.servlet.http.HttpServletResponse
import org.springframework.stereotype.Component
import org.springframework.web.filter.GenericFilterBean
import org.springframework.web.util.ContentCachingRequestWrapper
import org.springframework.web.util.ContentCachingResponseWrapper
import org.springframework.web.util.WebUtils
import java.io.UnsupportedEncodingException
import java.util.*

@Component
class LoggingFilter(
private val discordApiClient: DiscordApiClient
) : GenericFilterBean() {
private val excludedUrls = setOf("/actuator", "/swagger-ui")

override fun doFilter(request: ServletRequest, response: ServletResponse, chain: FilterChain) {
val requestWrapper: ContentCachingRequestWrapper =
ContentCachingRequestWrapper(request as HttpServletRequest)
val responseWrapper: ContentCachingResponseWrapper =
ContentCachingResponseWrapper(response as HttpServletResponse)
if (excludeLogging(request.requestURI)) {
chain.doFilter(request, response)
} else {
val startedAt = System.currentTimeMillis()
chain.doFilter(requestWrapper, responseWrapper)
val endedAt = System.currentTimeMillis()

logger().info(
"\n" +
"[REQUEST] ${request.method} - ${request.requestURI} ${responseWrapper.status} - ${(endedAt - startedAt) / 10000.0} \n" +
"Headers : ${getHeaders(request)} \n" +
"Parameters : ${getRequestParams(request)} \n" +
"Request body : ${getRequestBody(requestWrapper)} \n" +
"Response body : ${getResponseBody(responseWrapper)}"
)

if(responseWrapper.status >= 400) {
this.discordApiClient.sendErrorLog(
HttpLog(
request.method,
request.requestURI,
responseWrapper.status,
(endedAt - startedAt) / 10000.0,
getHeaders(request),
getRequestParams(request),
getRequestBody(requestWrapper),
getResponseBody(responseWrapper)
)
)
}
}
}

private fun excludeLogging(requestURI: String): Boolean {
return excludedUrls.contains(requestURI)
}

private fun getResponseBody(response: ContentCachingResponseWrapper): String {
var payload: String? = null
response.characterEncoding = "utf-8"
val wrapper =
WebUtils.getNativeResponse(response, ContentCachingResponseWrapper::class.java)
if (wrapper != null) {
val buf = wrapper.contentAsByteArray
if (buf.isNotEmpty()) {
payload = String(buf, 0, buf.size, charset(wrapper.characterEncoding))
wrapper.copyBodyToResponse()
}
}
return payload ?: " - "
}

private fun getRequestBody(request: ContentCachingRequestWrapper): String {
request.characterEncoding = "utf-8"
val wrapper = WebUtils.getNativeRequest<ContentCachingRequestWrapper>(
request,
ContentCachingRequestWrapper::class.java
)
if (wrapper != null) {
val buf = wrapper.contentAsByteArray
if (buf.isNotEmpty()) {
return try {
String(buf, 0, buf.size, charset(wrapper.characterEncoding))
} catch (e: UnsupportedEncodingException) {
" - "
}
}
}
return " - "
}

private fun getRequestParams(request: HttpServletRequest): Map<String, String> {
val parameterMap: MutableMap<String, String> = HashMap()
request.characterEncoding = "utf-8"
val parameterArray: Enumeration<*> = request.parameterNames

while (parameterArray.hasMoreElements()) {
val parameterName = parameterArray.nextElement() as String
parameterMap[parameterName] = request.getParameter(parameterName)
}

return parameterMap
}

private fun getHeaders(request: HttpServletRequest): Map<String, String> {
val headerMap: MutableMap<String, String> = HashMap()

val headerArray: Enumeration<*> = request.headerNames
while (headerArray.hasMoreElements()) {
val headerName = headerArray.nextElement() as String
headerMap[headerName] = request.getHeader(headerName)
}
return headerMap
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
package com.ddd.sonnypolabobe.global.security

import jakarta.servlet.http.HttpServletRequest
import jakarta.servlet.http.HttpServletResponse
import org.springframework.http.HttpStatus
import org.springframework.stereotype.Component
import org.springframework.web.servlet.HandlerInterceptor

@Component
class RateLimitingInterceptor(private val rateLimitingService: RateLimitingService) :
HandlerInterceptor {

override fun preHandle(request: HttpServletRequest, response: HttpServletResponse, handler: Any): Boolean {
// 특정 URL 패턴을 필터링합니다.
if (request.requestURI == "/api/v1/boards" && request.method == "POST") {
if (!rateLimitingService.incrementRequestCount()) {
response.status = HttpStatus.TOO_MANY_REQUESTS.value()
response.writer.write("Daily request limit exceeded")
return false
}
}
return true
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
package com.ddd.sonnypolabobe.global.security

import com.ddd.sonnypolabobe.logger
import org.springframework.beans.factory.annotation.Value
import org.springframework.scheduling.annotation.Scheduled
import org.springframework.stereotype.Service
import java.util.concurrent.ConcurrentHashMap

@Service
class RateLimitingService(
@Value("\${limit.count}")
private val limit: Int
) {

private val requestCounts = ConcurrentHashMap<String, Int>()
private val LIMIT = limit
private val REQUEST_KEY = "api_request_count"

fun incrementRequestCount(): Boolean {
val currentCount = requestCounts.getOrDefault(REQUEST_KEY, 0)

if (currentCount >= LIMIT) {
return false
}

requestCounts[REQUEST_KEY] = currentCount + 1
logger().info("Request count: ${requestCounts[REQUEST_KEY]}")
return true
}

@Scheduled(cron = "0 0 0 * * *", zone = "Asia/Seoul")
fun resetRequestCount() {
requestCounts.clear()
}
}
Loading

0 comments on commit b499015

Please sign in to comment.