-
Notifications
You must be signed in to change notification settings - Fork 0
미디어 Presigned URL 업로드 및 삭제 API 구현 #20
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from 4 commits
Commits
Show all changes
6 commits
Select commit
Hold shift + click to select a range
50b4803
feat: #19 :: add media ownership and storage migration
cfcromn 085f98b
feat: #19 :: implement presigned media upload and deletion
cfcromn ea034eb
test: #19 :: verify media upload ownership and validation
cfcromn 9c88d46
docs: #19 :: document media bucket requirements
cfcromn c3a8a79
fix: #19 :: address media upload review
cfcromn 50222b1
fix: #19 :: handle concurrent media completion
cfcromn File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
21 changes: 21 additions & 0 deletions
21
src/main/kotlin/team/cklob/mudda/domain/media/application/MediaStorage.kt
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,21 @@ | ||
| package team.cklob.mudda.domain.media.application | ||
|
|
||
| import java.time.LocalDateTime | ||
|
|
||
| interface MediaStorage { | ||
| fun createUploadUrl(key: String, contentType: String, contentLength: Long): SignedUrl | ||
| fun inspect(key: String): StoredObject | ||
| fun copy(sourceKey: String, destinationKey: String) | ||
| fun createAccessUrl(key: String): SignedUrl | ||
| fun delete(key: String) | ||
| } | ||
|
|
||
| data class SignedUrl( | ||
| val url: String, | ||
| val expiresAt: LocalDateTime, | ||
| ) | ||
|
|
||
| data class StoredObject( | ||
| val contentType: String?, | ||
| val contentLength: Long, | ||
| ) |
26 changes: 26 additions & 0 deletions
26
src/main/kotlin/team/cklob/mudda/domain/media/application/MediaUploadKey.kt
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,26 @@ | ||
| package team.cklob.mudda.domain.media.application | ||
|
|
||
| import team.cklob.mudda.domain.media.domain.type.MediaType | ||
| import java.util.UUID | ||
|
|
||
| data class MediaUploadKey( | ||
| val memberId: Long, | ||
| val mediaType: MediaType, | ||
| val id: UUID, | ||
| ) { | ||
| val pendingKey: String = "pending/$memberId/${mediaType.name.lowercase()}/$id" | ||
| val permanentKey: String = "media/$memberId/${mediaType.name.lowercase()}/$id" | ||
|
|
||
| companion object { | ||
| fun create(memberId: Long, mediaType: MediaType) = MediaUploadKey(memberId, mediaType, UUID.randomUUID()) | ||
|
|
||
| fun parse(key: String): MediaUploadKey? { | ||
| val parts = key.split('/') | ||
| if (parts.size != 4 || parts[0] != "pending") return null | ||
|
|
||
| return runCatching { | ||
| MediaUploadKey(parts[1].toLong(), MediaType.valueOf(parts[2].uppercase()), UUID.fromString(parts[3])) | ||
| }.getOrNull()?.takeIf { it.pendingKey == key } | ||
| } | ||
| } | ||
| } |
77 changes: 77 additions & 0 deletions
77
src/main/kotlin/team/cklob/mudda/domain/media/application/impl/CompleteMediaUploadService.kt
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,77 @@ | ||
| package team.cklob.mudda.domain.media.application.impl | ||
|
|
||
| import org.slf4j.LoggerFactory | ||
| import org.springframework.stereotype.Service | ||
| import org.springframework.transaction.annotation.Transactional | ||
| import team.cklob.mudda.domain.media.application.MediaStorage | ||
| import team.cklob.mudda.domain.media.application.MediaUploadKey | ||
| import team.cklob.mudda.domain.media.domain.repository.MediaRepository | ||
| import team.cklob.mudda.domain.media.domain.type.MediaType | ||
| import team.cklob.mudda.domain.media.infrastructure.MediaStorageProperties | ||
| import team.cklob.mudda.domain.media.presentation.request.CompleteMediaUploadRequest | ||
| import team.cklob.mudda.domain.media.presentation.response.CompleteMediaUploadResponse | ||
| import team.cklob.mudda.domain.member.domain.repository.MemberRepository | ||
| import team.cklob.mudda.global.exception.AuthException | ||
| import team.cklob.mudda.global.exception.BusinessException | ||
| import team.cklob.mudda.global.exception.ErrorCode | ||
|
|
||
| @Service | ||
| class CompleteMediaUploadService( | ||
| private val mediaRepository: MediaRepository, | ||
| private val memberRepository: MemberRepository, | ||
| private val mediaStorage: MediaStorage, | ||
| private val properties: MediaStorageProperties, | ||
| ) { | ||
| private val logger = LoggerFactory.getLogger(javaClass) | ||
|
|
||
| @Transactional | ||
| fun execute(memberId: Long, request: CompleteMediaUploadRequest): CompleteMediaUploadResponse { | ||
| val key = MediaUploadKey.parse(request.uploadKey) | ||
| ?.takeIf { it.memberId == memberId } | ||
| ?: throw BusinessException(ErrorCode.INVALID_MEDIA_UPLOAD) | ||
|
|
||
| mediaRepository.findByS3KeyAndUploaderId(key.permanentKey, memberId)?.let { | ||
| return CompleteMediaUploadResponse.from(it, mediaStorage.createAccessUrl(it.s3Key)) | ||
| } | ||
|
|
||
| val storedObject = mediaStorage.inspect(key.pendingKey) | ||
|
cfcromn marked this conversation as resolved.
Outdated
|
||
| val maxSize = when (key.mediaType) { | ||
| MediaType.IMAGE -> properties.maxImageSize | ||
| MediaType.VIDEO -> properties.maxVideoSize | ||
| MediaType.VOICE -> properties.maxVoiceSize | ||
| } | ||
| if (storedObject.contentType?.lowercase() !in CreateMediaUploadUrlService.ALLOWED_CONTENT_TYPES[key.mediaType].orEmpty() || | ||
|
cfcromn marked this conversation as resolved.
Outdated
|
||
| storedObject.contentLength <= 0 || storedObject.contentLength > maxSize | ||
| ) { | ||
| throw BusinessException(ErrorCode.INVALID_MEDIA_UPLOAD) | ||
| } | ||
|
|
||
| memberRepository.findById(memberId).orElseThrow { AuthException(ErrorCode.UNAUTHORIZED) } | ||
| val inserted = mediaRepository.insertUnattached(memberId, key.mediaType.name, key.permanentKey) | ||
| if (inserted == 0) { | ||
| val existing = mediaRepository.findByS3KeyAndUploaderId(key.permanentKey, memberId) | ||
| ?: throw BusinessException(ErrorCode.INVALID_MEDIA_UPLOAD) | ||
| return CompleteMediaUploadResponse.from(existing, mediaStorage.createAccessUrl(existing.s3Key)) | ||
| } | ||
|
|
||
| mediaStorage.copy(key.pendingKey, key.permanentKey) | ||
| try { | ||
| val accessUrl = mediaStorage.createAccessUrl(key.permanentKey) | ||
| val media = mediaRepository.findByS3KeyAndUploaderId(key.permanentKey, memberId) | ||
| ?: throw BusinessException(ErrorCode.INVALID_MEDIA_UPLOAD) | ||
| try { | ||
| mediaStorage.delete(key.pendingKey) | ||
| } catch (_: BusinessException) { | ||
| logger.warn("Pending media cleanup failed; S3 lifecycle will retry cleanup") | ||
| } | ||
| return CompleteMediaUploadResponse.from(media, accessUrl) | ||
| } catch (exception: Exception) { | ||
| try { | ||
| mediaStorage.delete(key.permanentKey) | ||
| } catch (_: BusinessException) { | ||
| logger.error("Permanent media compensation cleanup failed") | ||
| } | ||
| throw exception | ||
| } | ||
| } | ||
| } | ||
44 changes: 44 additions & 0 deletions
44
...main/kotlin/team/cklob/mudda/domain/media/application/impl/CreateMediaUploadUrlService.kt
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,44 @@ | ||
| package team.cklob.mudda.domain.media.application.impl | ||
|
|
||
| import org.springframework.stereotype.Service | ||
| import team.cklob.mudda.domain.media.application.MediaStorage | ||
| import team.cklob.mudda.domain.media.application.MediaUploadKey | ||
| import team.cklob.mudda.domain.media.domain.type.MediaType | ||
| import team.cklob.mudda.domain.media.infrastructure.MediaStorageProperties | ||
| import team.cklob.mudda.domain.media.presentation.request.CreateMediaUploadUrlRequest | ||
| import team.cklob.mudda.domain.media.presentation.response.CreateMediaUploadUrlResponse | ||
| import team.cklob.mudda.global.exception.BusinessException | ||
| import team.cklob.mudda.global.exception.ErrorCode | ||
|
|
||
| @Service | ||
| class CreateMediaUploadUrlService( | ||
| private val mediaStorage: MediaStorage, | ||
| private val properties: MediaStorageProperties, | ||
| ) { | ||
| fun execute(memberId: Long, request: CreateMediaUploadUrlRequest): CreateMediaUploadUrlResponse { | ||
| validate(request) | ||
| val key = MediaUploadKey.create(memberId, request.mediaType).pendingKey | ||
| val signedUrl = mediaStorage.createUploadUrl(key, request.contentType, request.fileSize) | ||
| return CreateMediaUploadUrlResponse(key, signedUrl.url, signedUrl.expiresAt) | ||
| } | ||
|
|
||
| private fun validate(request: CreateMediaUploadUrlRequest) { | ||
| val allowedTypes = ALLOWED_CONTENT_TYPES[request.mediaType].orEmpty() | ||
| val maxSize = when (request.mediaType) { | ||
|
cfcromn marked this conversation as resolved.
Outdated
|
||
| MediaType.IMAGE -> properties.maxImageSize | ||
| MediaType.VIDEO -> properties.maxVideoSize | ||
| MediaType.VOICE -> properties.maxVoiceSize | ||
| } | ||
| if (request.contentType.lowercase() !in allowedTypes || request.fileSize > maxSize) { | ||
| throw BusinessException(ErrorCode.INVALID_MEDIA_UPLOAD) | ||
| } | ||
| } | ||
|
|
||
| companion object { | ||
| val ALLOWED_CONTENT_TYPES = mapOf( | ||
| MediaType.IMAGE to setOf("image/jpeg", "image/png", "image/webp"), | ||
| MediaType.VIDEO to setOf("video/mp4", "video/quicktime"), | ||
| MediaType.VOICE to setOf("audio/mpeg", "audio/mp4", "audio/wav"), | ||
| ) | ||
| } | ||
| } | ||
24 changes: 24 additions & 0 deletions
24
src/main/kotlin/team/cklob/mudda/domain/media/application/impl/DeleteMediaService.kt
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,24 @@ | ||
| package team.cklob.mudda.domain.media.application.impl | ||
|
|
||
| import org.springframework.stereotype.Service | ||
| import org.springframework.transaction.annotation.Transactional | ||
| import team.cklob.mudda.domain.media.application.MediaStorage | ||
| import team.cklob.mudda.domain.media.domain.repository.MediaRepository | ||
| import team.cklob.mudda.global.exception.BusinessException | ||
| import team.cklob.mudda.global.exception.ErrorCode | ||
|
|
||
| @Service | ||
| class DeleteMediaService( | ||
| private val mediaRepository: MediaRepository, | ||
| private val mediaStorage: MediaStorage, | ||
| ) { | ||
| @Transactional | ||
| fun execute(memberId: Long, mediaId: Long) { | ||
| val media = mediaRepository.findByIdAndUploaderId(mediaId, memberId) | ||
| ?: throw BusinessException(ErrorCode.MEDIA_NOT_FOUND) | ||
| if (media.timeCapsule != null) throw BusinessException(ErrorCode.MEDIA_ALREADY_ATTACHED) | ||
|
|
||
| mediaStorage.delete(media.s3Key) | ||
|
cfcromn marked this conversation as resolved.
Outdated
|
||
| mediaRepository.delete(media) | ||
| } | ||
| } | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
23 changes: 22 additions & 1 deletion
23
src/main/kotlin/team/cklob/mudda/domain/media/domain/repository/MediaRepository.kt
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,6 +1,27 @@ | ||
| package team.cklob.mudda.domain.media.domain.repository | ||
|
|
||
| import org.springframework.data.jpa.repository.JpaRepository | ||
| import org.springframework.data.jpa.repository.Modifying | ||
| import org.springframework.data.jpa.repository.Query | ||
| import org.springframework.data.repository.query.Param | ||
| import team.cklob.mudda.domain.media.domain.entity.Media | ||
|
|
||
| interface MediaRepository : JpaRepository<Media, Long> | ||
| interface MediaRepository : JpaRepository<Media, Long> { | ||
| fun findByS3KeyAndUploaderId(s3Key: String, uploaderId: Long): Media? | ||
| fun findByIdAndUploaderId(id: Long, uploaderId: Long): Media? | ||
|
|
||
| @Modifying | ||
| @Query( | ||
| value = """ | ||
| INSERT INTO tbl_media (uploader_id, media_type, s3_key, created_at) | ||
| VALUES (:uploaderId, :mediaType, :s3Key, CURRENT_TIMESTAMP) | ||
| ON CONFLICT (s3_key) DO NOTHING | ||
| """, | ||
| nativeQuery = true, | ||
| ) | ||
| fun insertUnattached( | ||
| @Param("uploaderId") uploaderId: Long, | ||
| @Param("mediaType") mediaType: String, | ||
| @Param("s3Key") s3Key: String, | ||
| ): Int | ||
| } |
8 changes: 8 additions & 0 deletions
8
src/main/kotlin/team/cklob/mudda/domain/media/infrastructure/MediaStorageConfig.kt
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,8 @@ | ||
| package team.cklob.mudda.domain.media.infrastructure | ||
|
|
||
| import org.springframework.boot.context.properties.EnableConfigurationProperties | ||
| import org.springframework.context.annotation.Configuration | ||
|
|
||
| @Configuration | ||
| @EnableConfigurationProperties(MediaStorageProperties::class) | ||
| class MediaStorageConfig |
14 changes: 14 additions & 0 deletions
14
src/main/kotlin/team/cklob/mudda/domain/media/infrastructure/MediaStorageProperties.kt
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,14 @@ | ||
| package team.cklob.mudda.domain.media.infrastructure | ||
|
|
||
| import org.springframework.boot.context.properties.ConfigurationProperties | ||
| import java.time.Duration | ||
|
|
||
| @ConfigurationProperties("media.storage") | ||
| data class MediaStorageProperties( | ||
| val bucket: String, | ||
| val uploadUrlExpiration: Duration = Duration.ofMinutes(10), | ||
| val accessUrlExpiration: Duration = Duration.ofMinutes(5), | ||
| val maxImageSize: Long = 10 * 1024 * 1024, | ||
| val maxVoiceSize: Long = 20 * 1024 * 1024, | ||
| val maxVideoSize: Long = 100 * 1024 * 1024, | ||
| ) |
81 changes: 81 additions & 0 deletions
81
src/main/kotlin/team/cklob/mudda/domain/media/infrastructure/S3MediaStorage.kt
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,81 @@ | ||
| package team.cklob.mudda.domain.media.infrastructure | ||
|
|
||
| import org.springframework.stereotype.Component | ||
| import software.amazon.awssdk.services.s3.S3Client | ||
| import software.amazon.awssdk.services.s3.model.CopyObjectRequest | ||
| import software.amazon.awssdk.services.s3.model.DeleteObjectRequest | ||
| import software.amazon.awssdk.services.s3.model.GetObjectRequest | ||
| import software.amazon.awssdk.services.s3.model.HeadObjectRequest | ||
| 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.PutObjectPresignRequest | ||
| import team.cklob.mudda.domain.media.application.MediaStorage | ||
| import team.cklob.mudda.domain.media.application.SignedUrl | ||
| import team.cklob.mudda.domain.media.application.StoredObject | ||
| import team.cklob.mudda.global.exception.BusinessException | ||
| import team.cklob.mudda.global.exception.ErrorCode | ||
| import java.time.LocalDateTime | ||
|
|
||
| @Component | ||
| class S3MediaStorage( | ||
| private val s3Client: S3Client, | ||
| private val s3Presigner: S3Presigner, | ||
| private val properties: MediaStorageProperties, | ||
| ) : MediaStorage { | ||
| override fun createUploadUrl(key: String, contentType: String, contentLength: Long): SignedUrl = storageCall { | ||
| val request = PutObjectRequest.builder() | ||
| .bucket(properties.bucket) | ||
| .key(key) | ||
| .contentType(contentType) | ||
| .contentLength(contentLength) | ||
| .build() | ||
| val presigned = s3Presigner.presignPutObject( | ||
| PutObjectPresignRequest.builder() | ||
| .signatureDuration(properties.uploadUrlExpiration) | ||
| .putObjectRequest(request) | ||
| .build(), | ||
| ) | ||
| SignedUrl(presigned.url().toString(), LocalDateTime.now().plus(properties.uploadUrlExpiration)) | ||
| } | ||
|
|
||
| override fun inspect(key: String): StoredObject = storageCall { | ||
| val response = s3Client.headObject(HeadObjectRequest.builder().bucket(properties.bucket).key(key).build()) | ||
| StoredObject(response.contentType(), response.contentLength()) | ||
| } | ||
|
|
||
| override fun copy(sourceKey: String, destinationKey: String) = storageCall { | ||
| s3Client.copyObject( | ||
| CopyObjectRequest.builder() | ||
| .copySource("${properties.bucket}/$sourceKey") | ||
| .destinationBucket(properties.bucket) | ||
| .destinationKey(destinationKey) | ||
| .build(), | ||
| ) | ||
| Unit | ||
| } | ||
|
|
||
| override fun createAccessUrl(key: String): SignedUrl = storageCall { | ||
| val request = GetObjectRequest.builder().bucket(properties.bucket).key(key).build() | ||
| val presigned = s3Presigner.presignGetObject( | ||
| GetObjectPresignRequest.builder() | ||
| .signatureDuration(properties.accessUrlExpiration) | ||
| .getObjectRequest(request) | ||
| .build(), | ||
| ) | ||
| SignedUrl(presigned.url().toString(), LocalDateTime.now().plus(properties.accessUrlExpiration)) | ||
| } | ||
|
|
||
| override fun delete(key: String) = storageCall { | ||
| s3Client.deleteObject(DeleteObjectRequest.builder().bucket(properties.bucket).key(key).build()) | ||
| Unit | ||
| } | ||
|
|
||
| private fun <T> storageCall(block: () -> T): T = try { | ||
|
cfcromn marked this conversation as resolved.
|
||
| block() | ||
| } catch (exception: BusinessException) { | ||
| throw exception | ||
| } catch (exception: Exception) { | ||
| throw BusinessException(ErrorCode.MEDIA_STORAGE_ERROR) | ||
| } | ||
| } | ||
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.