Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -25,3 +25,4 @@ S3_BUCKET=String
S3_ENDPOINT=String
S3_PUBLIC_BASE_URL=String
S3_PATH_STYLE_ACCESS_ENABLED=String
S3_PRESIGNED_URL_EXPIRATION_SECONDS=Number
Original file line number Diff line number Diff line change
Expand Up @@ -14,5 +14,7 @@ interface AvatarImageStorage {

fun toPublicUrl(s3Uri: String): String

fun generatePresignedUrl(imageUrl: String): String

fun delete(imageUrl: String)
}
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import team.darkmoderap.aikon.global.common.error.ErrorCode
@Service
class GetAvatarByPassServiceImpl(
private val avatarRepository: AvatarRepository,
private val avatarImageStorage: AvatarImageStorage,
private val avatarQrUrlProvider: AvatarQrUrlProvider,
) : GetAvatarByPassService {
@Transactional(readOnly = true)
Expand All @@ -20,7 +21,7 @@ class GetAvatarByPassServiceImpl(
?: throw AikonException(ErrorCode.AVATAR_NOT_FOUND)

val isCompleted = avatar.generationStatus == GenerationStatus.COMPLETED
val imageUrl = if (isCompleted) avatar.imageUrl else null
val imageUrl = if (isCompleted) avatar.imageUrl?.let { avatarImageStorage.generatePresignedUrl(it) } else null
val resolvedPassUrl = if (isCompleted) avatar.passUrl else null

return GetAvatarResDto(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import team.darkmoderap.aikon.global.common.error.ErrorCode
@Service
class GetAvatarServiceImpl(
private val avatarRepository: AvatarRepository,
private val avatarImageStorage: AvatarImageStorage,
private val avatarQrUrlProvider: AvatarQrUrlProvider,
) : GetAvatarService {
@Transactional(readOnly = true)
Expand All @@ -21,7 +22,7 @@ class GetAvatarServiceImpl(
?: throw AikonException(ErrorCode.AVATAR_NOT_FOUND)

val isCompleted = avatar.generationStatus == GenerationStatus.COMPLETED
val imageUrl = if (isCompleted) avatar.imageUrl else null
val imageUrl = if (isCompleted) avatar.imageUrl?.let { avatarImageStorage.generatePresignedUrl(it) } else null
val passUrl = if (isCompleted) avatar.passUrl else null

return GetAvatarResDto(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,16 +5,22 @@ import org.springframework.stereotype.Service
import software.amazon.awssdk.core.sync.RequestBody
import software.amazon.awssdk.services.s3.S3Client
import software.amazon.awssdk.services.s3.model.DeleteObjectRequest
import software.amazon.awssdk.services.s3.model.GetObjectRequest
import software.amazon.awssdk.services.s3.model.PutObjectRequest
import software.amazon.awssdk.services.s3.presigner.S3Presigner
import software.amazon.awssdk.services.s3.presigner.model.GetObjectPresignRequest
import team.darkmoderap.aikon.global.common.error.AikonException
import team.darkmoderap.aikon.global.common.error.ErrorCode
import java.time.Duration

@Service
class S3AvatarImageStorage(
private val s3Client: S3Client,
private val s3Presigner: S3Presigner,
@Value("\${aws.region}") private val region: String,
@Value("\${aws.s3.bucket}") private val bucket: String,
@Value("\${aws.s3.public-base-url}") private val publicBaseUrl: String,
@Value("\${aws.s3.presigned-url-expiration-seconds}") private val presignedUrlExpirationSeconds: Long,
) : AvatarImageStorage {
override fun upload(
avatarId: Long,
Expand Down Expand Up @@ -80,6 +86,26 @@ class S3AvatarImageStorage(
}
}

override fun generatePresignedUrl(imageUrl: String): String {
val key =
extractKey(imageUrl)
?: throw AikonException(ErrorCode.AVATAR_IMAGE_URL_GENERATION_FAILED)

val presignRequest =
GetObjectPresignRequest
.builder()
.signatureDuration(Duration.ofSeconds(presignedUrlExpirationSeconds))
.getObjectRequest(
GetObjectRequest
.builder()
.bucket(bucket)
.key(key)
.build(),
).build()

return s3Presigner.presignGetObject(presignRequest).url().toString()
}

override fun delete(imageUrl: String) {
if (bucket.isBlank()) {
throw AikonException(ErrorCode.AVATAR_IMAGE_DELETE_FAILED)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ import java.util.concurrent.CopyOnWriteArrayList
@Service
class SubscribeAvatarChangesServiceImpl(
private val avatarRepository: AvatarRepository,
private val avatarImageStorage: AvatarImageStorage,
private val eventPublisher: ApplicationEventPublisher,
transactionManager: PlatformTransactionManager,
@Value("\${aikon.sse.timeout-millis:1800000}") private val timeoutMillis: Long,
Expand Down Expand Up @@ -134,7 +135,7 @@ class SubscribeAvatarChangesServiceImpl(
gender = gender,
ageRange = ageRange,
generationStatus = generationStatus,
imageUrl = imageUrl,
imageUrl = imageUrl?.let { avatarImageStorage.generatePresignedUrl(it) },
passUrl = passUrl,
createdAt = createdAt,
)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ enum class ErrorCode(
AVATAR_PASS_CODE_ASSIGNMENT_FAILED("아바타 패스 코드 배정에 실패하였습니다.", 409),
AVATAR_IMAGE_GENERATION_FAILED("아바타 이미지 생성에 실패하였습니다.", 502),
AVATAR_IMAGE_DELETE_FAILED("아바타 이미지 삭제에 실패하였습니다.", 502),
AVATAR_IMAGE_URL_GENERATION_FAILED("아바타 이미지 URL 생성에 실패하였습니다.", 502),
AVATAR_STYLE_NOT_SUPPORTED_BY_AI("해당 스타일은 현재 AI 서버에서 지원되지 않습니다.", 422),
FASTAPI_REQUEST_FAILED("AI 서버 요청에 실패하였습니다.", 502),
INVALID_INTERNAL_SECRET("내부 인증에 실패하였습니다.", 401),
Expand Down
27 changes: 27 additions & 0 deletions src/main/kotlin/team/darkmoderap/aikon/global/config/S3Config.kt
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import software.amazon.awssdk.auth.credentials.DefaultCredentialsProvider
import software.amazon.awssdk.regions.Region
import software.amazon.awssdk.services.s3.S3Client
import software.amazon.awssdk.services.s3.S3Configuration
import software.amazon.awssdk.services.s3.presigner.S3Presigner
import java.net.URI

@Configuration
Expand Down Expand Up @@ -36,4 +37,30 @@ class S3Config {
return builder
.build()
}

@Bean
fun s3Presigner(
@Value("\${aws.region}") region: String,
@Value("\${aws.s3.endpoint}") endpoint: String,
@Value("\${aws.s3.path-style-access-enabled}") pathStyleAccessEnabled: Boolean,
): S3Presigner {
val builder =
S3Presigner
.builder()
.region(Region.of(region))
.credentialsProvider(DefaultCredentialsProvider.builder().build())
.serviceConfiguration(
S3Configuration
.builder()
.pathStyleAccessEnabled(pathStyleAccessEnabled)
.build(),
)

if (endpoint.isNotBlank()) {
builder.endpointOverride(URI.create(endpoint))
}

return builder
.build()
}
}
1 change: 1 addition & 0 deletions src/main/resources/application.yml
Original file line number Diff line number Diff line change
Expand Up @@ -39,3 +39,4 @@ aws:
public-base-url: ${S3_PUBLIC_BASE_URL:}
endpoint: ${S3_ENDPOINT:}
path-style-access-enabled: ${S3_PATH_STYLE_ACCESS_ENABLED:true}
presigned-url-expiration-seconds: ${S3_PRESIGNED_URL_EXPIRATION_SECONDS:3600}
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,9 @@ class GetAvatarByPassServiceImplTest {
@Mock
private lateinit var avatarRepository: AvatarRepository

@Mock
private lateinit var avatarImageStorage: AvatarImageStorage

@Mock
private lateinit var avatarQrUrlProvider: AvatarQrUrlProvider

Expand All @@ -38,6 +41,7 @@ class GetAvatarByPassServiceImplTest {
fun `returns completed avatar with result urls when found`() {
// Given
given(avatarRepository.findByPassUrl(PASS_URL)).willReturn(avatar())
given(avatarImageStorage.generatePresignedUrl("https://example.com/avatar.png")).willReturn(PRESIGNED_IMAGE_URL)
given(avatarQrUrlProvider.create(PASS_URL)).willReturn(QR_URL)

// When
Expand All @@ -47,7 +51,7 @@ class GetAvatarByPassServiceImplTest {
assertEquals(AVATAR_ID, result.id)
assertEquals("새아바타", result.nickname)
assertEquals(GenerationStatus.COMPLETED, result.generationStatus)
assertEquals("https://example.com/avatar.png", result.imageUrl)
assertEquals(PRESIGNED_IMAGE_URL, result.imageUrl)
assertEquals(PASS_URL, result.passUrl)
assertEquals(QR_URL, result.qrUrl)
}
Expand Down Expand Up @@ -91,6 +95,7 @@ class GetAvatarByPassServiceImplTest {
private const val AVATAR_ID = 1L
private const val PASS_URL = "Aikon500"
private const val QR_URL = "https://aikon.example.com/pass/Aikon500"
private const val PRESIGNED_IMAGE_URL = "https://example.com/avatar.png?X-Amz-Signature=signed"

private fun avatar(generationStatus: GenerationStatus = GenerationStatus.COMPLETED): AvatarEntity =
AvatarEntity(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,9 @@ class GetAvatarServiceImplTest {
@Mock
private lateinit var avatarRepository: AvatarRepository

@Mock
private lateinit var avatarImageStorage: AvatarImageStorage

@Mock
private lateinit var avatarQrUrlProvider: AvatarQrUrlProvider

Expand All @@ -39,6 +42,7 @@ class GetAvatarServiceImplTest {
fun `returns completed avatar with result urls when found`() {
// Given
given(avatarRepository.findById(AVATAR_ID)).willReturn(Optional.of(avatar()))
given(avatarImageStorage.generatePresignedUrl("https://example.com/avatar.png")).willReturn(PRESIGNED_IMAGE_URL)
given(avatarQrUrlProvider.create("Aikon500")).willReturn(QR_URL)

// When
Expand All @@ -48,7 +52,7 @@ class GetAvatarServiceImplTest {
assertEquals(AVATAR_ID, result.id)
assertEquals("새아바타", result.nickname)
assertEquals(GenerationStatus.COMPLETED, result.generationStatus)
assertEquals("https://example.com/avatar.png", result.imageUrl)
assertEquals(PRESIGNED_IMAGE_URL, result.imageUrl)
assertEquals("Aikon500", result.passUrl)
assertEquals(QR_URL, result.qrUrl)
}
Expand Down Expand Up @@ -91,6 +95,7 @@ class GetAvatarServiceImplTest {
companion object {
private const val AVATAR_ID = 1L
private const val QR_URL = "https://aikon.example.com/pass/Aikon500"
private const val PRESIGNED_IMAGE_URL = "https://example.com/avatar.png?X-Amz-Signature=signed"

private fun avatar(generationStatus: GenerationStatus = GenerationStatus.COMPLETED): AvatarEntity =
AvatarEntity(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,20 +7,28 @@ import org.junit.jupiter.api.Test
import org.junit.jupiter.api.assertThrows
import org.junit.jupiter.api.extension.ExtendWith
import org.mockito.ArgumentCaptor
import org.mockito.BDDMockito.given
import org.mockito.Mock
import org.mockito.junit.jupiter.MockitoExtension
import software.amazon.awssdk.core.sync.RequestBody
import software.amazon.awssdk.services.s3.S3Client
import software.amazon.awssdk.services.s3.model.DeleteObjectRequest
import software.amazon.awssdk.services.s3.model.PutObjectRequest
import software.amazon.awssdk.services.s3.presigner.S3Presigner
import software.amazon.awssdk.services.s3.presigner.model.GetObjectPresignRequest
import software.amazon.awssdk.services.s3.presigner.model.PresignedGetObjectRequest
import team.darkmoderap.aikon.global.common.error.AikonException
import team.darkmoderap.aikon.global.common.error.ErrorCode
import java.net.URI

@ExtendWith(MockitoExtension::class)
class S3AvatarImageStorageTest {
@Mock
private lateinit var s3Client: S3Client

@Mock
private lateinit var s3Presigner: S3Presigner

@Nested
@DisplayName("upload 메서드는")
inner class Upload {
Expand Down Expand Up @@ -97,18 +105,67 @@ class S3AvatarImageStorageTest {
}
}

@Nested
@DisplayName("generatePresignedUrl 메서드는")
inner class GeneratePresignedUrl {
@Test
@DisplayName("publicBaseUrl 기반 URL에서 key를 추출해 presigned URL을 생성한다")
fun `returns presigned url extracted from public base url`() {
// Given
val storage = s3AvatarImageStorage(publicBaseUrl = PUBLIC_BASE_URL)
val imageUrl = "$PUBLIC_BASE_URL/avatars/$AVATAR_ID.png"
val presignedGetObjectRequest = org.mockito.Mockito.mock(PresignedGetObjectRequest::class.java)
given(presignedGetObjectRequest.url()).willReturn(URI.create(PRESIGNED_URL).toURL())
given(
s3Presigner.presignGetObject(org.mockito.ArgumentMatchers.any(GetObjectPresignRequest::class.java)),
).willReturn(presignedGetObjectRequest)

// When
val result = storage.generatePresignedUrl(imageUrl)

// Then
assertEquals(PRESIGNED_URL, result)
val requestCaptor = ArgumentCaptor.forClass(GetObjectPresignRequest::class.java)
org.mockito.Mockito
.verify(s3Presigner)
.presignGetObject(requestCaptor.capture())
assertEquals(BUCKET, requestCaptor.value.getObjectRequest().bucket())
assertEquals("avatars/$AVATAR_ID.png", requestCaptor.value.getObjectRequest().key())
}

@Test
@DisplayName("key를 추출할 수 없으면 502 예외를 던진다")
fun `throws bad gateway when key cannot be extracted`() {
// Given
val storage = s3AvatarImageStorage(publicBaseUrl = PUBLIC_BASE_URL)

// When
val exception =
assertThrows<AikonException> {
storage.generatePresignedUrl("https://unknown.example.com/avatar.png")
}

// Then
assertEquals(ErrorCode.AVATAR_IMAGE_URL_GENERATION_FAILED, exception.errorCode)
}
}

private fun s3AvatarImageStorage(publicBaseUrl: String): S3AvatarImageStorage =
S3AvatarImageStorage(
s3Client = s3Client,
s3Presigner = s3Presigner,
region = REGION,
bucket = BUCKET,
publicBaseUrl = publicBaseUrl,
presignedUrlExpirationSeconds = PRESIGNED_URL_EXPIRATION_SECONDS,
)

companion object {
private const val AVATAR_ID = 1L
private const val REGION = "ap-northeast-2"
private const val BUCKET = "aikon"
private const val PUBLIC_BASE_URL = "http://localhost:9000/aikon"
private const val PRESIGNED_URL_EXPIRATION_SECONDS = 3600L
private const val PRESIGNED_URL = "https://example.com/avatars/1.png?X-Amz-Signature=signed"
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,9 @@ class SubscribeAvatarChangesServiceImplTest {
@Mock
private lateinit var avatarRepository: AvatarRepository

@Mock
private lateinit var avatarImageStorage: AvatarImageStorage

@Mock
private lateinit var eventPublisher: ApplicationEventPublisher

Expand All @@ -47,6 +50,7 @@ class SubscribeAvatarChangesServiceImplTest {
subscribeAvatarChangesService =
SubscribeAvatarChangesServiceImpl(
avatarRepository = avatarRepository,
avatarImageStorage = avatarImageStorage,
eventPublisher = eventPublisher,
transactionManager = transactionManager,
timeoutMillis = 5000L,
Expand Down
Loading