Skip to content
Merged
Show file tree
Hide file tree
Changes from 4 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,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
Comment thread
cfcromn marked this conversation as resolved.
Outdated
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)
Comment thread
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() ||
Comment thread
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
}
}
}
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) {
Comment thread
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"),
)
}
}
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)
Comment thread
cfcromn marked this conversation as resolved.
Outdated
mediaRepository.delete(media)
}
}
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,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
}
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,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,
)
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 {
Comment thread
cfcromn marked this conversation as resolved.
block()
} catch (exception: BusinessException) {
throw exception
} catch (exception: Exception) {
throw BusinessException(ErrorCode.MEDIA_STORAGE_ERROR)
}
}
Loading