diff --git a/build.gradle.kts b/build.gradle.kts index f88635b8..0b8f61b2 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -60,6 +60,8 @@ tasks.create("openApiGenerateMastodonCompatibleApi", GenerateTask: configOptions.put("interfaceOnly", "true") configOptions.put("useSpringBoot3", "true") additionalProperties.put("useTags", "true") + importMappings.put("org.springframework.core.io.Resource", "org.springframework.web.multipart.MultipartFile") + typeMappings.put("org.springframework.core.io.Resource", "org.springframework.web.multipart.MultipartFile") } repositories { @@ -116,6 +118,9 @@ dependencies { implementation("org.springframework.security:spring-security-oauth2-jose") implementation("org.springframework.boot:spring-boot-starter-data-mongodb") implementation("org.jetbrains.exposed:exposed-spring-boot-starter:0.44.0") + implementation("io.trbl:blurhash:1.0.0") + implementation("software.amazon.awssdk:s3:2.20.157") + implementation("io.ktor:ktor-client-logging-jvm:$ktor_version") diff --git a/detekt.yml b/detekt.yml index 26af6de1..658f1b3b 100644 --- a/detekt.yml +++ b/detekt.yml @@ -3,6 +3,7 @@ build: weights: Indentation: 0 MagicNumber: 0 + InjectDispatcher: 0 style: ClassOrdering: diff --git a/src/main/kotlin/dev/usbharu/hideout/config/AwsConfig.kt b/src/main/kotlin/dev/usbharu/hideout/config/AwsConfig.kt new file mode 100644 index 00000000..b697bb61 --- /dev/null +++ b/src/main/kotlin/dev/usbharu/hideout/config/AwsConfig.kt @@ -0,0 +1,20 @@ +package dev.usbharu.hideout.config + +import org.springframework.context.annotation.Bean +import org.springframework.context.annotation.Configuration +import software.amazon.awssdk.auth.credentials.AwsBasicCredentials +import software.amazon.awssdk.regions.Region +import software.amazon.awssdk.services.s3.S3Client +import java.net.URI + +@Configuration +class AwsConfig { + @Bean + fun s3Client(awsConfig: StorageConfig): S3Client { + return S3Client.builder() + .endpointOverride(URI.create(awsConfig.endpoint)) + .region(Region.of(awsConfig.region)) + .credentialsProvider { AwsBasicCredentials.create(awsConfig.accessKey, awsConfig.secretKey) } + .build() + } +} diff --git a/src/main/kotlin/dev/usbharu/hideout/config/SpringConfig.kt b/src/main/kotlin/dev/usbharu/hideout/config/SpringConfig.kt index 05d0c019..25ef84ed 100644 --- a/src/main/kotlin/dev/usbharu/hideout/config/SpringConfig.kt +++ b/src/main/kotlin/dev/usbharu/hideout/config/SpringConfig.kt @@ -10,9 +10,23 @@ class SpringConfig { @Autowired lateinit var config: ApplicationConfig + + @Autowired + lateinit var storageConfig: StorageConfig } @ConfigurationProperties("hideout") data class ApplicationConfig( val url: URL ) + +@ConfigurationProperties("hideout.storage") +data class StorageConfig( + val useS3: Boolean, + val endpoint: String, + val publicUrl: String, + val bucket: String, + val region: String, + val accessKey: String, + val secretKey: String +) diff --git a/src/main/kotlin/dev/usbharu/hideout/controller/mastodon/MastodonMediaApiController.kt b/src/main/kotlin/dev/usbharu/hideout/controller/mastodon/MastodonMediaApiController.kt new file mode 100644 index 00000000..e357e2cc --- /dev/null +++ b/src/main/kotlin/dev/usbharu/hideout/controller/mastodon/MastodonMediaApiController.kt @@ -0,0 +1,31 @@ +package dev.usbharu.hideout.controller.mastodon + +import dev.usbharu.hideout.controller.mastodon.generated.MediaApi +import dev.usbharu.hideout.domain.mastodon.model.generated.MediaAttachment +import dev.usbharu.hideout.domain.model.hideout.form.Media +import dev.usbharu.hideout.service.api.mastodon.MediaApiService +import kotlinx.coroutines.runBlocking +import org.springframework.http.ResponseEntity +import org.springframework.stereotype.Controller +import org.springframework.web.multipart.MultipartFile + +@Controller +class MastodonMediaApiController(private val mediaApiService: MediaApiService) : MediaApi { + override fun apiV1MediaPost( + file: MultipartFile, + thumbnail: MultipartFile?, + description: String?, + focus: String? + ): ResponseEntity = runBlocking { + ResponseEntity.ok( + mediaApiService.postMedia( + Media( + file, + thumbnail, + description, + focus + ) + ) + ) + } +} diff --git a/src/main/kotlin/dev/usbharu/hideout/domain/model/MediaSave.kt b/src/main/kotlin/dev/usbharu/hideout/domain/model/MediaSave.kt new file mode 100644 index 00000000..0a32a8e3 --- /dev/null +++ b/src/main/kotlin/dev/usbharu/hideout/domain/model/MediaSave.kt @@ -0,0 +1,8 @@ +package dev.usbharu.hideout.domain.model + +data class MediaSave( + val name: String, + val prefix: String, + val fileInputStream: ByteArray, + val thumbnailInputStream: ByteArray? +) diff --git a/src/main/kotlin/dev/usbharu/hideout/domain/model/hideout/dto/FileType.kt b/src/main/kotlin/dev/usbharu/hideout/domain/model/hideout/dto/FileType.kt new file mode 100644 index 00000000..a72ac82a --- /dev/null +++ b/src/main/kotlin/dev/usbharu/hideout/domain/model/hideout/dto/FileType.kt @@ -0,0 +1,8 @@ +package dev.usbharu.hideout.domain.model.hideout.dto + +enum class FileType { + Image, + Video, + Audio, + Unknown +} diff --git a/src/main/kotlin/dev/usbharu/hideout/domain/model/hideout/dto/ProcessedFile.kt b/src/main/kotlin/dev/usbharu/hideout/domain/model/hideout/dto/ProcessedFile.kt new file mode 100644 index 00000000..1bf60d6c --- /dev/null +++ b/src/main/kotlin/dev/usbharu/hideout/domain/model/hideout/dto/ProcessedFile.kt @@ -0,0 +1,6 @@ +package dev.usbharu.hideout.domain.model.hideout.dto + +data class ProcessedFile( + val byteArray: ByteArray, + val extension: String +) diff --git a/src/main/kotlin/dev/usbharu/hideout/domain/model/hideout/dto/ProcessedMedia.kt b/src/main/kotlin/dev/usbharu/hideout/domain/model/hideout/dto/ProcessedMedia.kt new file mode 100644 index 00000000..b11416e8 --- /dev/null +++ b/src/main/kotlin/dev/usbharu/hideout/domain/model/hideout/dto/ProcessedMedia.kt @@ -0,0 +1,6 @@ +package dev.usbharu.hideout.domain.model.hideout.dto + +data class ProcessedMedia( + val file: ProcessedFile, + val thumbnail: ProcessedFile? +) diff --git a/src/main/kotlin/dev/usbharu/hideout/domain/model/hideout/dto/RemoteMedia.kt b/src/main/kotlin/dev/usbharu/hideout/domain/model/hideout/dto/RemoteMedia.kt new file mode 100644 index 00000000..d4428ad1 --- /dev/null +++ b/src/main/kotlin/dev/usbharu/hideout/domain/model/hideout/dto/RemoteMedia.kt @@ -0,0 +1,7 @@ +package dev.usbharu.hideout.domain.model.hideout.dto + +data class RemoteMedia( + val name: String, + val url: String, + val mediaType: String +) diff --git a/src/main/kotlin/dev/usbharu/hideout/domain/model/hideout/dto/SavedMedia.kt b/src/main/kotlin/dev/usbharu/hideout/domain/model/hideout/dto/SavedMedia.kt new file mode 100644 index 00000000..b8ab7490 --- /dev/null +++ b/src/main/kotlin/dev/usbharu/hideout/domain/model/hideout/dto/SavedMedia.kt @@ -0,0 +1,16 @@ +package dev.usbharu.hideout.domain.model.hideout.dto + +sealed class SavedMedia(val success: Boolean) + +class SuccessSavedMedia( + val name: String, + val url: String, + val thumbnailUrl: String, +) : + SavedMedia(true) + +class FaildSavedMedia( + val reason: String, + val description: String, + val trace: Throwable? = null +) : SavedMedia(false) diff --git a/src/main/kotlin/dev/usbharu/hideout/domain/model/hideout/entity/Media.kt b/src/main/kotlin/dev/usbharu/hideout/domain/model/hideout/entity/Media.kt new file mode 100644 index 00000000..042388b7 --- /dev/null +++ b/src/main/kotlin/dev/usbharu/hideout/domain/model/hideout/entity/Media.kt @@ -0,0 +1,13 @@ +package dev.usbharu.hideout.domain.model.hideout.entity + +import dev.usbharu.hideout.domain.model.hideout.dto.FileType + +data class Media( + val id: Long, + val name: String, + val url: String, + val remoteUrl: String?, + val thumbnailUrl: String?, + val type: FileType, + val blurHash: String? +) diff --git a/src/main/kotlin/dev/usbharu/hideout/domain/model/hideout/form/Media.kt b/src/main/kotlin/dev/usbharu/hideout/domain/model/hideout/form/Media.kt new file mode 100644 index 00000000..978eaa56 --- /dev/null +++ b/src/main/kotlin/dev/usbharu/hideout/domain/model/hideout/form/Media.kt @@ -0,0 +1,10 @@ +package dev.usbharu.hideout.domain.model.hideout.form + +import org.springframework.web.multipart.MultipartFile + +data class Media( + val file: MultipartFile, + val thumbnail: MultipartFile?, + val description: String?, + val focus: String? +) diff --git a/src/main/kotlin/dev/usbharu/hideout/exception/media/MediaConvertException.kt b/src/main/kotlin/dev/usbharu/hideout/exception/media/MediaConvertException.kt new file mode 100644 index 00000000..1082f080 --- /dev/null +++ b/src/main/kotlin/dev/usbharu/hideout/exception/media/MediaConvertException.kt @@ -0,0 +1,14 @@ +package dev.usbharu.hideout.exception.media + +open class MediaConvertException : MediaException { + constructor() : super() + constructor(message: String?) : super(message) + constructor(message: String?, cause: Throwable?) : super(message, cause) + constructor(cause: Throwable?) : super(cause) + constructor(message: String?, cause: Throwable?, enableSuppression: Boolean, writableStackTrace: Boolean) : super( + message, + cause, + enableSuppression, + writableStackTrace + ) +} diff --git a/src/main/kotlin/dev/usbharu/hideout/exception/media/MediaException.kt b/src/main/kotlin/dev/usbharu/hideout/exception/media/MediaException.kt new file mode 100644 index 00000000..922a42a8 --- /dev/null +++ b/src/main/kotlin/dev/usbharu/hideout/exception/media/MediaException.kt @@ -0,0 +1,14 @@ +package dev.usbharu.hideout.exception.media + +abstract class MediaException : RuntimeException { + constructor() : super() + constructor(message: String?) : super(message) + constructor(message: String?, cause: Throwable?) : super(message, cause) + constructor(cause: Throwable?) : super(cause) + constructor(message: String?, cause: Throwable?, enableSuppression: Boolean, writableStackTrace: Boolean) : super( + message, + cause, + enableSuppression, + writableStackTrace + ) +} diff --git a/src/main/kotlin/dev/usbharu/hideout/exception/media/MediaFileSizeException.kt b/src/main/kotlin/dev/usbharu/hideout/exception/media/MediaFileSizeException.kt new file mode 100644 index 00000000..f7bd6536 --- /dev/null +++ b/src/main/kotlin/dev/usbharu/hideout/exception/media/MediaFileSizeException.kt @@ -0,0 +1,14 @@ +package dev.usbharu.hideout.exception.media + +open class MediaFileSizeException : MediaException { + constructor() : super() + constructor(message: String?) : super(message) + constructor(message: String?, cause: Throwable?) : super(message, cause) + constructor(cause: Throwable?) : super(cause) + constructor(message: String?, cause: Throwable?, enableSuppression: Boolean, writableStackTrace: Boolean) : super( + message, + cause, + enableSuppression, + writableStackTrace + ) +} diff --git a/src/main/kotlin/dev/usbharu/hideout/exception/media/MediaFileSizeIsZeroException.kt b/src/main/kotlin/dev/usbharu/hideout/exception/media/MediaFileSizeIsZeroException.kt new file mode 100644 index 00000000..523f94b0 --- /dev/null +++ b/src/main/kotlin/dev/usbharu/hideout/exception/media/MediaFileSizeIsZeroException.kt @@ -0,0 +1,14 @@ +package dev.usbharu.hideout.exception.media + +class MediaFileSizeIsZeroException : MediaFileSizeException { + constructor() : super() + constructor(message: String?) : super(message) + constructor(message: String?, cause: Throwable?) : super(message, cause) + constructor(cause: Throwable?) : super(cause) + constructor(message: String?, cause: Throwable?, enableSuppression: Boolean, writableStackTrace: Boolean) : super( + message, + cause, + enableSuppression, + writableStackTrace + ) +} diff --git a/src/main/kotlin/dev/usbharu/hideout/exception/media/MediaSaveException.kt b/src/main/kotlin/dev/usbharu/hideout/exception/media/MediaSaveException.kt new file mode 100644 index 00000000..8887a3fe --- /dev/null +++ b/src/main/kotlin/dev/usbharu/hideout/exception/media/MediaSaveException.kt @@ -0,0 +1,14 @@ +package dev.usbharu.hideout.exception.media + +open class MediaSaveException : MediaException { + constructor() : super() + constructor(message: String?) : super(message) + constructor(message: String?, cause: Throwable?) : super(message, cause) + constructor(cause: Throwable?) : super(cause) + constructor(message: String?, cause: Throwable?, enableSuppression: Boolean, writableStackTrace: Boolean) : super( + message, + cause, + enableSuppression, + writableStackTrace + ) +} diff --git a/src/main/kotlin/dev/usbharu/hideout/exception/media/UnsupportedMediaException.kt b/src/main/kotlin/dev/usbharu/hideout/exception/media/UnsupportedMediaException.kt new file mode 100644 index 00000000..2fd8fc23 --- /dev/null +++ b/src/main/kotlin/dev/usbharu/hideout/exception/media/UnsupportedMediaException.kt @@ -0,0 +1,14 @@ +package dev.usbharu.hideout.exception.media + +class UnsupportedMediaException : MediaException { + constructor() : super() + constructor(message: String?) : super(message) + constructor(message: String?, cause: Throwable?) : super(message, cause) + constructor(cause: Throwable?) : super(cause) + constructor(message: String?, cause: Throwable?, enableSuppression: Boolean, writableStackTrace: Boolean) : super( + message, + cause, + enableSuppression, + writableStackTrace + ) +} diff --git a/src/main/kotlin/dev/usbharu/hideout/repository/MediaRepository.kt b/src/main/kotlin/dev/usbharu/hideout/repository/MediaRepository.kt new file mode 100644 index 00000000..be55bb86 --- /dev/null +++ b/src/main/kotlin/dev/usbharu/hideout/repository/MediaRepository.kt @@ -0,0 +1,10 @@ +package dev.usbharu.hideout.repository + +import dev.usbharu.hideout.domain.model.hideout.entity.Media + +interface MediaRepository { + suspend fun generateId(): Long + suspend fun save(media: Media): Media + suspend fun findById(id: Long): Media + suspend fun delete(id: Long) +} diff --git a/src/main/kotlin/dev/usbharu/hideout/repository/MediaRepositoryImpl.kt b/src/main/kotlin/dev/usbharu/hideout/repository/MediaRepositoryImpl.kt new file mode 100644 index 00000000..610d3e5a --- /dev/null +++ b/src/main/kotlin/dev/usbharu/hideout/repository/MediaRepositoryImpl.kt @@ -0,0 +1,80 @@ +package dev.usbharu.hideout.repository + +import dev.usbharu.hideout.domain.model.hideout.dto.FileType +import dev.usbharu.hideout.exception.FailedToGetResourcesException +import dev.usbharu.hideout.service.core.IdGenerateService +import dev.usbharu.hideout.util.singleOr +import org.jetbrains.exposed.sql.* +import org.jetbrains.exposed.sql.SqlExpressionBuilder.eq +import org.springframework.stereotype.Repository +import dev.usbharu.hideout.domain.model.hideout.entity.Media as EntityMedia + +@Repository +class MediaRepositoryImpl(private val idGenerateService: IdGenerateService) : MediaRepository { + override suspend fun generateId(): Long = idGenerateService.generateId() + + override suspend fun save(media: EntityMedia): EntityMedia { + if (Media.select { + Media.id eq media.id + }.singleOrNull() != null + ) { + Media.update({ Media.id eq media.id }) { + it[Media.name] = media.name + it[Media.url] = media.url + it[Media.remoteUrl] = media.remoteUrl + it[Media.thumbnailUrl] = media.thumbnailUrl + it[Media.type] = media.type.ordinal + it[Media.blurhash] = media.blurHash + } + } else { + Media.insert { + it[Media.id] = media.id + it[Media.name] = media.name + it[Media.url] = media.url + it[Media.remoteUrl] = media.remoteUrl + it[Media.thumbnailUrl] = media.thumbnailUrl + it[Media.type] = media.type.ordinal + it[Media.blurhash] = media.blurHash + } + } + return media + } + + override suspend fun findById(id: Long): EntityMedia { + return Media + .select { + Media.id eq id + } + .singleOr { + FailedToGetResourcesException("id: $id was not found.") + }.toMedia() + } + + override suspend fun delete(id: Long) { + Media.deleteWhere { + Media.id eq id + } + } + + fun ResultRow.toMedia(): EntityMedia { + return EntityMedia( + id = this[Media.id], + name = this[Media.name], + url = this[Media.url], + remoteUrl = this[Media.remoteUrl], + thumbnailUrl = this[Media.thumbnailUrl], + type = FileType.values().first { it.ordinal == this[Media.type] }, + blurHash = this[Media.blurhash], + ) + } +} + +object Media : Table("media") { + val id = long("id") + val name = varchar("name", 255) + val url = varchar("url", 255) + val remoteUrl = varchar("remote_url", 255).nullable() + val thumbnailUrl = varchar("thumbnail_url", 255).nullable() + val type = integer("type") + val blurhash = varchar("blurhash", 255).nullable() +} diff --git a/src/main/kotlin/dev/usbharu/hideout/repository/ReactionRepositoryImpl.kt b/src/main/kotlin/dev/usbharu/hideout/repository/ReactionRepositoryImpl.kt index 866feaf3..34789884 100644 --- a/src/main/kotlin/dev/usbharu/hideout/repository/ReactionRepositoryImpl.kt +++ b/src/main/kotlin/dev/usbharu/hideout/repository/ReactionRepositoryImpl.kt @@ -12,7 +12,6 @@ class ReactionRepositoryImpl( private val idGenerateService: IdGenerateService ) : ReactionRepository { - override suspend fun generateId(): Long = idGenerateService.generateId() override suspend fun save(reaction: Reaction): Reaction { diff --git a/src/main/kotlin/dev/usbharu/hideout/service/api/mastodon/MediaApiService.kt b/src/main/kotlin/dev/usbharu/hideout/service/api/mastodon/MediaApiService.kt new file mode 100644 index 00000000..8b1da1b9 --- /dev/null +++ b/src/main/kotlin/dev/usbharu/hideout/service/api/mastodon/MediaApiService.kt @@ -0,0 +1,10 @@ +package dev.usbharu.hideout.service.api.mastodon + +import dev.usbharu.hideout.domain.mastodon.model.generated.MediaAttachment +import dev.usbharu.hideout.domain.model.hideout.form.Media +import org.springframework.stereotype.Service + +@Service +interface MediaApiService { + suspend fun postMedia(media: Media): MediaAttachment +} diff --git a/src/main/kotlin/dev/usbharu/hideout/service/api/mastodon/MediaApiServiceImpl.kt b/src/main/kotlin/dev/usbharu/hideout/service/api/mastodon/MediaApiServiceImpl.kt new file mode 100644 index 00000000..ab4d07de --- /dev/null +++ b/src/main/kotlin/dev/usbharu/hideout/service/api/mastodon/MediaApiServiceImpl.kt @@ -0,0 +1,35 @@ +package dev.usbharu.hideout.service.api.mastodon + +import dev.usbharu.hideout.domain.mastodon.model.generated.MediaAttachment +import dev.usbharu.hideout.domain.model.hideout.dto.FileType +import dev.usbharu.hideout.domain.model.hideout.form.Media +import dev.usbharu.hideout.service.core.Transaction +import dev.usbharu.hideout.service.media.MediaService +import org.springframework.stereotype.Service + +@Service +class MediaApiServiceImpl(private val mediaService: MediaService, private val transaction: Transaction) : + MediaApiService { + + override suspend fun postMedia(media: Media): MediaAttachment { + return transaction.transaction { + val uploadLocalMedia = mediaService.uploadLocalMedia(media) + val type = when (uploadLocalMedia.type) { + FileType.Image -> MediaAttachment.Type.image + FileType.Video -> MediaAttachment.Type.video + FileType.Audio -> MediaAttachment.Type.audio + FileType.Unknown -> MediaAttachment.Type.unknown + } + return@transaction MediaAttachment( + id = uploadLocalMedia.id.toString(), + type = type, + url = uploadLocalMedia.url, + previewUrl = uploadLocalMedia.thumbnailUrl, + remoteUrl = null, + description = media.description, + blurhash = uploadLocalMedia.blurHash, + textUrl = uploadLocalMedia.url + ) + } + } +} diff --git a/src/main/kotlin/dev/usbharu/hideout/service/media/FileTypeDeterminationService.kt b/src/main/kotlin/dev/usbharu/hideout/service/media/FileTypeDeterminationService.kt new file mode 100644 index 00000000..5849feea --- /dev/null +++ b/src/main/kotlin/dev/usbharu/hideout/service/media/FileTypeDeterminationService.kt @@ -0,0 +1,7 @@ +package dev.usbharu.hideout.service.media + +import dev.usbharu.hideout.domain.model.hideout.dto.FileType + +interface FileTypeDeterminationService { + fun fileType(byteArray: ByteArray, filename: String, contentType: String?): FileType +} diff --git a/src/main/kotlin/dev/usbharu/hideout/service/media/FileTypeDeterminationServiceImpl.kt b/src/main/kotlin/dev/usbharu/hideout/service/media/FileTypeDeterminationServiceImpl.kt new file mode 100644 index 00000000..a13ebf66 --- /dev/null +++ b/src/main/kotlin/dev/usbharu/hideout/service/media/FileTypeDeterminationServiceImpl.kt @@ -0,0 +1,27 @@ +package dev.usbharu.hideout.service.media + +import dev.usbharu.hideout.domain.model.hideout.dto.FileType +import org.springframework.stereotype.Component + +@Component +class FileTypeDeterminationServiceImpl : FileTypeDeterminationService { + override fun fileType( + byteArray: ByteArray, + filename: String, + contentType: String? + ): FileType { + if (contentType == null) { + return FileType.Unknown + } + if (contentType.startsWith("image")) { + return FileType.Image + } + if (contentType.startsWith("video")) { + return FileType.Video + } + if (contentType.startsWith("audio")) { + return FileType.Audio + } + return FileType.Unknown + } +} diff --git a/src/main/kotlin/dev/usbharu/hideout/service/media/MediaBlurhashService.kt b/src/main/kotlin/dev/usbharu/hideout/service/media/MediaBlurhashService.kt new file mode 100644 index 00000000..490a2ffe --- /dev/null +++ b/src/main/kotlin/dev/usbharu/hideout/service/media/MediaBlurhashService.kt @@ -0,0 +1,7 @@ +package dev.usbharu.hideout.service.media + +import java.awt.image.BufferedImage + +interface MediaBlurhashService { + fun generateBlurhash(bufferedImage: BufferedImage): String +} diff --git a/src/main/kotlin/dev/usbharu/hideout/service/media/MediaBlurhashServiceImpl.kt b/src/main/kotlin/dev/usbharu/hideout/service/media/MediaBlurhashServiceImpl.kt new file mode 100644 index 00000000..d2156796 --- /dev/null +++ b/src/main/kotlin/dev/usbharu/hideout/service/media/MediaBlurhashServiceImpl.kt @@ -0,0 +1,10 @@ +package dev.usbharu.hideout.service.media + +import io.trbl.blurhash.BlurHash +import org.springframework.stereotype.Service +import java.awt.image.BufferedImage + +@Service +class MediaBlurhashServiceImpl : MediaBlurhashService { + override fun generateBlurhash(bufferedImage: BufferedImage): String = BlurHash.encode(bufferedImage) +} diff --git a/src/main/kotlin/dev/usbharu/hideout/service/media/MediaDataStore.kt b/src/main/kotlin/dev/usbharu/hideout/service/media/MediaDataStore.kt new file mode 100644 index 00000000..bd03f704 --- /dev/null +++ b/src/main/kotlin/dev/usbharu/hideout/service/media/MediaDataStore.kt @@ -0,0 +1,9 @@ +package dev.usbharu.hideout.service.media + +import dev.usbharu.hideout.domain.model.MediaSave +import dev.usbharu.hideout.domain.model.hideout.dto.SavedMedia + +interface MediaDataStore { + suspend fun save(dataMediaSave: MediaSave): SavedMedia + suspend fun delete(id: String) +} diff --git a/src/main/kotlin/dev/usbharu/hideout/service/media/MediaService.kt b/src/main/kotlin/dev/usbharu/hideout/service/media/MediaService.kt new file mode 100644 index 00000000..025b22ad --- /dev/null +++ b/src/main/kotlin/dev/usbharu/hideout/service/media/MediaService.kt @@ -0,0 +1,9 @@ +package dev.usbharu.hideout.service.media + +import dev.usbharu.hideout.domain.model.hideout.dto.RemoteMedia +import dev.usbharu.hideout.domain.model.hideout.form.Media + +interface MediaService { + suspend fun uploadLocalMedia(media: Media): dev.usbharu.hideout.domain.model.hideout.entity.Media + suspend fun uploadRemoteMedia(remoteMedia: RemoteMedia) +} diff --git a/src/main/kotlin/dev/usbharu/hideout/service/media/MediaServiceImpl.kt b/src/main/kotlin/dev/usbharu/hideout/service/media/MediaServiceImpl.kt new file mode 100644 index 00000000..313bb8e1 --- /dev/null +++ b/src/main/kotlin/dev/usbharu/hideout/service/media/MediaServiceImpl.kt @@ -0,0 +1,95 @@ +package dev.usbharu.hideout.service.media + +import dev.usbharu.hideout.domain.model.MediaSave +import dev.usbharu.hideout.domain.model.hideout.dto.FaildSavedMedia +import dev.usbharu.hideout.domain.model.hideout.dto.FileType +import dev.usbharu.hideout.domain.model.hideout.dto.RemoteMedia +import dev.usbharu.hideout.domain.model.hideout.dto.SuccessSavedMedia +import dev.usbharu.hideout.domain.model.hideout.form.Media +import dev.usbharu.hideout.exception.media.MediaFileSizeIsZeroException +import dev.usbharu.hideout.exception.media.MediaSaveException +import dev.usbharu.hideout.exception.media.UnsupportedMediaException +import dev.usbharu.hideout.repository.MediaRepository +import dev.usbharu.hideout.service.media.converter.MediaProcessService +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import org.slf4j.LoggerFactory +import org.springframework.stereotype.Service +import java.util.* +import javax.imageio.ImageIO +import dev.usbharu.hideout.domain.model.hideout.entity.Media as EntityMedia + +@Service +class MediaServiceImpl( + private val mediaDataStore: MediaDataStore, + private val fileTypeDeterminationService: FileTypeDeterminationService, + private val mediaBlurhashService: MediaBlurhashService, + private val mediaRepository: MediaRepository, + private val mediaProcessService: MediaProcessService +) : MediaService { + override suspend fun uploadLocalMedia(media: Media): EntityMedia { + logger.info( + "Media upload. filename:${media.file.name} size:${media.file.size} contentType:${media.file.contentType}" + ) + + if (media.file.size == 0L) { + throw MediaFileSizeIsZeroException("Media file size is zero.") + } + + val fileType = fileTypeDeterminationService.fileType(media.file.bytes, media.file.name, media.file.contentType) + if (fileType != FileType.Image) { + throw UnsupportedMediaException("FileType: $fileType is not supported.") + } + + val process = mediaProcessService.process( + fileType, + media.file.contentType.orEmpty(), + media.file.name, + media.file.bytes, + media.thumbnail?.bytes + ) + + val dataMediaSave = MediaSave( + "${UUID.randomUUID()}.${process.file.extension}", + "", + process.file.byteArray, + process.thumbnail?.byteArray + ) + val save = try { + mediaDataStore.save(dataMediaSave) + } catch (e: Exception) { + logger.warn("Failed save media", e) + throw MediaSaveException("Failed save media.", e) + } + + if (save.success.not()) { + save as FaildSavedMedia + logger.warn("Failed save media. reason: ${save.reason}") + logger.warn(save.description, save.trace) + throw MediaSaveException("Failed save media.") + } + save as SuccessSavedMedia + + val blurHash = withContext(Dispatchers.IO) { + mediaBlurhashService.generateBlurhash(ImageIO.read(media.file.bytes.inputStream())) + } + + return mediaRepository.save( + EntityMedia( + id = mediaRepository.generateId(), + name = media.file.name, + url = save.url, + remoteUrl = null, + thumbnailUrl = save.thumbnailUrl, + type = fileType, + blurHash = blurHash + ) + ) + } + + override suspend fun uploadRemoteMedia(remoteMedia: RemoteMedia) = Unit + + companion object { + private val logger = LoggerFactory.getLogger(MediaServiceImpl::class.java) + } +} diff --git a/src/main/kotlin/dev/usbharu/hideout/service/media/S3MediaDataStore.kt b/src/main/kotlin/dev/usbharu/hideout/service/media/S3MediaDataStore.kt new file mode 100644 index 00000000..a10482f5 --- /dev/null +++ b/src/main/kotlin/dev/usbharu/hideout/service/media/S3MediaDataStore.kt @@ -0,0 +1,67 @@ +package dev.usbharu.hideout.service.media + +import dev.usbharu.hideout.config.StorageConfig +import dev.usbharu.hideout.domain.model.MediaSave +import dev.usbharu.hideout.domain.model.hideout.dto.SavedMedia +import dev.usbharu.hideout.domain.model.hideout.dto.SuccessSavedMedia +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitAll +import kotlinx.coroutines.withContext +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.GetUrlRequest +import software.amazon.awssdk.services.s3.model.PutObjectRequest + +@Service +class S3MediaDataStore(private val s3Client: S3Client, private val storageConfig: StorageConfig) : MediaDataStore { + override suspend fun save(dataMediaSave: MediaSave): SavedMedia { + val fileUploadRequest = PutObjectRequest.builder() + .bucket(storageConfig.bucket) + .key(dataMediaSave.name) + .build() + + val thumbnailKey = "thumbnail-${dataMediaSave.name}" + val thumbnailUploadRequest = PutObjectRequest.builder() + .bucket(storageConfig.bucket) + .key(thumbnailKey) + .build() + + val pairList = withContext(Dispatchers.IO) { + awaitAll( + async { + if (dataMediaSave.thumbnailInputStream != null) { + s3Client.putObject( + thumbnailUploadRequest, + RequestBody.fromBytes(dataMediaSave.thumbnailInputStream) + ) + "thumbnail" to s3Client.utilities() + .getUrl(GetUrlRequest.builder().bucket(storageConfig.bucket).key(thumbnailKey).build()) + } else { + "thumbnail" to null + } + }, + async { + s3Client.putObject(fileUploadRequest, RequestBody.fromBytes(dataMediaSave.fileInputStream)) + "file" to s3Client.utilities() + .getUrl(GetUrlRequest.builder().bucket(storageConfig.bucket).key(dataMediaSave.name).build()) + } + ) + }.toMap() + return SuccessSavedMedia( + dataMediaSave.name, + pairList.getValue("file").toString(), + pairList.getValue("thumbnail").toString() + ) + } + + override suspend fun delete(id: String) { + val fileDeleteRequest = DeleteObjectRequest.builder().bucket(storageConfig.bucket).key(id).build() + val thumbnailDeleteRequest = + DeleteObjectRequest.builder().bucket(storageConfig.bucket).key("thumbnail-$id").build() + s3Client.deleteObject(fileDeleteRequest) + s3Client.deleteObject(thumbnailDeleteRequest) + } +} diff --git a/src/main/kotlin/dev/usbharu/hideout/service/media/ThumbnailGenerateService.kt b/src/main/kotlin/dev/usbharu/hideout/service/media/ThumbnailGenerateService.kt new file mode 100644 index 00000000..cb8438d6 --- /dev/null +++ b/src/main/kotlin/dev/usbharu/hideout/service/media/ThumbnailGenerateService.kt @@ -0,0 +1,9 @@ +package dev.usbharu.hideout.service.media + +import dev.usbharu.hideout.domain.model.hideout.dto.ProcessedFile +import java.io.InputStream + +interface ThumbnailGenerateService { + fun generate(bufferedImage: InputStream, width: Int, height: Int): ProcessedFile? + fun generate(outputStream: ByteArray, width: Int, height: Int): ProcessedFile? +} diff --git a/src/main/kotlin/dev/usbharu/hideout/service/media/ThumbnailGenerateServiceImpl.kt b/src/main/kotlin/dev/usbharu/hideout/service/media/ThumbnailGenerateServiceImpl.kt new file mode 100644 index 00000000..7f736de5 --- /dev/null +++ b/src/main/kotlin/dev/usbharu/hideout/service/media/ThumbnailGenerateServiceImpl.kt @@ -0,0 +1,27 @@ +package dev.usbharu.hideout.service.media + +import dev.usbharu.hideout.domain.model.hideout.dto.ProcessedFile +import org.springframework.stereotype.Service +import java.awt.image.BufferedImage +import java.io.ByteArrayOutputStream +import java.io.InputStream +import javax.imageio.ImageIO + +@Service +class ThumbnailGenerateServiceImpl : ThumbnailGenerateService { + override fun generate(bufferedImage: InputStream, width: Int, height: Int): ProcessedFile? { + val image = ImageIO.read(bufferedImage) + return internalGenerate(image) + } + + override fun generate(outputStream: ByteArray, width: Int, height: Int): ProcessedFile? { + val image = ImageIO.read(outputStream.inputStream()) + return internalGenerate(image) + } + + private fun internalGenerate(image: BufferedImage): ProcessedFile { + val byteArrayOutputStream = ByteArrayOutputStream() + ImageIO.write(image, "jpeg", byteArrayOutputStream) + return ProcessedFile(byteArrayOutputStream.toByteArray(), "jpg") + } +} diff --git a/src/main/kotlin/dev/usbharu/hideout/service/media/converter/MediaConverter.kt b/src/main/kotlin/dev/usbharu/hideout/service/media/converter/MediaConverter.kt new file mode 100644 index 00000000..0829ac46 --- /dev/null +++ b/src/main/kotlin/dev/usbharu/hideout/service/media/converter/MediaConverter.kt @@ -0,0 +1,10 @@ +package dev.usbharu.hideout.service.media.converter + +import dev.usbharu.hideout.domain.model.hideout.dto.FileType +import dev.usbharu.hideout.domain.model.hideout.dto.ProcessedFile +import java.io.InputStream + +interface MediaConverter { + fun isSupport(fileType: FileType): Boolean + fun convert(inputStream: InputStream): ProcessedFile +} diff --git a/src/main/kotlin/dev/usbharu/hideout/service/media/converter/MediaConverterRoot.kt b/src/main/kotlin/dev/usbharu/hideout/service/media/converter/MediaConverterRoot.kt new file mode 100644 index 00000000..4965da2a --- /dev/null +++ b/src/main/kotlin/dev/usbharu/hideout/service/media/converter/MediaConverterRoot.kt @@ -0,0 +1,14 @@ +package dev.usbharu.hideout.service.media.converter + +import dev.usbharu.hideout.domain.model.hideout.dto.FileType +import dev.usbharu.hideout.domain.model.hideout.dto.ProcessedFile +import java.io.InputStream + +interface MediaConverterRoot { + suspend fun convert( + fileType: FileType, + contentType: String, + filename: String, + inputStream: InputStream + ): ProcessedFile +} diff --git a/src/main/kotlin/dev/usbharu/hideout/service/media/converter/MediaConverterRootImpl.kt b/src/main/kotlin/dev/usbharu/hideout/service/media/converter/MediaConverterRootImpl.kt new file mode 100644 index 00000000..4d340bef --- /dev/null +++ b/src/main/kotlin/dev/usbharu/hideout/service/media/converter/MediaConverterRootImpl.kt @@ -0,0 +1,32 @@ +package dev.usbharu.hideout.service.media.converter + +import dev.usbharu.hideout.domain.model.hideout.dto.FileType +import dev.usbharu.hideout.domain.model.hideout.dto.ProcessedFile +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import org.springframework.stereotype.Service +import java.io.InputStream + +@Service +class MediaConverterRootImpl(private val converters: List) : MediaConverterRoot { + override suspend fun convert( + fileType: FileType, + contentType: String, + filename: String, + inputStream: InputStream + ): ProcessedFile { + val convert = converters.find { + it.isSupport(fileType) + }?.convert(inputStream) + if (convert != null) { + return convert + } + return withContext(Dispatchers.IO) { + if (filename.contains('.')) { + ProcessedFile(inputStream.readAllBytes(), filename.substringAfterLast(".")) + } else { + ProcessedFile(inputStream.readAllBytes(), contentType.substringAfterLast("/")) + } + } + } +} diff --git a/src/main/kotlin/dev/usbharu/hideout/service/media/converter/MediaProcessService.kt b/src/main/kotlin/dev/usbharu/hideout/service/media/converter/MediaProcessService.kt new file mode 100644 index 00000000..5df2c16a --- /dev/null +++ b/src/main/kotlin/dev/usbharu/hideout/service/media/converter/MediaProcessService.kt @@ -0,0 +1,14 @@ +package dev.usbharu.hideout.service.media.converter + +import dev.usbharu.hideout.domain.model.hideout.dto.FileType +import dev.usbharu.hideout.domain.model.hideout.dto.ProcessedMedia + +interface MediaProcessService { + suspend fun process( + fileType: FileType, + contentType: String, + fileName: String, + file: ByteArray, + thumbnail: ByteArray? + ): ProcessedMedia +} diff --git a/src/main/kotlin/dev/usbharu/hideout/service/media/converter/MediaProcessServiceImpl.kt b/src/main/kotlin/dev/usbharu/hideout/service/media/converter/MediaProcessServiceImpl.kt new file mode 100644 index 00000000..c32688d5 --- /dev/null +++ b/src/main/kotlin/dev/usbharu/hideout/service/media/converter/MediaProcessServiceImpl.kt @@ -0,0 +1,47 @@ +package dev.usbharu.hideout.service.media.converter + +import dev.usbharu.hideout.domain.model.hideout.dto.FileType +import dev.usbharu.hideout.domain.model.hideout.dto.ProcessedMedia +import dev.usbharu.hideout.exception.media.MediaConvertException +import dev.usbharu.hideout.service.media.ThumbnailGenerateService +import org.slf4j.LoggerFactory +import org.springframework.stereotype.Service + +@Service +class MediaProcessServiceImpl( + private val mediaConverterRoot: MediaConverterRoot, + private val thumbnailGenerateService: ThumbnailGenerateService +) : MediaProcessService { + override suspend fun process( + fileType: FileType, + contentType: String, + filename: String, + file: ByteArray, + thumbnail: ByteArray? + ): ProcessedMedia { + val fileInputStream = try { + mediaConverterRoot.convert(fileType, contentType, filename, file.inputStream().buffered()) + } catch (e: Exception) { + logger.warn("Failed convert media.", e) + throw MediaConvertException("Failed convert media.", e) + } + val thumbnailInputStream = try { + thumbnail?.let { mediaConverterRoot.convert(fileType, contentType, filename, it.inputStream().buffered()) } + } catch (e: Exception) { + logger.warn("Failed convert thumbnail media.", e) + null + } + return ProcessedMedia( + fileInputStream, + thumbnailGenerateService.generate( + thumbnailInputStream?.byteArray ?: file, + 2048, + 2048 + ) + ) + } + + companion object { + private val logger = LoggerFactory.getLogger(MediaProcessServiceImpl::class.java) + } +} diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 0c4a32a1..143486fb 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -7,6 +7,11 @@ hideout: key-id: a private-key: "MIIEvwIBADANBgkqhkiG9w0BAQEFAASCBKkwggSlAgEAAoIBAQC7VJTUt9Us8cKjMzEfYyjiWA4R4/M2bS1GB4t7NXp98C3SC6dVMvDuictGeurT8jNbvJZHtCSuYEvuNMoSfm76oqFvAp8Gy0iz5sxjZmSnXyCdPEovGhLa0VzMaQ8s+CLOyS56YyCFGeJZqgtzJ6GR3eqoYSW9b9UMvkBpZODSctWSNGj3P7jRFDO5VoTwCQAWbFnOjDfH5Ulgp2PKSQnSJP3AJLQNFNe7br1XbrhV//eO+t51mIpGSDCUv3E0DDFcWDTH9cXDTTlRZVEiR2BwpZOOkE/Z0/BVnhZYL71oZV34bKfWjQIt6V/isSMahdsAASACp4ZTGtwiVuNd9tybAgMBAAECggEBAKTmjaS6tkK8BlPXClTQ2vpz/N6uxDeS35mXpqasqskVlaAidgg/sWqpjXDbXr93otIMLlWsM+X0CqMDgSXKejLS2jx4GDjI1ZTXg++0AMJ8sJ74pWzVDOfmCEQ/7wXs3+cbnXhKriO8Z036q92Qc1+N87SI38nkGa0ABH9CN83HmQqt4fB7UdHzuIRe/me2PGhIq5ZBzj6h3BpoPGzEP+x3l9YmK8t/1cN0pqI+dQwYdgfGjackLu/2qH80MCF7IyQaseZUOJyKrCLtSD/Iixv/hzDEUPfOCjFDgTpzf3cwta8+oE4wHCo1iI1/4TlPkwmXx4qSXtmw4aQPz7IDQvECgYEA8KNThCO2gsC2I9PQDM/8Cw0O983WCDY+oi+7JPiNAJwv5DYBqEZB1QYdj06YD16XlC/HAZMsMku1na2TN0driwenQQWzoev3g2S7gRDoS/FCJSI3jJ+kjgtaA7Qmzlgk1TxODN+G1H91HW7t0l7VnL27IWyYo2qRRK3jzxqUiPUCgYEAx0oQs2reBQGMVZnApD1jeq7n4MvNLcPvt8b/eU9iUv6Y4Mj0Suo/AU8lYZXm8ubbqAlwz2VSVunD2tOplHyMUrtCtObAfVDUAhCndKaA9gApgfb3xw1IKbuQ1u4IF1FJl3VtumfQn//LiH1B3rXhcdyo3/vIttEk48RakUKClU8CgYEAzV7W3COOlDDcQd935DdtKBFRAPRPAlspQUnzMi5eSHMD/ISLDY5IiQHbIH83D4bvXq0X7qQoSBSNP7Dvv3HYuqMhf0DaegrlBuJllFVVq9qPVRnKxt1Il2HgxOBvbhOT+9in1BzA+YJ99UzC85O0Qz06A+CmtHEy4aZ2kj5hHjECgYEAmNS4+A8Fkss8Js1RieK2LniBxMgmYml3pfVLKGnzmng7H2+cwPLhPIzIuwytXywh2bzbsYEfYx3EoEVgMEpPhoarQnYPukrJO4gwE2o5Te6T5mJSZGlQJQj9q4ZB2Dfzet6INsK0oG8XVGXSpQvQh3RUYekCZQkBBFcpqWpbIEsCgYAnM3DQf3FJoSnXaMhrVBIovic5l0xFkEHskAjFTevO86Fsz1C2aSeRKSqGFoOQ0tmJzBEs1R6KqnHInicDTQrKhArgLXX4v3CddjfTRJkFWDbE/CkvKZNOrcf1nhaGCPspRJj2KUkj1Fhl9Cncdn/RsYEONbwQSjIfMPkvxF+8HQ==" public-key: "MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAu1SU1LfVLPHCozMxH2Mo4lgOEePzNm0tRgeLezV6ffAt0gunVTLw7onLRnrq0/IzW7yWR7QkrmBL7jTKEn5u+qKhbwKfBstIs+bMY2Zkp18gnTxKLxoS2tFczGkPLPgizskuemMghRniWaoLcyehkd3qqGElvW/VDL5AaWTg0nLVkjRo9z+40RQzuVaE8AkAFmxZzow3x+VJYKdjykkJ0iT9wCS0DRTXu269V264Vf/3jvredZiKRkgwlL9xNAwxXFg0x/XFw005UWVRIkdgcKWTjpBP2dPwVZ4WWC+9aGVd+Gyn1o0CLelf4rEjGoXbAAEgAqeGUxrcIlbjXfbcmwIDAQAB" + + + + + spring: jackson: serialization: diff --git a/src/main/resources/openapi/mastodon.yaml b/src/main/resources/openapi/mastodon.yaml index 5464a28a..6f93fa36 100644 --- a/src/main/resources/openapi/mastodon.yaml +++ b/src/main/resources/openapi/mastodon.yaml @@ -17,6 +17,8 @@ tags: description: instance - name: timeline description: timeline + - name: media + description: media paths: /api/v2/instance: @@ -291,9 +293,48 @@ paths: type: array items: $ref: "#/components/schemas/Status" + /api/v1/media: + post: + tags: + - media + security: + - OAuth2: + - "write:media" + requestBody: + required: true + content: + multipart/form-data: + schema: + $ref: "#/components/schemas/V1MediaRequest" + encoding: + file: + contentType: image/jpeg, image/png + responses: + 200: + description: 成功 + content: + application/json: + schema: + $ref: "#/components/schemas/MediaAttachment" components: schemas: + V1MediaRequest: + type: object + properties: + file: + type: string + format: binary + thumbnail: + type: string + format: binary + description: + type: string + focus: + type: string + required: + - file + AccountsCreateRequest: type: object properties: