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
6 changes: 6 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,12 @@ REDIS_PORT=6379
AWS_REGION=ap-northeast-2
AWS_ACCESS_KEY=test
AWS_SECRET_KEY=test
MEDIA_STORAGE_BUCKET=mudda-media
MEDIA_UPLOAD_URL_EXPIRATION=10m
MEDIA_ACCESS_URL_EXPIRATION=5m
MEDIA_MAX_IMAGE_SIZE=10485760
MEDIA_MAX_VOICE_SIZE=20971520
MEDIA_MAX_VIDEO_SIZE=104857600

JWT_SECRET=change-me-change-me-change-me-change-me

Expand Down
4 changes: 4 additions & 0 deletions HELP.md
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,10 @@ Check the Compose file:
docker compose config
```

## Media Storage

The bucket configured by `MEDIA_STORAGE_BUCKET` must be private. Allow browser `PUT` requests in its CORS policy and add an S3 Lifecycle rule that expires objects under `pending/` after one day. Completed media is moved to `media/` and served only through short-lived signed URLs.

## Gradle Files

- `build.gradle.kts`: plugins and dependency setup
Expand Down
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,
)
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 }
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
package team.cklob.mudda.domain.media.application.impl

import org.slf4j.LoggerFactory
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.repository.MediaRepository
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)

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 = try {
mediaStorage.inspect(key.pendingKey)
} catch (exception: BusinessException) {
val existing = mediaRepository.findByS3KeyAndUploaderId(key.permanentKey, memberId) ?: throw exception
return CompleteMediaUploadResponse.from(existing, mediaStorage.createAccessUrl(existing.s3Key))
}
val maxSize = properties.maxSizeFor(key.mediaType)
if (storedObject.contentType?.lowercase() !in properties.allowedContentTypesFor(key.mediaType) ||
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
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
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.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 = properties.allowedContentTypesFor(request.mediaType)
val maxSize = properties.maxSizeFor(request.mediaType)
if (request.contentType.lowercase() !in allowedTypes || request.fileSize > maxSize) {
throw BusinessException(ErrorCode.INVALID_MEDIA_UPLOAD)
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
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.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,
) {
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)

mediaRepository.delete(media)
mediaStorage.delete(media.s3Key)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -12,23 +12,25 @@ import jakarta.persistence.JoinColumn
import jakarta.persistence.ManyToOne
import jakarta.persistence.Table
import team.cklob.mudda.domain.media.domain.type.MediaType
import team.cklob.mudda.domain.member.domain.entity.Member
import team.cklob.mudda.domain.timecapsule.domain.entity.TimeCapsule
import team.cklob.mudda.global.common.entity.BaseCreatedAtEntity

@Entity
@Table(name = "tbl_media")
class Media(
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "time_capsule_id", nullable = false)
val timeCapsule: TimeCapsule,
@JoinColumn(name = "uploader_id", nullable = false)
val uploader: Member,

@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "time_capsule_id")
var timeCapsule: TimeCapsule? = null,

@Enumerated(EnumType.STRING)
@Column(name = "media_type", nullable = false, length = 20)
val mediaType: MediaType,

@Column(name = "media_url", nullable = false, length = 255)
val mediaUrl: String,

@Column(name = "s3_key", nullable = false, length = 255)
val s3Key: String,

Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,29 @@
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 org.springframework.transaction.annotation.Transactional
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
@Transactional
@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
}
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
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
package team.cklob.mudda.domain.media.infrastructure

import org.springframework.boot.context.properties.ConfigurationProperties
import team.cklob.mudda.domain.media.domain.type.MediaType
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,
) {
fun maxSizeFor(mediaType: MediaType) = when (mediaType) {
MediaType.IMAGE -> maxImageSize
MediaType.VIDEO -> maxVideoSize
MediaType.VOICE -> maxVoiceSize
}

fun allowedContentTypesFor(mediaType: MediaType) = ALLOWED_CONTENT_TYPES[mediaType].orEmpty()

companion object {
private 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"),
)
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
package team.cklob.mudda.domain.media.infrastructure

import org.slf4j.LoggerFactory
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 {
private val logger = LoggerFactory.getLogger(javaClass)

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 {
Comment thread
cfcromn marked this conversation as resolved.
block()
} catch (exception: BusinessException) {
throw exception
} catch (exception: Exception) {
logger.error("Media storage request failed", exception)
throw BusinessException(ErrorCode.MEDIA_STORAGE_ERROR)
}
}
Loading