Merge pull request #71 from usbharu/feature/media-upload

Feature/media upload
This commit is contained in:
usbharu 2023-10-05 12:26:07 +09:00 committed by GitHub
commit 889aaf60b2
41 changed files with 794 additions and 1 deletions

View File

@ -60,6 +60,8 @@ tasks.create<GenerateTask>("openApiGenerateMastodonCompatibleApi", GenerateTask:
configOptions.put("interfaceOnly", "true") configOptions.put("interfaceOnly", "true")
configOptions.put("useSpringBoot3", "true") configOptions.put("useSpringBoot3", "true")
additionalProperties.put("useTags", "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 { repositories {
@ -116,6 +118,9 @@ dependencies {
implementation("org.springframework.security:spring-security-oauth2-jose") implementation("org.springframework.security:spring-security-oauth2-jose")
implementation("org.springframework.boot:spring-boot-starter-data-mongodb") implementation("org.springframework.boot:spring-boot-starter-data-mongodb")
implementation("org.jetbrains.exposed:exposed-spring-boot-starter:0.44.0") 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") implementation("io.ktor:ktor-client-logging-jvm:$ktor_version")

View File

@ -3,6 +3,7 @@ build:
weights: weights:
Indentation: 0 Indentation: 0
MagicNumber: 0 MagicNumber: 0
InjectDispatcher: 0
style: style:
ClassOrdering: ClassOrdering:

View File

@ -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()
}
}

View File

@ -10,9 +10,23 @@ class SpringConfig {
@Autowired @Autowired
lateinit var config: ApplicationConfig lateinit var config: ApplicationConfig
@Autowired
lateinit var storageConfig: StorageConfig
} }
@ConfigurationProperties("hideout") @ConfigurationProperties("hideout")
data class ApplicationConfig( data class ApplicationConfig(
val url: URL 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
)

View File

@ -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<MediaAttachment> = runBlocking {
ResponseEntity.ok(
mediaApiService.postMedia(
Media(
file,
thumbnail,
description,
focus
)
)
)
}
}

View File

@ -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?
)

View File

@ -0,0 +1,8 @@
package dev.usbharu.hideout.domain.model.hideout.dto
enum class FileType {
Image,
Video,
Audio,
Unknown
}

View File

@ -0,0 +1,6 @@
package dev.usbharu.hideout.domain.model.hideout.dto
data class ProcessedFile(
val byteArray: ByteArray,
val extension: String
)

View File

@ -0,0 +1,6 @@
package dev.usbharu.hideout.domain.model.hideout.dto
data class ProcessedMedia(
val file: ProcessedFile,
val thumbnail: ProcessedFile?
)

View File

@ -0,0 +1,7 @@
package dev.usbharu.hideout.domain.model.hideout.dto
data class RemoteMedia(
val name: String,
val url: String,
val mediaType: String
)

View File

@ -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)

View File

@ -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?
)

View File

@ -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?
)

View File

@ -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
)
}

View File

@ -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
)
}

View File

@ -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
)
}

View File

@ -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
)
}

View File

@ -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
)
}

View File

@ -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
)
}

View File

@ -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)
}

View File

@ -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()
}

View File

@ -12,7 +12,6 @@ class ReactionRepositoryImpl(
private val idGenerateService: IdGenerateService private val idGenerateService: IdGenerateService
) : ReactionRepository { ) : ReactionRepository {
override suspend fun generateId(): Long = idGenerateService.generateId() override suspend fun generateId(): Long = idGenerateService.generateId()
override suspend fun save(reaction: Reaction): Reaction { override suspend fun save(reaction: Reaction): Reaction {

View File

@ -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
}

View File

@ -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
)
}
}
}

View File

@ -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
}

View File

@ -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
}
}

View File

@ -0,0 +1,7 @@
package dev.usbharu.hideout.service.media
import java.awt.image.BufferedImage
interface MediaBlurhashService {
fun generateBlurhash(bufferedImage: BufferedImage): String
}

View File

@ -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)
}

View File

@ -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)
}

View File

@ -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)
}

View File

@ -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)
}
}

View File

@ -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)
}
}

View File

@ -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?
}

View File

@ -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")
}
}

View File

@ -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
}

View File

@ -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
}

View File

@ -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<MediaConverter>) : 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("/"))
}
}
}
}

View File

@ -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
}

View File

@ -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)
}
}

View File

@ -7,6 +7,11 @@ hideout:
key-id: a 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==" 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" public-key: "MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAu1SU1LfVLPHCozMxH2Mo4lgOEePzNm0tRgeLezV6ffAt0gunVTLw7onLRnrq0/IzW7yWR7QkrmBL7jTKEn5u+qKhbwKfBstIs+bMY2Zkp18gnTxKLxoS2tFczGkPLPgizskuemMghRniWaoLcyehkd3qqGElvW/VDL5AaWTg0nLVkjRo9z+40RQzuVaE8AkAFmxZzow3x+VJYKdjykkJ0iT9wCS0DRTXu269V264Vf/3jvredZiKRkgwlL9xNAwxXFg0x/XFw005UWVRIkdgcKWTjpBP2dPwVZ4WWC+9aGVd+Gyn1o0CLelf4rEjGoXbAAEgAqeGUxrcIlbjXfbcmwIDAQAB"
spring: spring:
jackson: jackson:
serialization: serialization:

View File

@ -17,6 +17,8 @@ tags:
description: instance description: instance
- name: timeline - name: timeline
description: timeline description: timeline
- name: media
description: media
paths: paths:
/api/v2/instance: /api/v2/instance:
@ -291,9 +293,48 @@ paths:
type: array type: array
items: items:
$ref: "#/components/schemas/Status" $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: components:
schemas: schemas:
V1MediaRequest:
type: object
properties:
file:
type: string
format: binary
thumbnail:
type: string
format: binary
description:
type: string
focus:
type: string
required:
- file
AccountsCreateRequest: AccountsCreateRequest:
type: object type: object
properties: properties: