diff --git a/.github/workflows/ktlint.yml b/.github/workflows/ktlint.yml new file mode 100644 index 0000000..50d24dd --- /dev/null +++ b/.github/workflows/ktlint.yml @@ -0,0 +1,27 @@ +name: Ktlint Check + +on: + pull_request: + branches: + - master + - develop + +jobs: + ktlint: + runs-on: ubuntu-latest + + steps: + - name: Checkout Code + uses: actions/checkout@v4 + + - name: Set up JDK + uses: actions/setup-java@v4 + with: + java-version: '21' + distribution: 'temurin' + + - name: Grant execute permission for gradlew + run: chmod +x gradlew + + - name: Run Ktlint Check + run: ./gradlew ktlintCheck \ No newline at end of file diff --git a/src/main/kotlin/com/ixfp/gitmon/GitmonApplication.kt b/src/main/kotlin/com/ixfp/gitmon/GitmonApplication.kt index c76d234..67ac969 100644 --- a/src/main/kotlin/com/ixfp/gitmon/GitmonApplication.kt +++ b/src/main/kotlin/com/ixfp/gitmon/GitmonApplication.kt @@ -1,11 +1,15 @@ package com.ixfp.gitmon import org.springframework.boot.autoconfigure.SpringBootApplication +import org.springframework.boot.context.properties.ConfigurationPropertiesScan import org.springframework.boot.runApplication import org.springframework.cloud.openfeign.EnableFeignClients +import org.springframework.context.annotation.EnableAspectJAutoProxy @SpringBootApplication @EnableFeignClients +@ConfigurationPropertiesScan +@EnableAspectJAutoProxy class GitmonApplication fun main(args: Array) { diff --git a/src/main/kotlin/com/ixfp/gitmon/client/github/GithubApiService.kt b/src/main/kotlin/com/ixfp/gitmon/client/github/GithubApiService.kt index 458a9b6..99708ff 100644 --- a/src/main/kotlin/com/ixfp/gitmon/client/github/GithubApiService.kt +++ b/src/main/kotlin/com/ixfp/gitmon/client/github/GithubApiService.kt @@ -1,42 +1,130 @@ package com.ixfp.gitmon.client.github +import com.ixfp.gitmon.client.github.exception.GithubApiExceptionStrategy +import com.ixfp.gitmon.client.github.exception.GithubInvalidAuthCodeException import com.ixfp.gitmon.client.github.request.GithubAccessTokenRequest import com.ixfp.gitmon.client.github.request.GithubCreateRepositoryRequest +import com.ixfp.gitmon.client.github.request.GithubUpsertFileRequest +import com.ixfp.gitmon.client.github.response.GithubContent import com.ixfp.gitmon.client.github.response.GithubUserResponse +import com.ixfp.gitmon.common.aop.WrapWith +import com.ixfp.gitmon.common.type.Profile +import com.ixfp.gitmon.common.util.Base64Encoder import com.ixfp.gitmon.common.util.BearerToken -import org.springframework.beans.factory.annotation.Value +import feign.FeignException +import io.github.oshai.kotlinlogging.KotlinLogging.logger import org.springframework.stereotype.Component +import org.springframework.web.multipart.MultipartFile +@WrapWith(GithubApiExceptionStrategy::class) @Component class GithubApiService( private val githubOauth2ApiClient: GithubOauth2ApiClient, private val githubResourceApiClient: GithubResourceApiClient, - @Value("\${oauth2.client.github.id}") private val githubClientId: String, - @Value("\${oauth2.client.github.secret}") private val githubClientSecret: String, + private val githubProdClient: GithubOauth2ClientProdProperties, + private val githubDevClient: GithubOauth2ClientDevProperties, ) { fun getGithubUser(githubAccessToken: String): GithubUserResponse { return githubResourceApiClient.fetchUser(BearerToken(githubAccessToken).format()) } - fun getAccessTokenByCode(code: String): String { + fun getAuthRedirectionUrl(profile: Profile): String { + return when (profile) { + Profile.PROD -> buildAuthRedirectionUrl(githubProdClient.id) + Profile.DEV -> buildAuthRedirectionUrl(githubDevClient.id) + } + } + + fun getAccessTokenByCode( + code: String, + profile: Profile, + ): String { val request = GithubAccessTokenRequest( code = code, - client_id = githubClientId, - client_secret = githubClientSecret, + client_id = githubClientId(profile), + client_secret = githubClientSecret(profile), ) - return githubOauth2ApiClient.fetchAccessToken(request).accessToken + + val response = githubOauth2ApiClient.fetchAccessToken(request) + if (response.accessToken == null) { + throw GithubInvalidAuthCodeException("response=$response") + } + return response.accessToken } - // TODO(KHJ): dummy function, 관련 api 나오면 정리할 것 - fun hasRepository(githubAccessToken: String): Boolean { - return false + fun upsertFile( + githubAccessToken: String, + content: MultipartFile, + githubUsername: String, + repo: String, + path: String, + commitMessage: String, + sha: String = "", + ): GithubContent { + val request = + GithubUpsertFileRequest( + message = commitMessage, + content = Base64Encoder.encodeBase64(content), + sha = sha, + ) + val response = + githubResourceApiClient.upsertFile( + bearerToken = BearerToken(githubAccessToken).format(), + owner = githubUsername, + repo = repo, + path = path, + request = request, + ) + return response.content } - private fun createRepository( - token: String, + fun createRepository( + accessToken: String, request: GithubCreateRepositoryRequest, ) { - githubResourceApiClient.createRepository(token, request) + githubResourceApiClient.createRepository( + bearerToken = "Bearer $accessToken", + request = request, + ) + } + + fun isRepositoryExist( + token: String, + owner: String, + repo: String, + ): Boolean { + return try { + githubResourceApiClient.fetchRepository( + token = token, + owner = owner, + repo = repo, + ) + true + } catch (e: FeignException.NotFound) { + false + } + } + + private fun buildAuthRedirectionUrl(githubClientId: String): String { + return "https://github.com/login/oauth/authorize?&scope=repo&client_id=$githubClientId" + } + + private fun githubClientId(profile: Profile): String { + return when (profile) { + Profile.PROD -> githubProdClient.id + Profile.DEV -> githubDevClient.id + } + } + + private fun githubClientSecret(profile: Profile): String { + return when (profile) { + Profile.PROD -> githubProdClient.secret + Profile.DEV -> githubDevClient.secret + } + } + + companion object { + private val log = logger {} } } diff --git a/src/main/kotlin/com/ixfp/gitmon/client/github/GithubOauth2ClientDevProperties.kt b/src/main/kotlin/com/ixfp/gitmon/client/github/GithubOauth2ClientDevProperties.kt new file mode 100644 index 0000000..a169f6b --- /dev/null +++ b/src/main/kotlin/com/ixfp/gitmon/client/github/GithubOauth2ClientDevProperties.kt @@ -0,0 +1,9 @@ +package com.ixfp.gitmon.client.github + +import org.springframework.boot.context.properties.ConfigurationProperties + +@ConfigurationProperties(prefix = "oauth2.client-dev.github") +data class GithubOauth2ClientDevProperties( + val id: String, + val secret: String, +) diff --git a/src/main/kotlin/com/ixfp/gitmon/client/github/GithubOauth2ClientProdProperties.kt b/src/main/kotlin/com/ixfp/gitmon/client/github/GithubOauth2ClientProdProperties.kt new file mode 100644 index 0000000..d491325 --- /dev/null +++ b/src/main/kotlin/com/ixfp/gitmon/client/github/GithubOauth2ClientProdProperties.kt @@ -0,0 +1,9 @@ +package com.ixfp.gitmon.client.github + +import org.springframework.boot.context.properties.ConfigurationProperties + +@ConfigurationProperties(prefix = "oauth2.client.github") +data class GithubOauth2ClientProdProperties( + val id: String, + val secret: String, +) diff --git a/src/main/kotlin/com/ixfp/gitmon/client/github/GithubResourceApiClient.kt b/src/main/kotlin/com/ixfp/gitmon/client/github/GithubResourceApiClient.kt index ed2c596..d64eea7 100644 --- a/src/main/kotlin/com/ixfp/gitmon/client/github/GithubResourceApiClient.kt +++ b/src/main/kotlin/com/ixfp/gitmon/client/github/GithubResourceApiClient.kt @@ -1,14 +1,17 @@ package com.ixfp.gitmon.client.github import com.ixfp.gitmon.client.github.request.GithubCreateRepositoryRequest +import com.ixfp.gitmon.client.github.request.GithubUpsertFileRequest import com.ixfp.gitmon.client.github.response.GithubCreateRepositoryResponse import com.ixfp.gitmon.client.github.response.GithubFetchRepositoryResponse +import com.ixfp.gitmon.client.github.response.GithubUpsertFileResponse import com.ixfp.gitmon.client.github.response.GithubUserResponse import org.springframework.cloud.openfeign.FeignClient import org.springframework.http.MediaType import org.springframework.web.bind.annotation.GetMapping import org.springframework.web.bind.annotation.PathVariable import org.springframework.web.bind.annotation.PostMapping +import org.springframework.web.bind.annotation.PutMapping import org.springframework.web.bind.annotation.RequestBody import org.springframework.web.bind.annotation.RequestHeader @@ -47,4 +50,22 @@ interface GithubResourceApiClient { @RequestHeader("Accept") accept: String = "application/vnd.github+json", @RequestHeader("X-GitHub-Api-Version") apiVersion: String = "2022-11-28", ): GithubCreateRepositoryResponse + + /** + * https://docs.github.com/ko/rest/repos/contents + */ + @PutMapping( + "/repos/{owner}/{repo}/contents/{path}", + consumes = [MediaType.APPLICATION_JSON_VALUE], + produces = [MediaType.APPLICATION_JSON_VALUE], + ) + fun upsertFile( + @RequestHeader("Authorization") bearerToken: String, + @RequestHeader("Accept") accept: String = "application/vnd.github+json", + @RequestHeader("X-GitHub-Api-Version") apiVersion: String = "2022-11-28", + @PathVariable("owner") owner: String, + @PathVariable("repo") repo: String, + @PathVariable("path") path: String, + @RequestBody request: GithubUpsertFileRequest, + ): GithubUpsertFileResponse } diff --git a/src/main/kotlin/com/ixfp/gitmon/client/github/exception/GithubApiExceptionStrategy.kt b/src/main/kotlin/com/ixfp/gitmon/client/github/exception/GithubApiExceptionStrategy.kt new file mode 100644 index 0000000..4ab3bae --- /dev/null +++ b/src/main/kotlin/com/ixfp/gitmon/client/github/exception/GithubApiExceptionStrategy.kt @@ -0,0 +1,18 @@ +package com.ixfp.gitmon.client.github.exception + +import com.fasterxml.jackson.core.JsonProcessingException +import com.ixfp.gitmon.common.aop.ExceptionWrappingStrategy +import com.ixfp.gitmon.common.exception.AppException +import org.springframework.stereotype.Component +import java.io.IOException + +@Component +class GithubApiExceptionStrategy : ExceptionWrappingStrategy { + override fun wrap(e: Throwable): AppException = + when (e) { + is GithubApiException -> e + is JsonProcessingException -> GithubResponseParsingException(cause = e) + is IOException -> GithubNetworkException(cause = e) + else -> GithubUnexpectedException(cause = e) + } +} diff --git a/src/main/kotlin/com/ixfp/gitmon/client/github/exception/GithubApiExceptions.kt b/src/main/kotlin/com/ixfp/gitmon/client/github/exception/GithubApiExceptions.kt new file mode 100644 index 0000000..26fdbca --- /dev/null +++ b/src/main/kotlin/com/ixfp/gitmon/client/github/exception/GithubApiExceptions.kt @@ -0,0 +1,28 @@ +package com.ixfp.gitmon.client.github.exception + +import com.ixfp.gitmon.common.exception.AppException + +sealed class GithubApiException( + message: String, + cause: Throwable? = null, +) : AppException(message, cause) + +class GithubNetworkException( + message: String = "GitHub 서버와의 네트워크 통신에 실패했습니다.", + cause: Throwable? = null, +) : GithubApiException(message, cause) + +class GithubResponseParsingException( + message: String = "GitHub 응답을 파싱하는 데 실패했습니다.", + cause: Throwable? = null, +) : GithubApiException(message, cause) + +class GithubUnexpectedException( + message: String = "GitHub API 호출 중 예상치 못한 예외가 발생했습니다.", + cause: Throwable? = null, +) : GithubApiException(message, cause) + +class GithubInvalidAuthCodeException( + message: String = "Github Auth Code가 유효하지 않습니다.", + cause: Throwable? = null, +) : GithubApiException(message, cause) diff --git a/src/main/kotlin/com/ixfp/gitmon/client/github/request/GithubUpsertFileRequest.kt b/src/main/kotlin/com/ixfp/gitmon/client/github/request/GithubUpsertFileRequest.kt new file mode 100644 index 0000000..99d947f --- /dev/null +++ b/src/main/kotlin/com/ixfp/gitmon/client/github/request/GithubUpsertFileRequest.kt @@ -0,0 +1,7 @@ +package com.ixfp.gitmon.client.github.request + +data class GithubUpsertFileRequest( + val message: String, + val content: String, + val sha: String, +) diff --git a/src/main/kotlin/com/ixfp/gitmon/client/github/response/GithubAccessTokenResponse.kt b/src/main/kotlin/com/ixfp/gitmon/client/github/response/GithubAccessTokenResponse.kt index 6c477e5..068e347 100644 --- a/src/main/kotlin/com/ixfp/gitmon/client/github/response/GithubAccessTokenResponse.kt +++ b/src/main/kotlin/com/ixfp/gitmon/client/github/response/GithubAccessTokenResponse.kt @@ -4,8 +4,13 @@ import com.fasterxml.jackson.annotation.JsonProperty data class GithubAccessTokenResponse( @JsonProperty("access_token") - val accessToken: String, - val scope: String, + val accessToken: String?, + val scope: String?, @JsonProperty("token_type") - val tokenType: String, + val tokenType: String?, + val error: String?, + @JsonProperty("error_description") + val errorDescription: String?, + @JsonProperty("error_uri") + val errorUri: String?, ) diff --git a/src/main/kotlin/com/ixfp/gitmon/client/github/response/GithubUpsertFileResponse.kt b/src/main/kotlin/com/ixfp/gitmon/client/github/response/GithubUpsertFileResponse.kt new file mode 100644 index 0000000..4381209 --- /dev/null +++ b/src/main/kotlin/com/ixfp/gitmon/client/github/response/GithubUpsertFileResponse.kt @@ -0,0 +1,12 @@ +package com.ixfp.gitmon.client.github.response + +data class GithubUpsertFileResponse( + val content: GithubContent, +) + +data class GithubContent( + val name: String, + val path: String, + val sha: String, + val download_url: String, +) diff --git a/src/main/kotlin/com/ixfp/gitmon/common/aop/ExceptionWrappingAspect.kt b/src/main/kotlin/com/ixfp/gitmon/common/aop/ExceptionWrappingAspect.kt new file mode 100644 index 0000000..37bb72b --- /dev/null +++ b/src/main/kotlin/com/ixfp/gitmon/common/aop/ExceptionWrappingAspect.kt @@ -0,0 +1,36 @@ +package com.ixfp.gitmon.common.aop + +import org.aspectj.lang.ProceedingJoinPoint +import org.aspectj.lang.annotation.Around +import org.aspectj.lang.annotation.Aspect +import org.springframework.context.ApplicationContext +import org.springframework.stereotype.Component +import kotlin.reflect.full.findAnnotation + +@Aspect +@Component +class ExceptionWrappingAspect( + private val applicationContext: ApplicationContext, +) { + @Around( + "execution(public * *(..)) && " + + "(@annotation(wrapWith) || @within(wrapWith))", + ) + fun aroundAnnotated( + joinPoint: ProceedingJoinPoint, + wrapWith: WrapWith?, + ): Any? { + val ann = + wrapWith + ?: joinPoint.signature.declaringType.kotlin + .findAnnotation() + ?: return joinPoint.proceed() + + return try { + joinPoint.proceed() + } catch (original: Throwable) { + val strategy = applicationContext.getBean(ann.value.java) + throw strategy.wrap(original) + } + } +} diff --git a/src/main/kotlin/com/ixfp/gitmon/common/aop/ExceptionWrappingStrategy.kt b/src/main/kotlin/com/ixfp/gitmon/common/aop/ExceptionWrappingStrategy.kt new file mode 100644 index 0000000..674b825 --- /dev/null +++ b/src/main/kotlin/com/ixfp/gitmon/common/aop/ExceptionWrappingStrategy.kt @@ -0,0 +1,7 @@ +package com.ixfp.gitmon.common.aop + +import com.ixfp.gitmon.common.exception.AppException + +interface ExceptionWrappingStrategy { + fun wrap(e: Throwable): AppException +} diff --git a/src/main/kotlin/com/ixfp/gitmon/common/aop/WrapWith.kt b/src/main/kotlin/com/ixfp/gitmon/common/aop/WrapWith.kt new file mode 100644 index 0000000..f2c6e42 --- /dev/null +++ b/src/main/kotlin/com/ixfp/gitmon/common/aop/WrapWith.kt @@ -0,0 +1,9 @@ +package com.ixfp.gitmon.common.aop + +import kotlin.reflect.KClass + +@Target(AnnotationTarget.FUNCTION, AnnotationTarget.CLASS) +@Retention(AnnotationRetention.RUNTIME) +annotation class WrapWith( + val value: KClass, +) diff --git a/src/main/kotlin/com/ixfp/gitmon/common/exception/AppException.kt b/src/main/kotlin/com/ixfp/gitmon/common/exception/AppException.kt new file mode 100644 index 0000000..d2832f4 --- /dev/null +++ b/src/main/kotlin/com/ixfp/gitmon/common/exception/AppException.kt @@ -0,0 +1,3 @@ +package com.ixfp.gitmon.common.exception + +open class AppException(message: String, cause: Throwable? = null) : RuntimeException(message, cause) diff --git a/src/main/kotlin/com/ixfp/gitmon/common/exception/LayerExceptions.kt b/src/main/kotlin/com/ixfp/gitmon/common/exception/LayerExceptions.kt new file mode 100644 index 0000000..b7b1c80 --- /dev/null +++ b/src/main/kotlin/com/ixfp/gitmon/common/exception/LayerExceptions.kt @@ -0,0 +1,7 @@ +package com.ixfp.gitmon.common.exception + +open class PresentationException(message: String, cause: Throwable? = null) : AppException(message, cause) + +open class DomainException(message: String, cause: Throwable? = null) : AppException(message, cause) + +open class RepositoryException(message: String, cause: Throwable? = null) : AppException(message, cause) diff --git a/src/main/kotlin/com/ixfp/gitmon/common/type/Profile.kt b/src/main/kotlin/com/ixfp/gitmon/common/type/Profile.kt new file mode 100644 index 0000000..0e7944f --- /dev/null +++ b/src/main/kotlin/com/ixfp/gitmon/common/type/Profile.kt @@ -0,0 +1,13 @@ +package com.ixfp.gitmon.common.type + +enum class Profile(val code: String, val description: String) { + DEV("dev", "개발환경"), + PROD("prod", "운영환경"), + ; + + companion object { + fun findByCode(code: String?): Profile { + return entries.find { it.code == code } ?: PROD + } + } +} diff --git a/src/main/kotlin/com/ixfp/gitmon/common/util/Base64Encoder.kt b/src/main/kotlin/com/ixfp/gitmon/common/util/Base64Encoder.kt new file mode 100644 index 0000000..9ffe4d7 --- /dev/null +++ b/src/main/kotlin/com/ixfp/gitmon/common/util/Base64Encoder.kt @@ -0,0 +1,10 @@ +package com.ixfp.gitmon.common.util + +import org.springframework.web.multipart.MultipartFile +import java.util.Base64 + +object Base64Encoder { + fun encodeBase64(file: MultipartFile): String { + return Base64.getEncoder().encodeToString(file.bytes) + } +} diff --git a/src/main/kotlin/com/ixfp/gitmon/common/util/JwtUtil.kt b/src/main/kotlin/com/ixfp/gitmon/common/util/JwtUtil.kt index 54745ad..132dd99 100644 --- a/src/main/kotlin/com/ixfp/gitmon/common/util/JwtUtil.kt +++ b/src/main/kotlin/com/ixfp/gitmon/common/util/JwtUtil.kt @@ -13,6 +13,17 @@ class JwtUtil( private val key = Keys.hmacShaKeyFor(key.toByteArray()) fun createAccessToken(memberExposedId: String): String { + return buildToken(memberExposedId, ACCESS_TOKEN_EXPIRE_MILLISECONDS) + } + + fun createRefreshToken(memberExposedId: String): String { + return buildToken(memberExposedId, REFRESH_TOKEN_EXPIRE_MILLISECONDS) + } + + private fun buildToken( + memberExposedId: String, + tokenValidityMillis: Int, + ): String { return Jwts.builder() .claims( mapOf( @@ -20,7 +31,7 @@ class JwtUtil( ), ) .issuedAt(Date()) - .expiration(Date(System.currentTimeMillis() + TOKEN_EXPIRE_MILLISECONDS)) + .expiration(Date(System.currentTimeMillis() + tokenValidityMillis)) .signWith(key) .compact() } @@ -41,7 +52,12 @@ class JwtUtil( } companion object { - private const val TOKEN_EXPIRE_MILLISECONDS = 1000 * 60 * 60 * 10 // 10시간 + private const val ONE_SECOND_MILLIS = 1000 + private const val ONE_MINUTE_MILLIS = 60 * ONE_SECOND_MILLIS + private const val ONE_HOUR_MILLIS = 60 * ONE_MINUTE_MILLIS + private const val ONE_DAY_MILLIS = 24 * ONE_HOUR_MILLIS + private const val ACCESS_TOKEN_EXPIRE_MILLISECONDS = 1 * ONE_HOUR_MILLIS + private const val REFRESH_TOKEN_EXPIRE_MILLISECONDS = 14 * ONE_DAY_MILLIS } } diff --git a/src/main/kotlin/com/ixfp/gitmon/common/config/SpringDocConfig.kt b/src/main/kotlin/com/ixfp/gitmon/config/docs/SpringDocConfig.kt similarity index 72% rename from src/main/kotlin/com/ixfp/gitmon/common/config/SpringDocConfig.kt rename to src/main/kotlin/com/ixfp/gitmon/config/docs/SpringDocConfig.kt index a8c6c91..168c182 100644 --- a/src/main/kotlin/com/ixfp/gitmon/common/config/SpringDocConfig.kt +++ b/src/main/kotlin/com/ixfp/gitmon/config/docs/SpringDocConfig.kt @@ -1,4 +1,4 @@ -package com.ixfp.gitmon.common.config +package com.ixfp.gitmon.config.docs import io.swagger.v3.oas.models.Components import io.swagger.v3.oas.models.OpenAPI @@ -9,6 +9,8 @@ import io.swagger.v3.oas.models.media.MediaType import io.swagger.v3.oas.models.media.Schema import io.swagger.v3.oas.models.responses.ApiResponse import io.swagger.v3.oas.models.responses.ApiResponses +import io.swagger.v3.oas.models.security.SecurityScheme +import io.swagger.v3.oas.models.servers.Server import org.springdoc.core.customizers.OpenApiCustomizer import org.springframework.context.annotation.Bean import org.springframework.context.annotation.Configuration @@ -19,13 +21,25 @@ data class ResponseMsg( val timestamp: String = "2025-01-01T12:00:00Z", ) -// TODO(KHJ): 인증 관련 처리 필요함 @Configuration class SpringDocConfig { // SpringDoc Main Title 세팅 @Bean fun customOpenAPI(): OpenAPI { - return OpenAPI().info(configurationInfo()).components(Components()) + return OpenAPI() + .info(configurationInfo()) + .components( + Components().addSecuritySchemes( + ACCESS_TOKEN, + SecurityScheme() + .name(ACCESS_TOKEN) + .type(SecurityScheme.Type.HTTP) + .`in`(SecurityScheme.In.HEADER) + .scheme("bearer") + .bearerFormat("JWT"), + ), + ) + .addServersItem(Server().url("https://api.gitmon.blog")) } private fun configurationInfo(): Info { @@ -41,8 +55,8 @@ class SpringDocConfig { val apiResponses: ApiResponses = operation.responses // 미지원 Standard Response 추가 - apiResponses.addApiResponse("400", customApiResponse("400", "Bad Request")) - apiResponses.addApiResponse("500", customApiResponse("500", "Internal Server Error")) +// apiResponses.addApiResponse("400", customApiResponse("400", "Bad Request")) +// apiResponses.addApiResponse("500", customApiResponse("500", "Internal Server Error")) } } } diff --git a/src/main/kotlin/com/ixfp/gitmon/config/docs/SwaggerSecurityRequirements.kt b/src/main/kotlin/com/ixfp/gitmon/config/docs/SwaggerSecurityRequirements.kt new file mode 100644 index 0000000..bf09f7d --- /dev/null +++ b/src/main/kotlin/com/ixfp/gitmon/config/docs/SwaggerSecurityRequirements.kt @@ -0,0 +1,3 @@ +package com.ixfp.gitmon.config.docs + +const val ACCESS_TOKEN = "accessToken" diff --git a/src/main/kotlin/com/ixfp/gitmon/config/web/JwtAuthInterceptor.kt b/src/main/kotlin/com/ixfp/gitmon/config/web/JwtAuthInterceptor.kt new file mode 100644 index 0000000..8dec900 --- /dev/null +++ b/src/main/kotlin/com/ixfp/gitmon/config/web/JwtAuthInterceptor.kt @@ -0,0 +1,56 @@ +package com.ixfp.gitmon.config.web + +import com.ixfp.gitmon.common.util.JwtUtil +import com.ixfp.gitmon.domain.auth.AuthService +import io.github.oshai.kotlinlogging.KotlinLogging.logger +import jakarta.servlet.http.HttpServletRequest +import jakarta.servlet.http.HttpServletResponse +import org.springframework.stereotype.Component +import org.springframework.web.servlet.HandlerInterceptor + +@Component +class JwtAuthInterceptor( + private val jwtUtil: JwtUtil, + private val authService: AuthService, +) : HandlerInterceptor { + override fun preHandle( + request: HttpServletRequest, + response: HttpServletResponse, + handler: Any, + ): Boolean { + if (request.method.equals("OPTIONS", ignoreCase = true)) { + return true + } + + val authHeader = request.getHeader("Authorization") + + if (authHeader.isNullOrBlank() || !authHeader.startsWith("Bearer ")) { + response.sendError(HttpServletResponse.SC_UNAUTHORIZED, "Missing or invalid Authorization header") + return false + } + + val token = authHeader.removePrefix("Bearer ") + val exposedId = + jwtUtil.parseAccessToken(token)?.exposedId + ?: run { + log.info { "유효하지 않은 사용자 토큰" } + response.sendError(HttpServletResponse.SC_UNAUTHORIZED, "Invalid token") + return false + } + + val member = + authService.getMemberByExposedId(exposedId) + ?: run { + log.info { "토큰에 해당하는 사용자를 찾을 수 없음" } + response.sendError(HttpServletResponse.SC_UNAUTHORIZED, "Invalid token") + return false + } + + request.setAttribute(AUTHENTICATED_MEMBER, member) // 요청 속성에 저장하여 컨트롤러에서 사용 가능 + return true + } + + companion object { + private val log = logger {} + } +} diff --git a/src/main/kotlin/com/ixfp/gitmon/config/web/RequestHeaderAttributes.kt b/src/main/kotlin/com/ixfp/gitmon/config/web/RequestHeaderAttributes.kt new file mode 100644 index 0000000..100be18 --- /dev/null +++ b/src/main/kotlin/com/ixfp/gitmon/config/web/RequestHeaderAttributes.kt @@ -0,0 +1,3 @@ +package com.ixfp.gitmon.config.web + +const val AUTHENTICATED_MEMBER = "authenticatedMember" diff --git a/src/main/kotlin/com/ixfp/gitmon/config/web/WebMvcConfig.kt b/src/main/kotlin/com/ixfp/gitmon/config/web/WebMvcConfig.kt new file mode 100644 index 0000000..47c8f92 --- /dev/null +++ b/src/main/kotlin/com/ixfp/gitmon/config/web/WebMvcConfig.kt @@ -0,0 +1,34 @@ +package com.ixfp.gitmon.config.web + +import org.springframework.context.annotation.Configuration +import org.springframework.web.servlet.config.annotation.CorsRegistry +import org.springframework.web.servlet.config.annotation.InterceptorRegistry +import org.springframework.web.servlet.config.annotation.WebMvcConfigurer + +@Configuration +class WebMvcConfig( + private val jwtAuthInterceptor: JwtAuthInterceptor, +) : WebMvcConfigurer { + override fun addInterceptors(registry: InterceptorRegistry) { + registry.addInterceptor(jwtAuthInterceptor) + .addPathPatterns("/api/v1/member/repo/**") // 인증이 필요한 경로 + .addPathPatterns("/api/v1/member", "/api/v1/member/") + .addPathPatterns("/api/v1/posting", "/api/v1/posting/") + .addPathPatterns("/api/v1/posting/images", "/api/v1/posting/images/") + } + + override fun addCorsMappings(registry: CorsRegistry) { + registry.addMapping("/**") + .allowedOrigins(*ALLOWED_ORIGINS) + .allowedMethods(*ALLOWED_METHODS) + .allowedHeaders("*") + .allowCredentials(true) + } + + companion object { + private const val GITMON_CLIENT_URL = "https://gitmon.blog" + private const val GITMON_API_URL = "https://api.gitmon.blog" + private val ALLOWED_ORIGINS = arrayOf(GITMON_CLIENT_URL, GITMON_API_URL, "http://localhost:3000", "http://127.0.0.1:3000") + private val ALLOWED_METHODS = arrayOf("GET", "POST", "PUT", "DELETE", "OPTIONS") + } +} diff --git a/src/main/kotlin/com/ixfp/gitmon/controller/AuthController.kt b/src/main/kotlin/com/ixfp/gitmon/controller/AuthController.kt new file mode 100644 index 0000000..b830cbc --- /dev/null +++ b/src/main/kotlin/com/ixfp/gitmon/controller/AuthController.kt @@ -0,0 +1,58 @@ +package com.ixfp.gitmon.controller + +import com.ixfp.gitmon.common.util.JwtUtil +import com.ixfp.gitmon.controller.response.RefreshResponse +import com.ixfp.gitmon.controller.type.ApiError +import com.ixfp.gitmon.controller.type.ApiResponseBody +import com.ixfp.gitmon.controller.util.ApiResponseHelper +import com.ixfp.gitmon.controller.util.withCookie +import com.ixfp.gitmon.domain.auth.AuthService +import org.springframework.http.ResponseEntity +import org.springframework.web.bind.annotation.CookieValue +import org.springframework.web.bind.annotation.PostMapping +import org.springframework.web.bind.annotation.RequestMapping +import org.springframework.web.bind.annotation.RestController +import java.time.Duration + +@RestController +@RequestMapping("/api/v1") +class AuthController( + private val authService: AuthService, + private val jwtUtil: JwtUtil, +) : IAuthController { + @PostMapping("/refresh") + override fun refreshToken( + @CookieValue("refreshToken") refreshToken: String?, + ): ResponseEntity> { + println(refreshToken) + if (refreshToken.isNullOrBlank()) { + return ApiResponseHelper.error(ApiError.INVALID_REFRESH_TOKEN) + } + + val exposedId = + jwtUtil.parseAccessToken(refreshToken)?.exposedId + ?: run { + return ApiResponseHelper.error(ApiError.INVALID_REFRESH_TOKEN) + } + + val member = + authService.getMemberByExposedId(exposedId) + ?: run { + return ApiResponseHelper.error(ApiError.INVALID_REFRESH_TOKEN) + } + + val newAccessToken = authService.createAccessToken(member) + val newRefreshToken = authService.createRefreshToken(member) + + return ApiResponseHelper.success(RefreshResponse(newAccessToken)) + .withCookie( + key = "refreshToken", + value = newRefreshToken, + maxAge = Duration.ofDays(14), + path = "/api/v1/refresh", + httpOnly = true, + secure = true, + sameSite = "Lax", + ) + } +} diff --git a/src/main/kotlin/com/ixfp/gitmon/controller/ControllerExceptionHandler.kt b/src/main/kotlin/com/ixfp/gitmon/controller/ControllerExceptionHandler.kt new file mode 100644 index 0000000..868633e --- /dev/null +++ b/src/main/kotlin/com/ixfp/gitmon/controller/ControllerExceptionHandler.kt @@ -0,0 +1,48 @@ +package com.ixfp.gitmon.controller + +import com.ixfp.gitmon.client.github.exception.GithubInvalidAuthCodeException +import com.ixfp.gitmon.controller.type.ApiError +import com.ixfp.gitmon.controller.type.ApiResponseBody +import com.ixfp.gitmon.controller.util.ApiResponseHelper +import com.ixfp.gitmon.domain.auth.exception.AuthException +import com.ixfp.gitmon.domain.posting.exception.InvalidImageExtensionException +import com.ixfp.gitmon.domain.posting.exception.PostingRepositoryNotFoundException +import io.github.oshai.kotlinlogging.KotlinLogging.logger +import org.springframework.http.ResponseEntity +import org.springframework.web.bind.annotation.ExceptionHandler +import org.springframework.web.bind.annotation.RestControllerAdvice + +@RestControllerAdvice +class ControllerExceptionHandler { + @ExceptionHandler(GithubInvalidAuthCodeException::class) + fun handleInvalidAuthCodeException(ex: GithubInvalidAuthCodeException): ResponseEntity> { + return ApiResponseHelper.error(ApiError.INVALID_AUTH_CODE) + } + + @ExceptionHandler(AuthException::class) + fun handleInvalidAuthException(ex: AuthException): ResponseEntity> { + // TODO: Auth 예외 구체화 + return ApiResponseHelper.error(ApiError.UNAUTHORIZED) + } + + @ExceptionHandler(InvalidImageExtensionException::class) + fun handleInvalidImageExtensionException(ex: InvalidImageExtensionException): ResponseEntity> { + return ApiResponseHelper.error(ApiError.BAD_REQUEST, "invalid image extension") + } + + @ExceptionHandler(PostingRepositoryNotFoundException::class) + fun handlePostingRepositoryNotFoundException(ex: PostingRepositoryNotFoundException): ResponseEntity> { + return ApiResponseHelper.error(ApiError.REPOSITORY_NOT_CONFIGURED) + } + + @ExceptionHandler(Exception::class) + fun handleAll(ex: Exception): ResponseEntity> { + log.warn(ex) { "예상하지 못한 예외" } + // TODO: 상황에 따라 error 레벨의 로그 추가 + return ApiResponseHelper.error(ApiError.INTERNAL_SERVER_ERROR) + } + + companion object { + private val log = logger {} + } +} diff --git a/src/main/kotlin/com/ixfp/gitmon/controller/IAuthController.kt b/src/main/kotlin/com/ixfp/gitmon/controller/IAuthController.kt new file mode 100644 index 0000000..cc35dd3 --- /dev/null +++ b/src/main/kotlin/com/ixfp/gitmon/controller/IAuthController.kt @@ -0,0 +1,72 @@ +package com.ixfp.gitmon.controller + +import com.ixfp.gitmon.controller.response.RefreshResponse +import com.ixfp.gitmon.controller.type.ApiResponseBody +import io.swagger.v3.oas.annotations.Operation +import io.swagger.v3.oas.annotations.headers.Header +import io.swagger.v3.oas.annotations.media.Content +import io.swagger.v3.oas.annotations.media.Schema +import io.swagger.v3.oas.annotations.responses.ApiResponse +import io.swagger.v3.oas.annotations.responses.ApiResponses +import io.swagger.v3.oas.annotations.tags.Tag +import org.springframework.http.ResponseEntity + +@Tag(name = "Auth Token Control", description = "JWT Access/Refresh 토큰 관련 API") +interface IAuthController { + @Operation( + summary = "토큰 재발급", + description = "Refresh Token 쿠키를 이용해 Access Token, Refresh Token 재발급", + ) + @ApiResponses( + value = [ + ApiResponse( + responseCode = "200", + description = "토큰 재발급 성공", + content = [ + Content( + mediaType = "application/json", + schema = + Schema( + example = + """ + { + "status": "SUCCESS", + "data": { + "accessToken": "header.payload.signature" + } + } + """, + ), + ), + ], + headers = [ + Header( + name = "Set-Cookie", + description = "새로운 Refresh Token 쿠키", + schema = Schema(type = "string"), + ), + ], + ), + ApiResponse( + responseCode = "401", + description = "만료되었거나 유효하지 않은 Refresh Token", + content = [ + Content( + mediaType = "application/json", + schema = + Schema( + example = + """ + { + "status": "UNAUTHORIZED", + "errorMessage": "Invalid or expired refresh token" + } + """, + ), + ), + ], + ), + ], + ) + fun refreshToken(refreshToken: String?): ResponseEntity> +} diff --git a/src/main/kotlin/com/ixfp/gitmon/controller/IMemberController.kt b/src/main/kotlin/com/ixfp/gitmon/controller/IMemberController.kt new file mode 100644 index 0000000..4d08636 --- /dev/null +++ b/src/main/kotlin/com/ixfp/gitmon/controller/IMemberController.kt @@ -0,0 +1,164 @@ +package com.ixfp.gitmon.controller + +import com.ixfp.gitmon.config.docs.ACCESS_TOKEN +import com.ixfp.gitmon.controller.request.CreateRepoRequest +import com.ixfp.gitmon.controller.response.CheckRepoNameResponse +import com.ixfp.gitmon.controller.response.GithubRepoUrlResponse +import com.ixfp.gitmon.controller.response.MemberInfoResponse +import com.ixfp.gitmon.controller.type.ApiResponseBody +import com.ixfp.gitmon.domain.member.Member +import io.swagger.v3.oas.annotations.Operation +import io.swagger.v3.oas.annotations.media.Content +import io.swagger.v3.oas.annotations.media.Schema +import io.swagger.v3.oas.annotations.responses.ApiResponse +import io.swagger.v3.oas.annotations.responses.ApiResponses +import io.swagger.v3.oas.annotations.security.SecurityRequirement +import io.swagger.v3.oas.annotations.tags.Tag +import org.springframework.http.ResponseEntity + +@Tag(name = "Member", description = "회원 관련 API") +interface IMemberController { + @Operation( + summary = "회원 정보 조회", + security = [SecurityRequirement(name = ACCESS_TOKEN)], + ) + @ApiResponses( + value = [ + ApiResponse( + responseCode = "200", + content = [ + Content( + mediaType = "application/json", + schema = + Schema( + example = + """ + { + "status": "SUCCESS", + "data": { + "id": "exposedId", + "githubUsername": "kimsj-git", + "isRepoCreated": "true", + "repoName": "gitmon" + } + } + """, + ), + ), + ], + ), + ], + ) + fun getMember(member: Member): ResponseEntity> + + @Operation( + summary = "레포지토리 생성/갱신", + description = "이미 설정된 레포지토리가 있다면 갱신하고, 없다면 새로 생성합니다.", + security = [SecurityRequirement(name = ACCESS_TOKEN)], + ) + @ApiResponses( + value = [ + ApiResponse( + responseCode = "201", + description = "레포 생성 성공", + content = [ + Content( + mediaType = "application/json", + schema = + Schema( + example = """ + { + "status": "CREATED", + "data": null + } + """, + ), + ), + ], + ), + ApiResponse( + responseCode = "200", + description = "레포 갱신 성공", + content = [ + Content( + mediaType = "application/json", + schema = + Schema( + example = """ + { + "status": "SUCCESS", + "data": null + } + """, + ), + ), + ], + ), + ], + ) + fun upsertRepo( + request: CreateRepoRequest, + member: Member, + ): ResponseEntity> + + @Operation( + summary = "레포지토리 이름 중복 조회", + description = "입력한 레포지토리 이름을 사용할 수 있는지 확인합니다.", + security = [SecurityRequirement(name = ACCESS_TOKEN)], + ) + @ApiResponses( + value = [ + ApiResponse( + description = "사용 가능한 레포지토리 이름인지 확인", + content = [ + Content( + mediaType = "application/json", + schema = + Schema( + example = + """ + { + "status": "SUCCESS", + "data": { + "isAvailable": true + } + } + """, + ), + ), + ], + ), + ], + ) + fun checkRepoName( + name: String, + member: Member, + ): ResponseEntity> + + @Operation(summary = "레포지토리 URL 조회") + @ApiResponses( + value = [ + ApiResponse( + description = "레포지토리 URL 조회", + content = [ + Content( + mediaType = "application/json", + schema = + Schema( + example = + """ + { + "status": "SUCCESS", + "data": { + "githubRepoUrl": "https://api.gitmon.blog/api/v1/member/github/repo?githubUsername=kimsj-git" + } + } + """, + ), + ), + ], + ), + ], + ) + fun findGithubRepoUrl(githubUsername: String): ResponseEntity> +} diff --git a/src/main/kotlin/com/ixfp/gitmon/controller/IOauth2Controller.kt b/src/main/kotlin/com/ixfp/gitmon/controller/IOauth2Controller.kt new file mode 100644 index 0000000..57615a2 --- /dev/null +++ b/src/main/kotlin/com/ixfp/gitmon/controller/IOauth2Controller.kt @@ -0,0 +1,98 @@ +package com.ixfp.gitmon.controller + +import com.ixfp.gitmon.controller.request.GithubOauth2Request +import com.ixfp.gitmon.controller.response.LoginResponse +import com.ixfp.gitmon.controller.type.ApiResponseBody +import io.swagger.v3.oas.annotations.Operation +import io.swagger.v3.oas.annotations.headers.Header +import io.swagger.v3.oas.annotations.media.Content +import io.swagger.v3.oas.annotations.media.Schema +import io.swagger.v3.oas.annotations.responses.ApiResponse +import io.swagger.v3.oas.annotations.responses.ApiResponses +import io.swagger.v3.oas.annotations.tags.Tag +import org.springframework.http.HttpHeaders +import org.springframework.http.ResponseEntity + +@Tag(name = "OAuth Token Control", description = "OAuth 인증 관련 API") +interface IOauth2Controller { + @Operation(summary = "Get Oauth2 Token", description = "Github으로부터 Oauth2 Token을 받아오는 API") + @ApiResponses( + value = [ + ApiResponse( + responseCode = "301", + description = "Redirect to GitHub OAuth page", + headers = [ + Header( + name = HttpHeaders.LOCATION, + description = "GitHub OAuth authorization URL", + schema = Schema(type = "string", format = "uri"), + ), + ], + ), + ], + ) + fun redirectToGithubOauthUrl(profile: String?): ResponseEntity + + @Operation( + summary = "gitmon Access, Refresh 토큰 발급", + description = "Github OAuth 인증 코드를 받아 로그인 처리 후 gitmon의 Access Token을 HTTP Body로 반환 및 Refresh Token 쿠키 설정", + ) + @ApiResponses( + value = [ + ApiResponse( + responseCode = "200", + description = "로그인 및 토큰 발급 성공", + content = [ + Content( + mediaType = "application/json", + schema = + Schema( + example = + """ + { + "status": "SUCCESS", + "data": { + "accessToken": "header.payload.signature" + } + } + """, + ), + ), + ], + headers = [ + Header( + name = "Set-Cookie", + description = "Refresh token cookie", + schema = + Schema( + type = "string", + example = "refreshToken=; HttpOnly; Path=/api/v1/refresh; Secure", + ), + ), + ], + ), + ApiResponse( + responseCode = "401", + description = "유효하지 않은 GitHub 인증 코드", + content = [ + Content( + mediaType = "application/json", + schema = + Schema( + example = """ + { + "status": "UNAUTHORIZED", + "errorMessage": "Invalid auth code" + } + """, + ), + ), + ], + ), + ], + ) + fun login( + profile: String?, + request: GithubOauth2Request, + ): ResponseEntity> +} diff --git a/src/main/kotlin/com/ixfp/gitmon/controller/IPostingController.kt b/src/main/kotlin/com/ixfp/gitmon/controller/IPostingController.kt new file mode 100644 index 0000000..4fe1c44 --- /dev/null +++ b/src/main/kotlin/com/ixfp/gitmon/controller/IPostingController.kt @@ -0,0 +1,190 @@ +package com.ixfp.gitmon.controller + +import com.ixfp.gitmon.config.docs.ACCESS_TOKEN +import com.ixfp.gitmon.controller.type.ApiResponseBody +import com.ixfp.gitmon.domain.member.Member +import com.ixfp.gitmon.domain.posting.PostingReadDto +import io.swagger.v3.oas.annotations.Operation +import io.swagger.v3.oas.annotations.media.Content +import io.swagger.v3.oas.annotations.media.Schema +import io.swagger.v3.oas.annotations.responses.ApiResponse +import io.swagger.v3.oas.annotations.responses.ApiResponses +import io.swagger.v3.oas.annotations.security.SecurityRequirement +import io.swagger.v3.oas.annotations.tags.Tag +import org.springframework.http.MediaType +import org.springframework.http.ResponseEntity +import org.springframework.web.multipart.MultipartFile + +@Tag(name = "Posting", description = "게시글(포스팅) 관련 API") +interface IPostingController { + @Operation( + summary = "포스팅 생성", + description = "포스팅을 생성합니다.", + security = [SecurityRequirement(name = ACCESS_TOKEN)], + ) + @ApiResponses( + value = [ + ApiResponse( + responseCode = "201", + description = "포스팅 생성 성공", + content = [ + Content( + mediaType = "application/json", + schema = + Schema( + example = """ + { + "status": "CREATED", + "data": null + } + """, + ), + ), + ], + ), + ApiResponse( + responseCode = "400", + description = "허용되지 않은 이미지 확장자", + content = [ + Content( + mediaType = "application/json", + schema = + Schema( + example = """ + { + "status": "BAD_REQUEST", + "errorMessage": "invalid image extension" + } + """, + ), + ), + ], + ), + ApiResponse( + responseCode = "409", + description = "사용자의 레포지토리가 아직 설정되지 않음", + content = [ + Content( + mediaType = "application/json", + schema = + Schema( + example = """ + { + "status": "CONFLICT", + "errorMessage": "repository not configured" + } + """, + ), + ), + ], + ), + ], + ) + @io.swagger.v3.oas.annotations.parameters.RequestBody( + description = "포스팅 생성 요청 (제목 + 파일)", + required = true, + content = [ + Content( + mediaType = MediaType.MULTIPART_FORM_DATA_VALUE, + ), + ], + ) + fun createPosting( + title: String, + content: MultipartFile, + member: Member, + ): ResponseEntity> + + fun getPostingList(exposedMemberId: String): ResponseEntity>> + + fun getPostingListByGithubUsername(githubUsername: String): ResponseEntity>> + + fun getPosting( + githubUsername: String, + postingId: Long, + ): ResponseEntity> + + fun updatePosting( + id: Long, + title: String, + content: MultipartFile, + member: Member, + ): ResponseEntity> + + @Operation( + summary = "이미지 업로드", + description = "포스팅에 사용할 이미지를 업로드합니다.", + security = [SecurityRequirement(name = ACCESS_TOKEN)], + ) + @ApiResponses( + value = [ + ApiResponse( + responseCode = "201", + description = "이미지 업로드 성공", + content = [ + Content( + mediaType = "application/json", + schema = + Schema( + example = """ + { + "status": "CREATED", + "data": "https://raw.githubusercontent.com/user/repo/main/images/uuid.jpg" + } + """, + ), + ), + ], + ), + ApiResponse( + responseCode = "400", + description = "허용되지 않은 이미지 확장자", + content = [ + Content( + mediaType = "application/json", + schema = + Schema( + example = """ + { + "status": "BAD_REQUEST", + "errorMessage": "invalid image extension" + } + """, + ), + ), + ], + ), + ApiResponse( + responseCode = "409", + description = "사용자의 레포지토리가 아직 설정되지 않음", + content = [ + Content( + mediaType = "application/json", + schema = + Schema( + example = """ + { + "status": "CONFLICT", + "errorMessage": "repository not configured" + } + """, + ), + ), + ], + ), + ], + ) + @io.swagger.v3.oas.annotations.parameters.RequestBody( + description = "업로드할 이미지 파일", + required = true, + content = [ + Content( + mediaType = MediaType.MULTIPART_FORM_DATA_VALUE, + ), + ], + ) + fun uploadImage( + image: MultipartFile, + member: Member, + ): ResponseEntity> +} diff --git a/src/main/kotlin/com/ixfp/gitmon/controller/MemberController.kt b/src/main/kotlin/com/ixfp/gitmon/controller/MemberController.kt index 8f25062..6a3b19f 100644 --- a/src/main/kotlin/com/ixfp/gitmon/controller/MemberController.kt +++ b/src/main/kotlin/com/ixfp/gitmon/controller/MemberController.kt @@ -1,140 +1,81 @@ package com.ixfp.gitmon.controller -import com.ixfp.gitmon.common.util.JwtUtil +import com.ixfp.gitmon.config.web.AUTHENTICATED_MEMBER import com.ixfp.gitmon.controller.request.CreateRepoRequest +import com.ixfp.gitmon.controller.response.CheckRepoNameResponse +import com.ixfp.gitmon.controller.response.GithubRepoUrlResponse +import com.ixfp.gitmon.controller.response.MemberInfoResponse +import com.ixfp.gitmon.controller.type.ApiResponseBody +import com.ixfp.gitmon.controller.util.ApiResponseHelper import com.ixfp.gitmon.domain.auth.AuthService +import com.ixfp.gitmon.domain.member.Member import com.ixfp.gitmon.domain.member.MemberService import io.github.oshai.kotlinlogging.KotlinLogging.logger -import io.swagger.v3.oas.annotations.Operation -import io.swagger.v3.oas.annotations.responses.ApiResponse -import io.swagger.v3.oas.annotations.responses.ApiResponses -import io.swagger.v3.oas.annotations.tags.Tag -import org.springframework.http.HttpStatus import org.springframework.http.ResponseEntity import org.springframework.web.bind.annotation.GetMapping import org.springframework.web.bind.annotation.PostMapping +import org.springframework.web.bind.annotation.RequestAttribute import org.springframework.web.bind.annotation.RequestBody -import org.springframework.web.bind.annotation.RequestHeader import org.springframework.web.bind.annotation.RequestMapping import org.springframework.web.bind.annotation.RequestParam import org.springframework.web.bind.annotation.RestController import javax.naming.AuthenticationException +@RestController @RequestMapping("/api/v1/member") -@RestController() -@Tag(name = "Member", description = "회원 관련 API") class MemberController( - private val jwtUtil: JwtUtil, private val authService: AuthService, private val memberService: MemberService, -) { - @Operation( - summary = "레포지토리 생성/갱신", - description = "이미 설정된 레포지토리가 있다면 갱신하고, 없다면 새로 생성합니다.", - ) - @ApiResponses( - value = [ - ApiResponse( - responseCode = "201", - description = "레포 생성/갱신 성공", - ), - ApiResponse( - responseCode = "200", - description = "레포지토리 이름이 설정된 사용자 레포 이름과 동일함", - ), - ApiResponse( - responseCode = "400", - description = "잘못된 요청 (예: 파라미터 오류 등)", - ), - ApiResponse( - responseCode = "401", - description = "인증 실패 (엑세스 토큰 문제)", - ), - ApiResponse( - responseCode = "500", - description = "서버 내부 오류", - ), - ], - ) +) : IMemberController { + @GetMapping + override fun getMember( + @RequestAttribute(AUTHENTICATED_MEMBER) member: Member, + ): ResponseEntity> { + val response = + MemberInfoResponse( + id = member.exposedId, + githubUsername = member.githubUsername, + repoName = member.repoName, + isRepoCreated = member.repoName != null, + ) + return ApiResponseHelper.success(response) + } + @PostMapping("/repo") - fun upsertRepo( + override fun upsertRepo( @RequestBody request: CreateRepoRequest, - @RequestHeader("Authorization") authorizationHeader: String, - ): ResponseEntity { - try { - // TODO: 엑세스토큰으로 id 혹은 member 객체로 변환하는 필터 만들기 - val accessToken = authorizationHeader.removePrefix("Bearer ") - val memberExposedId = jwtUtil.parseAccessToken(accessToken)?.exposedId - if (memberExposedId == null) { - throw AuthenticationException("유효하지 않은 사용자 토큰") - } - val member = authService.getMemberByExposedId(memberExposedId) - if (member == null) { - throw Error("토큰에 해당하는 사용자를 찾을 수 없음") - } - val githubAccessToken = authService.getGithubAccessToken(member.id) - if (githubAccessToken == null) { - throw Error("사용자의 깃허브 토큰을 찾을 수 없음") - } - if (member.repoName == request.name) { - return ResponseEntity(HttpStatus.OK) - } - memberService.upsertRepo(member, request.name, githubAccessToken) - return ResponseEntity.status(HttpStatus.CREATED).build() - } catch (e: Exception) { - log.error(e) { "Failed to create repository: ${e.message}" } - val status = - when (e) { - is IllegalArgumentException -> HttpStatus.BAD_REQUEST - is AuthenticationException -> HttpStatus.UNAUTHORIZED - else -> HttpStatus.INTERNAL_SERVER_ERROR - } - return ResponseEntity.status(status).build() + @RequestAttribute(AUTHENTICATED_MEMBER) member: Member, + ): ResponseEntity> { + val githubAccessToken = + authService.getGithubAccessToken(member.id) + ?: throw AuthenticationException("사용자의 깃허브 토큰을 찾을 수 없음") + + if (member.repoName == request.name) { + return ApiResponseHelper.success() } + memberService.upsertRepo(member, request.name, githubAccessToken) + return ApiResponseHelper.created() } - @Operation( - summary = "레포지토리 이름 중복 조회", - description = "입력한 레포지토리 이름이 이미 사용 중인지 확인합니다.", - ) - @ApiResponses( - value = [ - ApiResponse(responseCode = "200", description = "사용 가능한 레포지토리 이름"), - ApiResponse(responseCode = "409", description = "이미 사용 중인 레포지토리 이름"), - ApiResponse(responseCode = "401", description = "인증 실패 (엑세스 토큰 문제)"), - ApiResponse(responseCode = "400", description = "잘못된 요청"), - ApiResponse(responseCode = "500", description = "서버 내부 오류"), - ], - ) @GetMapping("/repo/check") - fun checkRepoName( + override fun checkRepoName( @RequestParam name: String, - @RequestHeader("Authorization") authorizationHeader: String, - ): ResponseEntity { - return try { - val accessToken = authorizationHeader.removePrefix("Bearer ") - val memberExposedId = - jwtUtil.parseAccessToken(accessToken)?.exposedId - ?: throw AuthenticationException("유효하지 않은 사용자 토큰") - val member = - authService.getMemberByExposedId(memberExposedId) - ?: throw Error("토큰에 해당하는 사용자를 찾을 수 없음") - val isAvailable = memberService.isRepoNameAvailable(member, name) - if (!isAvailable) { - ResponseEntity.status(HttpStatus.CONFLICT).build() - } else { - ResponseEntity.ok().build() - } - } catch (e: Exception) { - log.error(e) { "Failed to check repository name: ${e.message}" } - val status = - when (e) { - is IllegalArgumentException -> HttpStatus.BAD_REQUEST - is AuthenticationException -> HttpStatus.UNAUTHORIZED - else -> HttpStatus.INTERNAL_SERVER_ERROR - } - ResponseEntity.status(status).build() - } + @RequestAttribute(AUTHENTICATED_MEMBER) member: Member, + ): ResponseEntity> { + val isAvailable = memberService.isRepoNameAvailable(member, name) + return ApiResponseHelper.success(CheckRepoNameResponse(isAvailable)) + } + + @GetMapping("/github/repo") + override fun findGithubRepoUrl( + @RequestParam githubUsername: String, + ): ResponseEntity> { + val githubRepoUrl = + memberService.findGithubRepoUrl(githubUsername) + ?: return ApiResponseHelper.success() + + val response = GithubRepoUrlResponse(githubRepoUrl) + return ApiResponseHelper.success(response) } companion object { diff --git a/src/main/kotlin/com/ixfp/gitmon/controller/Oauth2Controller.kt b/src/main/kotlin/com/ixfp/gitmon/controller/Oauth2Controller.kt index e125fb7..c9bbc68 100644 --- a/src/main/kotlin/com/ixfp/gitmon/controller/Oauth2Controller.kt +++ b/src/main/kotlin/com/ixfp/gitmon/controller/Oauth2Controller.kt @@ -1,115 +1,67 @@ package com.ixfp.gitmon.controller import com.ixfp.gitmon.client.github.GithubApiService +import com.ixfp.gitmon.common.type.Profile import com.ixfp.gitmon.controller.request.GithubOauth2Request -import com.ixfp.gitmon.controller.response.AccessTokenResponse +import com.ixfp.gitmon.controller.response.LoginResponse +import com.ixfp.gitmon.controller.type.ApiResponseBody +import com.ixfp.gitmon.controller.util.ApiResponseHelper +import com.ixfp.gitmon.controller.util.withCookie import com.ixfp.gitmon.domain.auth.AuthService import io.github.oshai.kotlinlogging.KotlinLogging.logger -import io.swagger.v3.oas.annotations.Operation -import io.swagger.v3.oas.annotations.media.ArraySchema -import io.swagger.v3.oas.annotations.media.Content -import io.swagger.v3.oas.annotations.media.Schema -import io.swagger.v3.oas.annotations.responses.ApiResponse -import io.swagger.v3.oas.annotations.responses.ApiResponses -import io.swagger.v3.oas.annotations.tags.Tag -import org.springframework.beans.factory.annotation.Value -import org.springframework.http.HttpStatus -import org.springframework.http.MediaType import org.springframework.http.ResponseEntity import org.springframework.web.bind.annotation.GetMapping import org.springframework.web.bind.annotation.PostMapping import org.springframework.web.bind.annotation.RequestBody import org.springframework.web.bind.annotation.RequestMapping +import org.springframework.web.bind.annotation.RequestParam import org.springframework.web.bind.annotation.RestController -import javax.naming.AuthenticationException +import java.time.Duration @RestController @RequestMapping("/api/v1") -@Tag(name = "OAuth Token Control", description = "OAuth 인증 관련 API") class Oauth2Controller( - @Value("\${oauth2.client.github.id}") private val githubClientId: String, private val authService: AuthService, private val githubApiService: GithubApiService, -) { - @Operation(summary = "Get Oauth2 Token", description = "Github으로부터 Oauth2 Token을 받아오는 API") - @ApiResponses( - value = [ - ApiResponse( - responseCode = "301", - description = "Success", - content = [ - Content( - mediaType = MediaType.APPLICATION_JSON_VALUE, - array = ArraySchema(schema = Schema(implementation = String::class)), - ), - ], - ), - ], - ) +) : IOauth2Controller { @GetMapping("/login/oauth/github") - fun redirectToGithubOauthUrl(): ResponseEntity { - return ResponseEntity.status(HttpStatus.MOVED_PERMANENTLY).header( - "Location", - "https://github.com/login/oauth/authorize?&scope=repo&client_id=$githubClientId", - ).build() + override fun redirectToGithubOauthUrl(profile: String?): ResponseEntity { + val redirectionUrl = githubApiService.getAuthRedirectionUrl(Profile.findByCode(profile)) + return ApiResponseHelper.redirectPermanent(redirectionUrl) } - @Operation( - summary = "gitmon Access 토큰 발급", - description = "Github OAuth 인증 코드를 받아 로그인 처리 후 gitmon의 Access Token을 발급 받음.", - ) - @ApiResponses( - value = [ - ApiResponse( - responseCode = "201", - description = "로그인 및 토큰 발급 성공", - content = [ - Content( - mediaType = MediaType.APPLICATION_JSON_VALUE, - schema = Schema(implementation = AccessTokenResponse::class), - ), - ], - ), - ApiResponse( - responseCode = "400", - description = "잘못된 요청 (code가 유효하지 않은 경우 등)", - ), - ApiResponse( - responseCode = "401", - description = "인증 실패", - ), - ApiResponse( - responseCode = "500", - description = "서버 에러", - ), - ], - ) @PostMapping("/login/oauth/github/tokens") - fun login( + override fun login( + @RequestParam profile: String?, @RequestBody request: GithubOauth2Request, - ): ResponseEntity { - try { - val githubAccessToken = githubApiService.getAccessTokenByCode(request.code) - val githubUser = githubApiService.getGithubUser(githubAccessToken) - var member = authService.getMemberByGithubId(githubUser.id.toLong()) - if (member == null) { - member = authService.signup(githubAccessToken, githubUser) - } - authService.login(githubAccessToken, member) - val accessToken = authService.createAccessToken(member) - val isRepoCreated = member.repoName != null - val response = AccessTokenResponse(accessToken, isRepoCreated) - return ResponseEntity.status(HttpStatus.CREATED).body(response) - } catch (e: Exception) { - log.error(e) { "Failed to login: ${e.message}" } - val status = - when (e) { - is IllegalArgumentException -> HttpStatus.BAD_REQUEST - is AuthenticationException -> HttpStatus.UNAUTHORIZED - else -> HttpStatus.INTERNAL_SERVER_ERROR - } - return ResponseEntity.status(status).build() - } + ): ResponseEntity> { + val githubAccessToken = githubApiService.getAccessTokenByCode(request.code, Profile.findByCode(profile)) + val githubUser = githubApiService.getGithubUser(githubAccessToken) + val member = + authService.getMemberByGithubId(githubUser.id.toLong()) + ?: authService.signup(githubAccessToken, githubUser) + + authService.login(githubAccessToken, member) + + val accessToken = authService.createAccessToken(member) + val refreshToken = authService.createRefreshToken(member) + val isRepoCreated = member.repoName != null + + val response = + LoginResponse( + id = member.exposedId, + accessToken = accessToken, + isRepoCreated = isRepoCreated, + ) + return ApiResponseHelper.success(response).withCookie( + key = "refreshToken", + value = refreshToken, + maxAge = Duration.ofDays(14), + path = "/api/v1/refresh", + httpOnly = true, + secure = true, + sameSite = "Lax", + ) } companion object { diff --git a/src/main/kotlin/com/ixfp/gitmon/controller/PostingController.kt b/src/main/kotlin/com/ixfp/gitmon/controller/PostingController.kt index b55edd4..08a1923 100644 --- a/src/main/kotlin/com/ixfp/gitmon/controller/PostingController.kt +++ b/src/main/kotlin/com/ixfp/gitmon/controller/PostingController.kt @@ -1,46 +1,95 @@ package com.ixfp.gitmon.controller -import com.ixfp.gitmon.common.util.JwtUtil -import com.ixfp.gitmon.controller.request.CreatePostingRequest -import com.ixfp.gitmon.domain.auth.AuthService +import com.ixfp.gitmon.config.web.AUTHENTICATED_MEMBER +import com.ixfp.gitmon.controller.type.ApiResponseBody +import com.ixfp.gitmon.controller.util.ApiResponseHelper +import com.ixfp.gitmon.domain.member.Member +import com.ixfp.gitmon.domain.posting.PostingReadDto import com.ixfp.gitmon.domain.posting.PostingService -import org.springframework.http.HttpStatus +import io.github.oshai.kotlinlogging.KotlinLogging.logger +import org.springframework.http.MediaType import org.springframework.http.ResponseEntity +import org.springframework.web.bind.annotation.GetMapping +import org.springframework.web.bind.annotation.PathVariable import org.springframework.web.bind.annotation.PostMapping -import org.springframework.web.bind.annotation.RequestBody -import org.springframework.web.bind.annotation.RequestHeader +import org.springframework.web.bind.annotation.PutMapping +import org.springframework.web.bind.annotation.RequestAttribute import org.springframework.web.bind.annotation.RequestMapping +import org.springframework.web.bind.annotation.RequestParam +import org.springframework.web.bind.annotation.RequestPart import org.springframework.web.bind.annotation.RestController -import javax.naming.AuthenticationException +import org.springframework.web.multipart.MultipartFile -@RequestMapping("/api/v1/posting") @RestController +@RequestMapping("/api/v1/posting") class PostingController( - private val jwtUtil: JwtUtil, - private val authService: AuthService, private val postingService: PostingService, -) { - @PostMapping - fun createPosting( - @RequestBody request: CreatePostingRequest, - @RequestHeader("Authorization") authorizationHeader: String - ): ResponseEntity { - try { - val accessToken = authorizationHeader.removePrefix("Bearer ") - val memberExposedId = jwtUtil.parseAccessToken(accessToken)?.exposedId - if (memberExposedId == null) { - throw AuthenticationException("유효하지 않은 사용자 토큰") - } - val member = authService.getMemberByExposedId(memberExposedId) - if (member == null) { - throw Error("토큰에 해당하는 사용자를 찾을 수 없음") - } - - postingService.create(member, request.title, request.content) - - return ResponseEntity(HttpStatus.CREATED) - } catch (e: Exception) { - return ResponseEntity(HttpStatus.INTERNAL_SERVER_ERROR) - } +) : IPostingController { + @PostMapping( + consumes = [MediaType.MULTIPART_FORM_DATA_VALUE], + ) + override fun createPosting( + @RequestParam title: String, + @RequestPart content: MultipartFile, + @RequestAttribute(AUTHENTICATED_MEMBER) member: Member, + ): ResponseEntity> { + val savedPostingId = postingService.create(member, title, content) + val posting = postingService.findPosting(savedPostingId) + return ApiResponseHelper.created(posting) + } + + @PutMapping( + consumes = [MediaType.MULTIPART_FORM_DATA_VALUE], + ) + override fun updatePosting( + @RequestParam id: Long, + @RequestParam title: String, + @RequestPart content: MultipartFile, + @RequestAttribute(AUTHENTICATED_MEMBER) member: Member, + ): ResponseEntity> { + val updatedPosting = postingService.update(member, id, title, content) + val posting = postingService.findPosting(updatedPosting) + return ApiResponseHelper.success(posting) + } + + @GetMapping("/{exposedMemberId}") + override fun getPostingList( + @PathVariable exposedMemberId: String, + ): ResponseEntity>> { + val postingList = postingService.findPostingListByMemberExposedId(exposedMemberId) + return ApiResponseHelper.success(postingList) + } + + @GetMapping("/github/{githubUsername}") + override fun getPostingListByGithubUsername( + @PathVariable githubUsername: String, + ): ResponseEntity>> { + val postingList = postingService.findPostingListByGithubUsername(githubUsername) + return ApiResponseHelper.success(postingList) + } + + @GetMapping("/github/{githubUsername}/{postingId}") + override fun getPosting( + @PathVariable githubUsername: String, + @PathVariable postingId: Long, + ): ResponseEntity> { + val posting = postingService.findPosting(githubUsername, postingId) + return ApiResponseHelper.success(posting) + } + + @PostMapping( + "/images", + consumes = [MediaType.MULTIPART_FORM_DATA_VALUE], + ) + override fun uploadImage( + @RequestPart image: MultipartFile, + @RequestAttribute(AUTHENTICATED_MEMBER) member: Member, + ): ResponseEntity> { + val imageUrl = postingService.uploadImage(member, image) + return ApiResponseHelper.created(imageUrl) + } + + companion object { + private val log = logger {} } -} \ No newline at end of file +} diff --git a/src/main/kotlin/com/ixfp/gitmon/controller/request/CreatePostingRequest.kt b/src/main/kotlin/com/ixfp/gitmon/controller/request/CreatePostingRequest.kt deleted file mode 100644 index a286f86..0000000 --- a/src/main/kotlin/com/ixfp/gitmon/controller/request/CreatePostingRequest.kt +++ /dev/null @@ -1,6 +0,0 @@ -package com.ixfp.gitmon.controller.request - -data class CreatePostingRequest( - val title: String, - val content: String, -) diff --git a/src/main/kotlin/com/ixfp/gitmon/controller/response/CheckRepoNameResponse.kt b/src/main/kotlin/com/ixfp/gitmon/controller/response/CheckRepoNameResponse.kt new file mode 100644 index 0000000..06dc3ff --- /dev/null +++ b/src/main/kotlin/com/ixfp/gitmon/controller/response/CheckRepoNameResponse.kt @@ -0,0 +1,5 @@ +package com.ixfp.gitmon.controller.response + +data class CheckRepoNameResponse( + val isAvailable: Boolean, +) diff --git a/src/main/kotlin/com/ixfp/gitmon/controller/response/GithubRepoUrlResponse.kt b/src/main/kotlin/com/ixfp/gitmon/controller/response/GithubRepoUrlResponse.kt new file mode 100644 index 0000000..011bfdb --- /dev/null +++ b/src/main/kotlin/com/ixfp/gitmon/controller/response/GithubRepoUrlResponse.kt @@ -0,0 +1,5 @@ +package com.ixfp.gitmon.controller.response + +data class GithubRepoUrlResponse( + val githubRepoUrl: String, +) diff --git a/src/main/kotlin/com/ixfp/gitmon/controller/response/AccessTokenResponse.kt b/src/main/kotlin/com/ixfp/gitmon/controller/response/LoginResponse.kt similarity index 70% rename from src/main/kotlin/com/ixfp/gitmon/controller/response/AccessTokenResponse.kt rename to src/main/kotlin/com/ixfp/gitmon/controller/response/LoginResponse.kt index a72d697..789dbfa 100644 --- a/src/main/kotlin/com/ixfp/gitmon/controller/response/AccessTokenResponse.kt +++ b/src/main/kotlin/com/ixfp/gitmon/controller/response/LoginResponse.kt @@ -1,6 +1,7 @@ package com.ixfp.gitmon.controller.response -data class AccessTokenResponse( +data class LoginResponse( + val id: String, val accessToken: String, val isRepoCreated: Boolean, ) diff --git a/src/main/kotlin/com/ixfp/gitmon/controller/response/MemberInfoResponse.kt b/src/main/kotlin/com/ixfp/gitmon/controller/response/MemberInfoResponse.kt new file mode 100644 index 0000000..9b5f7fa --- /dev/null +++ b/src/main/kotlin/com/ixfp/gitmon/controller/response/MemberInfoResponse.kt @@ -0,0 +1,8 @@ +package com.ixfp.gitmon.controller.response + +data class MemberInfoResponse( + val id: String, + val githubUsername: String, + val isRepoCreated: Boolean, + val repoName: String?, +) diff --git a/src/main/kotlin/com/ixfp/gitmon/controller/response/RefreshResponse.kt b/src/main/kotlin/com/ixfp/gitmon/controller/response/RefreshResponse.kt new file mode 100644 index 0000000..ae3c68a --- /dev/null +++ b/src/main/kotlin/com/ixfp/gitmon/controller/response/RefreshResponse.kt @@ -0,0 +1,5 @@ +package com.ixfp.gitmon.controller.response + +data class RefreshResponse( + val accessToken: String, +) diff --git a/src/main/kotlin/com/ixfp/gitmon/controller/type/ApiError.kt b/src/main/kotlin/com/ixfp/gitmon/controller/type/ApiError.kt new file mode 100644 index 0000000..c7d287c --- /dev/null +++ b/src/main/kotlin/com/ixfp/gitmon/controller/type/ApiError.kt @@ -0,0 +1,16 @@ +package com.ixfp.gitmon.controller.type + +import org.springframework.http.HttpStatus + +enum class ApiError(val httpStatus: HttpStatus, val message: String) { + BAD_REQUEST(HttpStatus.BAD_REQUEST, "Bad request"), + UNAUTHORIZED(HttpStatus.UNAUTHORIZED, "Unauthorized"), + FORBIDDEN(HttpStatus.FORBIDDEN, "forbidden"), + CONFLICT(HttpStatus.CONFLICT, "conflict"), + REPOSITORY_NOT_CONFIGURED(HttpStatus.CONFLICT, "repository not configured"), + INVALID_AUTH_CODE(HttpStatus.UNAUTHORIZED, "Invalid auth code"), + INVALID_ACCESS_TOKEN(HttpStatus.UNAUTHORIZED, "Invalid access token"), + INVALID_REFRESH_TOKEN(HttpStatus.UNAUTHORIZED, "Invalid refresh token"), + EXPIRED_ACCESS_TOKEN(HttpStatus.UNAUTHORIZED, "Access token expired"), + INTERNAL_SERVER_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "Internal server error"), +} diff --git a/src/main/kotlin/com/ixfp/gitmon/controller/type/ApiResponseBody.kt b/src/main/kotlin/com/ixfp/gitmon/controller/type/ApiResponseBody.kt new file mode 100644 index 0000000..5532ec3 --- /dev/null +++ b/src/main/kotlin/com/ixfp/gitmon/controller/type/ApiResponseBody.kt @@ -0,0 +1,42 @@ +package com.ixfp.gitmon.controller.type + +import org.springframework.http.HttpStatus + +data class ApiResponseBody( + val status: HttpStatus, + val statusCode: Int, + val data: T? = null, + val errorMessage: String? = null, +) { + companion object { + fun success(): ApiResponseBody { + return ApiResponseBody( + status = HttpStatus.OK, + statusCode = HttpStatus.OK.value(), + ) + } + + fun success( + status: HttpStatus = HttpStatus.OK, + data: T?, + ): ApiResponseBody { + return ApiResponseBody( + status = status, + statusCode = status.value(), + data = data, + ) + } + + fun error( + error: ApiError, + data: T? = null, + ): ApiResponseBody { + return ApiResponseBody( + status = error.httpStatus, + statusCode = error.httpStatus.value(), + errorMessage = error.message, + data = data, + ) + } + } +} diff --git a/src/main/kotlin/com/ixfp/gitmon/controller/util/ApiResponseHelper.kt b/src/main/kotlin/com/ixfp/gitmon/controller/util/ApiResponseHelper.kt new file mode 100644 index 0000000..03c4cda --- /dev/null +++ b/src/main/kotlin/com/ixfp/gitmon/controller/util/ApiResponseHelper.kt @@ -0,0 +1,78 @@ +package com.ixfp.gitmon.controller.util + +import com.ixfp.gitmon.controller.type.ApiError +import com.ixfp.gitmon.controller.type.ApiResponseBody +import org.springframework.http.HttpHeaders +import org.springframework.http.HttpStatus +import org.springframework.http.ResponseCookie +import org.springframework.http.ResponseEntity +import java.time.Duration + +object ApiResponseHelper { + fun success(data: T? = null): ResponseEntity> { + return ResponseEntity + .status(HttpStatus.OK) + .body(ApiResponseBody.success(HttpStatus.OK, data)) + } + + fun created(data: T? = null): ResponseEntity> { + return ResponseEntity + .status(HttpStatus.CREATED) + .body(ApiResponseBody.success(HttpStatus.CREATED, data)) + } + + fun redirectPermanent(location: String): ResponseEntity { + return ResponseEntity + .status(HttpStatus.MOVED_PERMANENTLY) + .header(HttpHeaders.LOCATION, location) + .build() + } + + fun custom( + status: HttpStatus, + data: T? = null, + ): ResponseEntity> { + return ResponseEntity + .status(status) + .body(ApiResponseBody.success(status, data)) + } + + fun error( + apiError: ApiError, + data: T? = null, + ): ResponseEntity> { + return ResponseEntity + .status(apiError.httpStatus) + .body(ApiResponseBody.error(apiError, data)) + } +} + +fun ResponseEntity>.withCookie( + key: String, + value: String, + maxAge: Duration = Duration.ofDays(14), + path: String = "/api/v1/refresh", + httpOnly: Boolean = true, + secure: Boolean = true, + sameSite: String = "Lax", +): ResponseEntity> { + val cookie = + ResponseCookie.from(key, value) + .path(path) + .httpOnly(httpOnly) + .secure(secure) + .sameSite(sameSite) + .apply { maxAge?.let { maxAge(it) } } + .build() + + val headers = + HttpHeaders().apply { + putAll(this@withCookie.headers) // 기존 헤더 유지 + add(HttpHeaders.SET_COOKIE, cookie.toString()) + } + + return ResponseEntity + .status(this.statusCode) + .headers(headers) + .body(this.body) +} diff --git a/src/main/kotlin/com/ixfp/gitmon/db/exception/DbExceptionStrategy.kt b/src/main/kotlin/com/ixfp/gitmon/db/exception/DbExceptionStrategy.kt new file mode 100644 index 0000000..3154311 --- /dev/null +++ b/src/main/kotlin/com/ixfp/gitmon/db/exception/DbExceptionStrategy.kt @@ -0,0 +1,23 @@ +package com.ixfp.gitmon.db.exception + +import com.ixfp.gitmon.common.aop.ExceptionWrappingStrategy +import com.ixfp.gitmon.common.exception.AppException +import jakarta.persistence.PersistenceException +import org.springframework.dao.DataAccessResourceFailureException +import org.springframework.stereotype.Component +import java.sql.SQLException +import org.springframework.dao.DataAccessException as SpringDataAccessException + +@Component +class DbExceptionStrategy : ExceptionWrappingStrategy { + override fun wrap(e: Throwable): AppException = + when (e) { + is DbException -> e + is DataAccessResourceFailureException, + is SQLException, + -> DbConnectionException(cause = e) + is SpringDataAccessException -> DbQueryExecutionException(cause = e) + is PersistenceException -> DbMappingException(cause = e) + else -> DbUnexpectedException(cause = e) + } +} diff --git a/src/main/kotlin/com/ixfp/gitmon/db/exception/DbExceptions.kt b/src/main/kotlin/com/ixfp/gitmon/db/exception/DbExceptions.kt new file mode 100644 index 0000000..3785839 --- /dev/null +++ b/src/main/kotlin/com/ixfp/gitmon/db/exception/DbExceptions.kt @@ -0,0 +1,28 @@ +package com.ixfp.gitmon.db.exception + +import com.ixfp.gitmon.common.exception.RepositoryException + +sealed class DbException( + message: String, + cause: Throwable? = null, +) : RepositoryException(message, cause) + +class DbConnectionException( + message: String = "데이터베이스 연결에 실패했습니다.", + cause: Throwable? = null, +) : DbException(message, cause) + +class DbQueryExecutionException( + message: String = "SQL 쿼리 실행 중 예외가 발생했습니다.", + cause: Throwable? = null, +) : DbException(message, cause) + +class DbMappingException( + message: String = "DB 결과를 매핑하는 데 실패했습니다.", + cause: Throwable? = null, +) : DbException(message, cause) + +class DbUnexpectedException( + message: String = "예상치 못한 DB 관련 예외가 발생했습니다.", + cause: Throwable? = null, +) : DbException(message, cause) diff --git a/src/main/kotlin/com/ixfp/gitmon/db/member/MemberRepository.kt b/src/main/kotlin/com/ixfp/gitmon/db/member/MemberRepository.kt index c516265..f5d649b 100644 --- a/src/main/kotlin/com/ixfp/gitmon/db/member/MemberRepository.kt +++ b/src/main/kotlin/com/ixfp/gitmon/db/member/MemberRepository.kt @@ -10,7 +10,7 @@ interface MemberRepository : JpaRepository { fun findByGithubId(githubId: Long): MemberEntity? - fun findByGithubUsername(githubUsername: String): List + fun findByGithubUsername(githubUsername: String): MemberEntity? fun findByExposedId(exposedId: String): MemberEntity? } diff --git a/src/main/kotlin/com/ixfp/gitmon/db/posting/PostingEntity.kt b/src/main/kotlin/com/ixfp/gitmon/db/posting/PostingEntity.kt new file mode 100644 index 0000000..5f7b009 --- /dev/null +++ b/src/main/kotlin/com/ixfp/gitmon/db/posting/PostingEntity.kt @@ -0,0 +1,21 @@ +package com.ixfp.gitmon.db.posting + +import com.ixfp.gitmon.db.BaseEntity +import jakarta.persistence.Entity +import jakarta.persistence.GeneratedValue +import jakarta.persistence.GenerationType +import jakarta.persistence.Id +import jakarta.persistence.Table + +@Entity +@Table(name = "posting") +class PostingEntity( + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + val id: Long = 0, + var title: String, + val refMemberId: Long, + var githubFilePath: String, + var githubFileSha: String, + var githubDownloadUrl: String, +) : BaseEntity() diff --git a/src/main/kotlin/com/ixfp/gitmon/db/posting/PostingRepository.kt b/src/main/kotlin/com/ixfp/gitmon/db/posting/PostingRepository.kt new file mode 100644 index 0000000..02c40f5 --- /dev/null +++ b/src/main/kotlin/com/ixfp/gitmon/db/posting/PostingRepository.kt @@ -0,0 +1,7 @@ +package com.ixfp.gitmon.db.posting + +import org.springframework.data.jpa.repository.JpaRepository + +interface PostingRepository : JpaRepository { + fun findByRefMemberId(refMemberId: Long): List +} diff --git a/src/main/kotlin/com/ixfp/gitmon/domain/auth/AuthService.kt b/src/main/kotlin/com/ixfp/gitmon/domain/auth/AuthService.kt index 6f77e69..c5d2940 100644 --- a/src/main/kotlin/com/ixfp/gitmon/domain/auth/AuthService.kt +++ b/src/main/kotlin/com/ixfp/gitmon/domain/auth/AuthService.kt @@ -1,7 +1,9 @@ package com.ixfp.gitmon.domain.auth import com.ixfp.gitmon.client.github.response.GithubUserResponse +import com.ixfp.gitmon.common.aop.WrapWith import com.ixfp.gitmon.common.util.JwtUtil +import com.ixfp.gitmon.domain.auth.exception.AuthExceptionStrategy import com.ixfp.gitmon.domain.member.Member import com.ixfp.gitmon.domain.member.MemberReader import com.ixfp.gitmon.domain.member.MemberWriter @@ -9,6 +11,7 @@ import jakarta.transaction.Transactional import org.springframework.stereotype.Service import java.util.UUID +@WrapWith(AuthExceptionStrategy::class) @Service class AuthService( private val jwtUtil: JwtUtil, @@ -43,6 +46,10 @@ class AuthService( return jwtUtil.createAccessToken(member.exposedId) } + fun createRefreshToken(member: Member): String { + return jwtUtil.createRefreshToken(member.exposedId) + } + fun getGithubAccessToken(memberId: Long): String? { return memberReader.findAccessTokenByMemberId(memberId) } diff --git a/src/main/kotlin/com/ixfp/gitmon/domain/auth/exception/AuthExceptionStrategy.kt b/src/main/kotlin/com/ixfp/gitmon/domain/auth/exception/AuthExceptionStrategy.kt new file mode 100644 index 0000000..cb5e4b9 --- /dev/null +++ b/src/main/kotlin/com/ixfp/gitmon/domain/auth/exception/AuthExceptionStrategy.kt @@ -0,0 +1,13 @@ +package com.ixfp.gitmon.domain.auth.exception + +import com.ixfp.gitmon.common.aop.ExceptionWrappingStrategy +import com.ixfp.gitmon.common.exception.AppException + +class AuthExceptionStrategy : ExceptionWrappingStrategy { + override fun wrap(e: Throwable): AppException { + return when (e) { + is AuthException -> e + else -> AuthUnexpectedException(cause = e) + } + } +} diff --git a/src/main/kotlin/com/ixfp/gitmon/domain/auth/exception/AuthExceptions.kt b/src/main/kotlin/com/ixfp/gitmon/domain/auth/exception/AuthExceptions.kt new file mode 100644 index 0000000..c00165d --- /dev/null +++ b/src/main/kotlin/com/ixfp/gitmon/domain/auth/exception/AuthExceptions.kt @@ -0,0 +1,13 @@ +package com.ixfp.gitmon.domain.auth.exception + +import com.ixfp.gitmon.common.exception.DomainException + +sealed class AuthException( + message: String, + cause: Throwable? = null, +) : DomainException(message, cause) + +class AuthUnexpectedException( + message: String = "예상치 못한 인증 도메인 예외가 발생했습니다.", + cause: Throwable? = null, +) : AuthException(message, cause) diff --git a/src/main/kotlin/com/ixfp/gitmon/domain/member/MemberReader.kt b/src/main/kotlin/com/ixfp/gitmon/domain/member/MemberReader.kt index 8c65e6f..d0a685b 100644 --- a/src/main/kotlin/com/ixfp/gitmon/domain/member/MemberReader.kt +++ b/src/main/kotlin/com/ixfp/gitmon/domain/member/MemberReader.kt @@ -1,10 +1,13 @@ package com.ixfp.gitmon.domain.member +import com.ixfp.gitmon.common.aop.WrapWith +import com.ixfp.gitmon.db.exception.DbExceptionStrategy import com.ixfp.gitmon.db.github.GithubAuthRepository import com.ixfp.gitmon.db.member.MemberEntity import com.ixfp.gitmon.db.member.MemberRepository import org.springframework.stereotype.Component +@WrapWith(DbExceptionStrategy::class) @Component class MemberReader( private val memberRepository: MemberRepository, @@ -24,6 +27,10 @@ class MemberReader( return memberRepository.findByGithubId(githubId)?.let { MemberEntity.toMember(it) } } + fun findByGithubUsername(githubUsername: String): Member? { + return memberRepository.findByGithubUsername(githubUsername)?.let { MemberEntity.toMember(it) } + } + fun findAccessTokenByMemberId(memberId: Long): String? { // TODO: 깃허브 토큰만 조회하게 변경 return githubAuthRepository.findByMemberId(memberId)?.githubAccessToken diff --git a/src/main/kotlin/com/ixfp/gitmon/domain/member/MemberService.kt b/src/main/kotlin/com/ixfp/gitmon/domain/member/MemberService.kt index a7a1dc3..58ce734 100644 --- a/src/main/kotlin/com/ixfp/gitmon/domain/member/MemberService.kt +++ b/src/main/kotlin/com/ixfp/gitmon/domain/member/MemberService.kt @@ -1,16 +1,35 @@ package com.ixfp.gitmon.domain.member -import com.ixfp.gitmon.client.github.GithubResourceApiClient +import com.ixfp.gitmon.client.github.GithubApiService import com.ixfp.gitmon.client.github.request.GithubCreateRepositoryRequest -import feign.FeignException +import com.ixfp.gitmon.common.aop.WrapWith +import com.ixfp.gitmon.domain.member.exception.MemberExceptionStrategy +import com.ixfp.gitmon.domain.member.exception.MemberGithubAccessTokenNotFoundException +import com.ixfp.gitmon.domain.member.exception.MemberNotFoundException import org.springframework.stereotype.Service +@WrapWith(MemberExceptionStrategy::class) @Service class MemberService( - private val githubResourceApiClient: GithubResourceApiClient, + private val githubApiService: GithubApiService, private val memberWriter: MemberWriter, private val memberReader: MemberReader, ) { + fun getMemberByExposedId(exposedId: String): Member { + return memberReader.findByExposedId(exposedId) + ?: throw MemberNotFoundException(message = "exposedId: $exposedId") + } + + fun getMemberByGithubUsername(githubUsername: String): Member { + return memberReader.findByGithubUsername(githubUsername) + ?: throw MemberNotFoundException(message = "githubUsername: $githubUsername") + } + + fun getGithubAccessTokenById(id: Long): String { + return memberReader.findAccessTokenByMemberId(id) + ?: throw MemberGithubAccessTokenNotFoundException(message = "memberId: $id") + } + fun upsertRepo( member: Member, repoName: String, @@ -24,7 +43,7 @@ class MemberService( private = false, ) // TODO: DB 레포지토리 업데이트 오류 시 생성된 레포지토리를 지워야 함 - githubResourceApiClient.createRepository("Bearer $githubAccessToken", githubRequest) + githubApiService.createRepository(githubAccessToken, githubRequest) memberWriter.upsertRepo(member, repoName) } @@ -38,22 +57,32 @@ class MemberService( return true } + fun findGithubRepoUrl(githubUsername: String): String? { + val member = memberReader.findByGithubUsername(githubUsername) ?: return null + + if (member.repoName == null) { + return null + } + + return buildGithubRepoUrl(member.githubUsername, member.repoName) + } + + private fun buildGithubRepoUrl( + githubUsername: String, + repoName: String, + ): String { + return "https://github.com/$githubUsername/$repoName" + } + private fun isRepoNameDuplicate( member: Member, repoName: String, ): Boolean { - val githubAccessToken = memberReader.findAccessTokenByMemberId(member.id) ?: throw Error("Github 엑세스 토큰 없음") - return try { - githubResourceApiClient.fetchRepository( - token = "token $githubAccessToken", - owner = member.githubUsername, - repo = repoName, - ) - true - } catch (e: FeignException.NotFound) { - false - } catch (e: Exception) { - throw RuntimeException("GitHub API 요청 실패: ${e.message}") - } + val githubAccessToken = getGithubAccessTokenById(member.id) + return githubApiService.isRepositoryExist( + token = "token $githubAccessToken", + owner = member.githubUsername, + repo = repoName, + ) } } diff --git a/src/main/kotlin/com/ixfp/gitmon/domain/member/MemberWriter.kt b/src/main/kotlin/com/ixfp/gitmon/domain/member/MemberWriter.kt index b2eb782..c09d3e9 100644 --- a/src/main/kotlin/com/ixfp/gitmon/domain/member/MemberWriter.kt +++ b/src/main/kotlin/com/ixfp/gitmon/domain/member/MemberWriter.kt @@ -1,5 +1,7 @@ package com.ixfp.gitmon.domain.member +import com.ixfp.gitmon.common.aop.WrapWith +import com.ixfp.gitmon.db.exception.DbExceptionStrategy import com.ixfp.gitmon.db.github.GithubAuthEntity import com.ixfp.gitmon.db.github.GithubAuthRepository import com.ixfp.gitmon.db.member.MemberEntity @@ -7,6 +9,7 @@ import com.ixfp.gitmon.db.member.MemberRepository import jakarta.transaction.Transactional import org.springframework.stereotype.Component +@WrapWith(DbExceptionStrategy::class) @Component class MemberWriter( private val memberRepository: MemberRepository, diff --git a/src/main/kotlin/com/ixfp/gitmon/domain/member/exception/MemberExceptionStrategy.kt b/src/main/kotlin/com/ixfp/gitmon/domain/member/exception/MemberExceptionStrategy.kt new file mode 100644 index 0000000..b4bacba --- /dev/null +++ b/src/main/kotlin/com/ixfp/gitmon/domain/member/exception/MemberExceptionStrategy.kt @@ -0,0 +1,15 @@ +package com.ixfp.gitmon.domain.member.exception + +import com.ixfp.gitmon.common.aop.ExceptionWrappingStrategy +import com.ixfp.gitmon.common.exception.AppException +import org.springframework.stereotype.Component + +@Component +class MemberExceptionStrategy : ExceptionWrappingStrategy { + override fun wrap(e: Throwable): AppException { + return when (e) { + is MemberException -> e + else -> MemberUnexpectedException(cause = e) + } + } +} diff --git a/src/main/kotlin/com/ixfp/gitmon/domain/member/exception/MemberExceptions.kt b/src/main/kotlin/com/ixfp/gitmon/domain/member/exception/MemberExceptions.kt new file mode 100644 index 0000000..58441df --- /dev/null +++ b/src/main/kotlin/com/ixfp/gitmon/domain/member/exception/MemberExceptions.kt @@ -0,0 +1,23 @@ +package com.ixfp.gitmon.domain.member.exception + +import com.ixfp.gitmon.common.exception.DomainException + +sealed class MemberException( + message: String, + cause: Throwable? = null, +) : DomainException(message, cause) + +class MemberUnexpectedException( + message: String = "예상치 못한 회원 도메인 예외가 발생했습니다.", + cause: Throwable? = null, +) : MemberException(message, cause) + +class MemberNotFoundException( + message: String = "해당 회원을 찾을 수 없습니다.", + cause: Throwable? = null, +) : MemberException(message, cause) + +class MemberGithubAccessTokenNotFoundException( + message: String = "해당 회원의 GitHub 액세스 토큰이 존재하지 않습니다.", + cause: Throwable? = null, +) : MemberException(message, cause) diff --git a/src/main/kotlin/com/ixfp/gitmon/domain/posting/CommitMessageGenerator.kt b/src/main/kotlin/com/ixfp/gitmon/domain/posting/CommitMessageGenerator.kt new file mode 100644 index 0000000..85a2182 --- /dev/null +++ b/src/main/kotlin/com/ixfp/gitmon/domain/posting/CommitMessageGenerator.kt @@ -0,0 +1,19 @@ +package com.ixfp.gitmon.domain.posting + +object CommitMessageGenerator { + fun createMessage(title: String): String { + return "Create \"$title\" - by Gitmon 👾" + } + + fun updateMessage(title: String): String { + return "Update \"$title\" - by Gitmon 👾" + } + + fun deleteMessage(title: String): String { + return "Delete \"$title\" - by Gitmon 👾" + } + + fun imageUploadMessage(): String { + return "Upload image - by Gitmon 👾" + } +} diff --git a/src/main/kotlin/com/ixfp/gitmon/domain/posting/Posting.kt b/src/main/kotlin/com/ixfp/gitmon/domain/posting/Posting.kt new file mode 100644 index 0000000..b0f76b3 --- /dev/null +++ b/src/main/kotlin/com/ixfp/gitmon/domain/posting/Posting.kt @@ -0,0 +1,14 @@ +package com.ixfp.gitmon.domain.posting + +import java.time.LocalDateTime + +data class Posting( + val id: Long, + val title: String, + val refMemberId: Long, + val githubFilePath: String, + val githubFileSha: String, + val githubDownloadUrl: String, + val createdAt: LocalDateTime, + val updatedAt: LocalDateTime, +) diff --git a/src/main/kotlin/com/ixfp/gitmon/domain/posting/PostingReadDto.kt b/src/main/kotlin/com/ixfp/gitmon/domain/posting/PostingReadDto.kt new file mode 100644 index 0000000..e21121c --- /dev/null +++ b/src/main/kotlin/com/ixfp/gitmon/domain/posting/PostingReadDto.kt @@ -0,0 +1,11 @@ +package com.ixfp.gitmon.domain.posting + +import java.time.LocalDateTime + +data class PostingReadDto( + val id: Long, + val title: String, + val githubDownloadUrl: String, + val createdAt: LocalDateTime, + val updatedAt: LocalDateTime, +) diff --git a/src/main/kotlin/com/ixfp/gitmon/domain/posting/PostingReader.kt b/src/main/kotlin/com/ixfp/gitmon/domain/posting/PostingReader.kt new file mode 100644 index 0000000..beb5066 --- /dev/null +++ b/src/main/kotlin/com/ixfp/gitmon/domain/posting/PostingReader.kt @@ -0,0 +1,36 @@ +package com.ixfp.gitmon.domain.posting + +import com.ixfp.gitmon.common.aop.WrapWith +import com.ixfp.gitmon.db.exception.DbExceptionStrategy +import com.ixfp.gitmon.db.posting.PostingEntity +import com.ixfp.gitmon.db.posting.PostingRepository +import org.springframework.stereotype.Component + +@WrapWith(DbExceptionStrategy::class) +@Component +class PostingReader( + private val postingRepository: PostingRepository, +) { + fun findPostingListByMemberId(memberId: Long): List { + return postingRepository.findByRefMemberId(memberId).map { toPosting(it) } + } + + fun findPostingById(postingId: Long): Posting { + return postingRepository.findById(postingId) + .orElseThrow { IllegalArgumentException("Posting with id $postingId not found") } + .let { toPosting(it) } + } + + private fun toPosting(entity: PostingEntity): Posting { + return Posting( + id = entity.id, + title = entity.title, + refMemberId = entity.refMemberId, + githubFilePath = entity.githubFilePath, + githubFileSha = entity.githubFileSha, + githubDownloadUrl = entity.githubDownloadUrl, + createdAt = entity.createdAt, + updatedAt = entity.updatedAt, + ) + } +} diff --git a/src/main/kotlin/com/ixfp/gitmon/domain/posting/PostingService.kt b/src/main/kotlin/com/ixfp/gitmon/domain/posting/PostingService.kt index 0602500..7db461c 100644 --- a/src/main/kotlin/com/ixfp/gitmon/domain/posting/PostingService.kt +++ b/src/main/kotlin/com/ixfp/gitmon/domain/posting/PostingService.kt @@ -1,17 +1,198 @@ package com.ixfp.gitmon.domain.posting +import com.ixfp.gitmon.client.github.GithubApiService +import com.ixfp.gitmon.common.aop.WrapWith import com.ixfp.gitmon.domain.member.Member -import com.ixfp.gitmon.domain.member.MemberReader +import com.ixfp.gitmon.domain.member.MemberService +import com.ixfp.gitmon.domain.posting.exception.InvalidImageExtensionException +import com.ixfp.gitmon.domain.posting.exception.PostingAuthorizationException +import com.ixfp.gitmon.domain.posting.exception.PostingExceptionStrategy +import com.ixfp.gitmon.domain.posting.exception.PostingRepositoryNotFoundException +import io.github.oshai.kotlinlogging.KotlinLogging.logger import org.springframework.stereotype.Service +import org.springframework.web.multipart.MultipartFile +import java.util.UUID +@WrapWith(PostingExceptionStrategy::class) @Service class PostingService( - private val memberReader: MemberReader, + private val memberService: MemberService, + private val githubApiService: GithubApiService, + private val postingReader: PostingReader, + private val postingWriter: PostingWriter, ) { - fun create(member: Member, title: String, content: String) { - val githubAccessToken = memberReader.findAccessTokenByMemberId(member.id) - ?: throw Error("Github 엑세스 토큰 없음") + fun findPostingListByMemberExposedId(memberExposedId: String): List { + val member = memberService.getMemberByExposedId(memberExposedId) + return postingReader.findPostingListByMemberId(member.id) + .map { toPostingReadDto(it) } + } + + fun findPostingListByGithubUsername(githubUsername: String): List { + log.info { "[PostingService] 포스팅 목록 조회 시작. githubUsername=$githubUsername" } + + val member = memberService.getMemberByGithubUsername(githubUsername) + val postingList = postingReader.findPostingListByMemberId(member.id) + + log.info { "[PostingService] 포스팅 목록 조회 완료. githubUsername=${member.githubUsername}, postingList.size=${postingList.size}" } + return postingList.map { toPostingReadDto(it) } + } + + fun findPosting(postingId: Long): PostingReadDto { + log.info { "[PostingService] 포스팅 조회 시작. postingId=$postingId" } + + val posting = postingReader.findPostingById(postingId) + + log.info { "[PostingService] 포스팅 조회 완료. posting=$posting" } + return toPostingReadDto(posting) + } + + fun findPosting( + githubUsername: String, + postingId: Long, + ): PostingReadDto? { + log.info { "[PostingService] 포스팅 조회 시작. githubUsername=$githubUsername, postingId=$postingId" } + + val member = memberService.getMemberByGithubUsername(githubUsername) + val posting = + postingReader.findPostingListByMemberId(member.id) + .find { it.id == postingId } + + log.info { "[PostingService] 포스팅 조회 완료. githubUsername=${member.githubUsername}, posting=$posting" } + return posting?.let { toPostingReadDto(it) } + } + + fun create( + member: Member, + title: String, + content: MultipartFile, + ): Long { + log.info { "PostingService#create start. member=$member, title=$title, content.size=${content.size}" } + val githubAccessToken = memberService.getGithubAccessTokenById(member.id) + + if (member.repoName == null) { + throw PostingRepositoryNotFoundException() + } + + val githubContent = + githubApiService.upsertFile( + githubAccessToken = githubAccessToken, + content = content, + githubUsername = member.githubUsername, + repo = member.repoName, + path = "$title.md", + commitMessage = CommitMessageGenerator.createMessage(title = title), + ) + + val savedPostingId = + postingWriter.write( + PostingWriteDto( + title = title, + member = member, + githubFilePath = githubContent.path, + githubFileSha = githubContent.sha, + githubDownloadUrl = githubContent.download_url, + ), + ) + + log.info { "PostingService#create end. savedPostingId=$savedPostingId, contentSha=${githubContent.sha}" } + return savedPostingId + } + + fun update( + member: Member, + postingId: Long, + title: String, + content: MultipartFile, + ): Long { + log.info { "PostingService#update start. member=$member, postingId=$postingId, title=$title, content.size=${content.size}" } + val posting = postingReader.findPostingById(postingId) + + if (posting.refMemberId != member.id) { + log.warn { "본인이 작성하지 않은 포스팅에 대한 수정 시도. memberId=${member.id}, postingId=$postingId" } + throw PostingAuthorizationException() + } + + val githubAccessToken = memberService.getGithubAccessTokenById(member.id) + + val githubContent = + githubApiService.upsertFile( + githubAccessToken = githubAccessToken, + content = content, + githubUsername = member.githubUsername, + repo = member.repoName ?: throw PostingRepositoryNotFoundException(), + path = "$title.md", + commitMessage = CommitMessageGenerator.updateMessage(title = title), + sha = posting.githubFileSha, + ) + + val updatedPostingId = + postingWriter.update( + postingId, + PostingUpdateDto( + title = title, + githubFilePath = githubContent.path, + githubFileSha = githubContent.sha, + githubDownloadUrl = githubContent.download_url, + ), + ) + + log.info { "PostingService#update end. updatedPostingId=$updatedPostingId, contentSha=${githubContent.sha}" } + return updatedPostingId + } + + fun uploadImage( + member: Member, + content: MultipartFile, + ): String { + if (member.repoName == null) { + throw PostingRepositoryNotFoundException() + } + + val githubAccessToken = memberService.getGithubAccessTokenById(member.id) + val extension = resolveExtension(content) ?: throw InvalidImageExtensionException() + val imageId = UUID.randomUUID().toString() + val filename = "$imageId.$extension" + val path = "images/$filename" + + val githubContent = + githubApiService.upsertFile( + githubAccessToken = githubAccessToken, + content = content, + githubUsername = member.githubUsername, + repo = member.repoName, + path = path, + commitMessage = CommitMessageGenerator.imageUploadMessage(), + ) + + return githubContent.download_url + } + + private fun resolveExtension(file: MultipartFile): String? { + val contentType = file.contentType + if (contentType != null && contentType.startsWith("image/")) { + val subtype = contentType.substringAfter("image/") + if (subtype.isNotBlank()) { + return subtype + } + } + val extension = file.originalFilename?.substringAfterLast('.', "") + if (!extension.isNullOrBlank()) { + return extension + } + return null + } + + private fun toPostingReadDto(posting: Posting): PostingReadDto { + return PostingReadDto( + id = posting.id, + title = posting.title, + githubDownloadUrl = posting.githubDownloadUrl, + createdAt = posting.createdAt, + updatedAt = posting.updatedAt, + ) + } - // Todo: githubService 연동하여 posting upsert API 호출 + companion object { + private val log = logger {} } } diff --git a/src/main/kotlin/com/ixfp/gitmon/domain/posting/PostingUpdateDto.kt b/src/main/kotlin/com/ixfp/gitmon/domain/posting/PostingUpdateDto.kt new file mode 100644 index 0000000..173a90a --- /dev/null +++ b/src/main/kotlin/com/ixfp/gitmon/domain/posting/PostingUpdateDto.kt @@ -0,0 +1,8 @@ +package com.ixfp.gitmon.domain.posting + +data class PostingUpdateDto( + val title: String, + val githubFilePath: String, + val githubFileSha: String, + val githubDownloadUrl: String, +) diff --git a/src/main/kotlin/com/ixfp/gitmon/domain/posting/PostingWriteDto.kt b/src/main/kotlin/com/ixfp/gitmon/domain/posting/PostingWriteDto.kt new file mode 100644 index 0000000..9571c5b --- /dev/null +++ b/src/main/kotlin/com/ixfp/gitmon/domain/posting/PostingWriteDto.kt @@ -0,0 +1,11 @@ +package com.ixfp.gitmon.domain.posting + +import com.ixfp.gitmon.domain.member.Member + +data class PostingWriteDto( + val title: String, + val member: Member, + val githubFilePath: String, + val githubFileSha: String, + val githubDownloadUrl: String, +) diff --git a/src/main/kotlin/com/ixfp/gitmon/domain/posting/PostingWriter.kt b/src/main/kotlin/com/ixfp/gitmon/domain/posting/PostingWriter.kt new file mode 100644 index 0000000..16477d4 --- /dev/null +++ b/src/main/kotlin/com/ixfp/gitmon/domain/posting/PostingWriter.kt @@ -0,0 +1,46 @@ +package com.ixfp.gitmon.domain.posting + +import com.ixfp.gitmon.common.aop.WrapWith +import com.ixfp.gitmon.db.exception.DbExceptionStrategy +import com.ixfp.gitmon.db.posting.PostingEntity +import com.ixfp.gitmon.db.posting.PostingRepository +import org.springframework.data.repository.findByIdOrNull +import org.springframework.stereotype.Component + +@WrapWith(DbExceptionStrategy::class) +@Component +class PostingWriter( + private val postingRepository: PostingRepository, +) { + fun write(postingWriteDto: PostingWriteDto): Long { + val entity = + PostingEntity( + title = postingWriteDto.title, + refMemberId = postingWriteDto.member.id, + githubFilePath = postingWriteDto.githubFilePath, + githubFileSha = postingWriteDto.githubFileSha, + githubDownloadUrl = postingWriteDto.githubDownloadUrl, + ) + val saved = postingRepository.save(entity) + + return saved.id + } + + fun update( + postingId: Long, + postingUpdateDto: PostingUpdateDto, + ): Long { + val postingEntity = + postingRepository.findByIdOrNull(postingId) + ?: throw IllegalArgumentException("Posting with id $postingId not found") + + postingEntity.title = postingUpdateDto.title + postingEntity.githubFilePath = postingUpdateDto.githubFilePath + postingEntity.githubFileSha = postingUpdateDto.githubFileSha + postingEntity.githubDownloadUrl = postingUpdateDto.githubDownloadUrl + + val updated = postingRepository.save(postingEntity) + + return updated.id + } +} diff --git a/src/main/kotlin/com/ixfp/gitmon/domain/posting/exception/PostingExceptionStrategy.kt b/src/main/kotlin/com/ixfp/gitmon/domain/posting/exception/PostingExceptionStrategy.kt new file mode 100644 index 0000000..55c276b --- /dev/null +++ b/src/main/kotlin/com/ixfp/gitmon/domain/posting/exception/PostingExceptionStrategy.kt @@ -0,0 +1,15 @@ +package com.ixfp.gitmon.domain.posting.exception + +import com.ixfp.gitmon.common.aop.ExceptionWrappingStrategy +import com.ixfp.gitmon.common.exception.AppException +import org.springframework.stereotype.Component + +@Component +class PostingExceptionStrategy : ExceptionWrappingStrategy { + override fun wrap(e: Throwable): AppException { + return when (e) { + is PostingException -> e + else -> PostingUnexpectedException(cause = e) + } + } +} diff --git a/src/main/kotlin/com/ixfp/gitmon/domain/posting/exception/PostingExceptions.kt b/src/main/kotlin/com/ixfp/gitmon/domain/posting/exception/PostingExceptions.kt new file mode 100644 index 0000000..ba6a6a1 --- /dev/null +++ b/src/main/kotlin/com/ixfp/gitmon/domain/posting/exception/PostingExceptions.kt @@ -0,0 +1,28 @@ +package com.ixfp.gitmon.domain.posting.exception + +import com.ixfp.gitmon.common.exception.DomainException + +sealed class PostingException( + message: String, + cause: Throwable? = null, +) : DomainException(message, cause) + +class PostingRepositoryNotFoundException( + message: String = "회원의 게시글 레포지토리가 없습니다.", + cause: Throwable? = null, +) : PostingException(message, cause) + +class InvalidImageExtensionException( + message: String = "업로드하는 파일의 확장자가 이미지가 아닙니다.", + cause: Throwable? = null, +) : PostingException(message, cause) + +class PostingAuthorizationException( + message: String = "게시글에 대한 권한이 없습니다.", + cause: Throwable? = null, +) : PostingException(message, cause) + +class PostingUnexpectedException( + message: String = "예상치 못한 게시글 도메인 예외가 발생했습니다.", + cause: Throwable? = null, +) : PostingException(message, cause) diff --git a/src/main/resources/application-dev.yml b/src/main/resources/application-dev.yml new file mode 100644 index 0000000..746eaf3 --- /dev/null +++ b/src/main/resources/application-dev.yml @@ -0,0 +1,5 @@ +oauth2: + client: + github: + id: ${GITHUB_OAUTH2_CLIENT_ID_DEV} + secret: ${GITHUB_OAUTH2_CLIENT_SECRET_DEV} diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 9aedcbc..f02373a 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -1,6 +1,10 @@ spring: application: name: gitmon + servlet: + multipart: + max-file-size: 10MB + max-request-size: 10MB datasource: url: ${DATABASE_URL} username: ${DATABASE_USERNAME} @@ -21,8 +25,12 @@ spring: oauth2: client: github: - id: ${OAUTH2_CLIENT_GITHUB_ID} - secret: ${OAUTH2_CLIENT_GITHUB_SECRET} + id: ${GITHUB_OAUTH2_CLIENT_ID_PROD} + secret: ${GITHUB_OAUTH2_CLIENT_SECRET_PROD} + client-dev: + github: + id: ${GITHUB_OAUTH2_CLIENT_ID_DEV} + secret: ${GITHUB_OAUTH2_CLIENT_SECRET_DEV} jwt: secret: ${JWT_SECRET} diff --git a/src/test/resources/application.yml b/src/test/resources/application.yml index 62a0202..c284ab5 100644 --- a/src/test/resources/application.yml +++ b/src/test/resources/application.yml @@ -18,6 +18,10 @@ oauth2: github: id: 'id' secret: 'secret' + client-dev: + github: + id: 'id' + secret: 'dev' jwt: secret: 'jwt_secret_key_with_size_greater_than_256_bits'