feat: S3にアップロードできるように

This commit is contained in:
usbharu 2023-10-05 11:54:27 +09:00
parent 97cf5eac65
commit e4ac377404
15 changed files with 102 additions and 44 deletions

View File

@ -3,13 +3,17 @@ package dev.usbharu.hideout.config
import org.springframework.context.annotation.Bean import org.springframework.context.annotation.Bean
import org.springframework.context.annotation.Configuration import org.springframework.context.annotation.Configuration
import software.amazon.awssdk.auth.credentials.AwsBasicCredentials import software.amazon.awssdk.auth.credentials.AwsBasicCredentials
import software.amazon.awssdk.regions.Region
import software.amazon.awssdk.services.s3.S3Client import software.amazon.awssdk.services.s3.S3Client
import java.net.URI
@Configuration @Configuration
class AwsConfig { class AwsConfig {
@Bean @Bean
fun s3(awsConfig: StorageConfig): S3Client { fun s3(awsConfig: StorageConfig): S3Client {
return S3Client.builder() return S3Client.builder()
.endpointOverride(URI.create(awsConfig.endpoint))
.region(Region.of(awsConfig.region))
.credentialsProvider { AwsBasicCredentials.create(awsConfig.accessKey, awsConfig.secretKey) } .credentialsProvider { AwsBasicCredentials.create(awsConfig.accessKey, awsConfig.secretKey) }
.build() .build()
} }

View File

@ -4,6 +4,7 @@ import dev.usbharu.hideout.controller.mastodon.generated.MediaApi
import dev.usbharu.hideout.domain.mastodon.model.generated.MediaAttachment import dev.usbharu.hideout.domain.mastodon.model.generated.MediaAttachment
import dev.usbharu.hideout.domain.model.hideout.form.Media import dev.usbharu.hideout.domain.model.hideout.form.Media
import dev.usbharu.hideout.service.api.mastodon.MediaApiService import dev.usbharu.hideout.service.api.mastodon.MediaApiService
import kotlinx.coroutines.runBlocking
import org.springframework.http.ResponseEntity import org.springframework.http.ResponseEntity
import org.springframework.stereotype.Controller import org.springframework.stereotype.Controller
import org.springframework.web.multipart.MultipartFile import org.springframework.web.multipart.MultipartFile
@ -15,8 +16,8 @@ class MastodonMediaApiController(private val mediaApiService: MediaApiService) :
thumbnail: MultipartFile?, thumbnail: MultipartFile?,
description: String?, description: String?,
focus: String? focus: String?
): ResponseEntity<MediaAttachment> { ): ResponseEntity<MediaAttachment> = runBlocking {
return ResponseEntity.ok( ResponseEntity.ok(
mediaApiService.postMedia( mediaApiService.postMedia(
Media( Media(
file, file,

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

@ -2,13 +2,21 @@ package dev.usbharu.hideout.service.api.mastodon
import dev.usbharu.hideout.domain.mastodon.model.generated.MediaAttachment import dev.usbharu.hideout.domain.mastodon.model.generated.MediaAttachment
import dev.usbharu.hideout.domain.model.hideout.form.Media import dev.usbharu.hideout.domain.model.hideout.form.Media
import dev.usbharu.hideout.service.core.Transaction
import dev.usbharu.hideout.service.media.MediaService import dev.usbharu.hideout.service.media.MediaService
import org.springframework.stereotype.Service
@Service
class MediaApiServiceImpl(private val mediaService: MediaService, private val transaction: Transaction) :
MediaApiService {
class MediaApiServiceImpl(private val mediaService: MediaService) : MediaApiService {
override suspend fun postMedia(media: Media): MediaAttachment { override suspend fun postMedia(media: Media): MediaAttachment {
return transaction.transaction {
val uploadLocalMedia = mediaService.uploadLocalMedia(media) val uploadLocalMedia = mediaService.uploadLocalMedia(media)
return MediaAttachment( return@transaction MediaAttachment(
) )
} }
} }
}

View File

@ -28,6 +28,8 @@ class MediaServiceImpl(
private val mediaProcessService: MediaProcessService private val mediaProcessService: MediaProcessService
) : MediaService { ) : MediaService {
override suspend fun uploadLocalMedia(media: Media): EntityMedia { 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) { if (media.file.size == 0L) {
throw MediaFileSizeIsZeroException("Media file size is zero.") throw MediaFileSizeIsZeroException("Media file size is zero.")
} }
@ -37,13 +39,19 @@ class MediaServiceImpl(
throw UnsupportedMediaException("FileType: $fileType is not supported.") throw UnsupportedMediaException("FileType: $fileType is not supported.")
} }
val process = mediaProcessService.process(fileType, media.file.bytes, media.thumbnail?.bytes) val process = mediaProcessService.process(
fileType,
media.file.contentType.orEmpty(),
media.file.name,
media.file.bytes,
media.thumbnail?.bytes
)
val dataMediaSave = MediaSave( val dataMediaSave = MediaSave(
UUID.randomUUID().toString(), "${UUID.randomUUID()}.${process.file.extension}",
"", "",
process.first, process.file.byteArray,
process.second process.thumbnail?.byteArray
) )
val save = try { val save = try {
mediaDataStore.save(dataMediaSave) mediaDataStore.save(dataMediaSave)

View File

@ -33,7 +33,10 @@ class S3MediaDataStore(private val s3Client: S3Client, private val storageConfig
awaitAll( awaitAll(
async { async {
if (dataMediaSave.thumbnailInputStream != null) { if (dataMediaSave.thumbnailInputStream != null) {
s3Client.putObject(fileUploadRequest, RequestBody.fromBytes(dataMediaSave.thumbnailInputStream)) s3Client.putObject(
thumbnailUploadRequest,
RequestBody.fromBytes(dataMediaSave.thumbnailInputStream)
)
"thumbnail" to s3Client.utilities() "thumbnail" to s3Client.utilities()
.getUrl(GetUrlRequest.builder().bucket(storageConfig.bucket).key(thumbnailKey).build()) .getUrl(GetUrlRequest.builder().bucket(storageConfig.bucket).key(thumbnailKey).build())
} else { } else {
@ -41,7 +44,7 @@ class S3MediaDataStore(private val s3Client: S3Client, private val storageConfig
} }
}, },
async { async {
s3Client.putObject(thumbnailUploadRequest, RequestBody.fromBytes(dataMediaSave.fileInputStream)) s3Client.putObject(fileUploadRequest, RequestBody.fromBytes(dataMediaSave.fileInputStream))
"file" to s3Client.utilities() "file" to s3Client.utilities()
.getUrl(GetUrlRequest.builder().bucket(storageConfig.bucket).key(dataMediaSave.name).build()) .getUrl(GetUrlRequest.builder().bucket(storageConfig.bucket).key(dataMediaSave.name).build())
} }

View File

@ -1,8 +1,9 @@
package dev.usbharu.hideout.service.media package dev.usbharu.hideout.service.media
import dev.usbharu.hideout.domain.model.hideout.dto.ProcessedFile
import java.io.InputStream import java.io.InputStream
interface ThumbnailGenerateService { interface ThumbnailGenerateService {
fun generate(bufferedImage: InputStream, width: Int, height: Int): ByteArray fun generate(bufferedImage: InputStream, width: Int, height: Int): ProcessedFile?
fun generate(outputStream: ByteArray, width: Int, height: Int): ByteArray fun generate(outputStream: ByteArray, width: Int, height: Int): ProcessedFile?
} }

View File

@ -1,28 +1,27 @@
package dev.usbharu.hideout.service.media package dev.usbharu.hideout.service.media
import dev.usbharu.hideout.domain.model.hideout.dto.ProcessedFile
import org.springframework.stereotype.Service import org.springframework.stereotype.Service
import java.awt.image.BufferedImage import java.awt.image.BufferedImage
import java.io.ByteArrayOutputStream import java.io.ByteArrayOutputStream
import java.io.InputStream import java.io.InputStream
import java.io.OutputStream
import javax.imageio.ImageIO import javax.imageio.ImageIO
import javax.imageio.stream.MemoryCacheImageOutputStream
@Service @Service
class ThumbnailGenerateServiceImpl : ThumbnailGenerateService { class ThumbnailGenerateServiceImpl : ThumbnailGenerateService {
override fun generate(bufferedImage: InputStream, width: Int, height: Int): ByteArrayOutputStream { override fun generate(bufferedImage: InputStream, width: Int, height: Int): ProcessedFile? {
val image = ImageIO.read(bufferedImage) val image = ImageIO.read(bufferedImage)
return internalGenerate(image) return internalGenerate(image)
} }
override fun generate(outputStream: OutputStream, width: Int, height: Int): ByteArrayOutputStream { override fun generate(outputStream: ByteArray, width: Int, height: Int): ProcessedFile? {
val image = ImageIO.read(MemoryCacheImageOutputStream(outputStream)) val image = ImageIO.read(outputStream.inputStream())
return internalGenerate(image) return internalGenerate(image)
} }
private fun internalGenerate(image: BufferedImage): ByteArrayOutputStream { private fun internalGenerate(image: BufferedImage): ProcessedFile {
val byteArrayOutputStream = ByteArrayOutputStream() val byteArrayOutputStream = ByteArrayOutputStream()
ImageIO.write(image, "webp", byteArrayOutputStream) ImageIO.write(image, "jpeg", byteArrayOutputStream)
return byteArrayOutputStream return ProcessedFile(byteArrayOutputStream.toByteArray(), "jpg")
} }
} }

View File

@ -1,9 +1,10 @@
package dev.usbharu.hideout.service.media.converter package dev.usbharu.hideout.service.media.converter
import dev.usbharu.hideout.domain.model.hideout.dto.FileType import dev.usbharu.hideout.domain.model.hideout.dto.FileType
import dev.usbharu.hideout.domain.model.hideout.dto.ProcessedFile
import java.io.InputStream import java.io.InputStream
interface MediaConverter { interface MediaConverter {
fun isSupport(fileType: FileType): Boolean fun isSupport(fileType: FileType): Boolean
fun convert(inputStream: InputStream): ByteArray fun convert(inputStream: InputStream): ProcessedFile
} }

View File

@ -1,8 +1,14 @@
package dev.usbharu.hideout.service.media.converter package dev.usbharu.hideout.service.media.converter
import dev.usbharu.hideout.domain.model.hideout.dto.FileType import dev.usbharu.hideout.domain.model.hideout.dto.FileType
import dev.usbharu.hideout.domain.model.hideout.dto.ProcessedFile
import java.io.InputStream import java.io.InputStream
interface MediaConverterRoot { interface MediaConverterRoot {
suspend fun convert(fileType: FileType, inputStream: InputStream): ByteArray suspend fun convert(
fileType: FileType,
contentType: String,
filename: String,
inputStream: InputStream
): ProcessedFile
} }

View File

@ -1,6 +1,7 @@
package dev.usbharu.hideout.service.media.converter package dev.usbharu.hideout.service.media.converter
import dev.usbharu.hideout.domain.model.hideout.dto.FileType 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.Dispatchers
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import org.springframework.stereotype.Service import org.springframework.stereotype.Service
@ -8,11 +9,24 @@ import java.io.InputStream
@Service @Service
class MediaConverterRootImpl(private val converters: List<MediaConverter>) : MediaConverterRoot { class MediaConverterRootImpl(private val converters: List<MediaConverter>) : MediaConverterRoot {
override suspend fun convert(fileType: FileType, inputStream: InputStream): ByteArray { override suspend fun convert(
return converters.find { fileType: FileType,
contentType: String,
filename: String,
inputStream: InputStream
): ProcessedFile {
val convert = converters.find {
it.isSupport(fileType) it.isSupport(fileType)
}?.convert(inputStream) ?: withContext(Dispatchers.IO) { }?.convert(inputStream)
inputStream.readAllBytes() 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

@ -1,11 +1,14 @@
package dev.usbharu.hideout.service.media.converter package dev.usbharu.hideout.service.media.converter
import dev.usbharu.hideout.domain.model.hideout.dto.FileType import dev.usbharu.hideout.domain.model.hideout.dto.FileType
import dev.usbharu.hideout.domain.model.hideout.dto.ProcessedMedia
interface MediaProcessService { interface MediaProcessService {
suspend fun process( suspend fun process(
fileType: FileType, fileType: FileType,
contentType: String,
fileName: String,
file: ByteArray, file: ByteArray,
thumbnail: ByteArray? thumbnail: ByteArray?
): Pair<ByteArray, ByteArray> ): ProcessedMedia
} }

View File

@ -1,6 +1,7 @@
package dev.usbharu.hideout.service.media.converter package dev.usbharu.hideout.service.media.converter
import dev.usbharu.hideout.domain.model.hideout.dto.FileType 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.exception.media.MediaConvertException
import dev.usbharu.hideout.service.media.ThumbnailGenerateService import dev.usbharu.hideout.service.media.ThumbnailGenerateService
import org.slf4j.LoggerFactory import org.slf4j.LoggerFactory
@ -13,27 +14,31 @@ class MediaProcessServiceImpl(
) : MediaProcessService { ) : MediaProcessService {
override suspend fun process( override suspend fun process(
fileType: FileType, fileType: FileType,
contentType: String,
filename: String,
file: ByteArray, file: ByteArray,
thumbnail: ByteArray? thumbnail: ByteArray?
): Pair<ByteArray, ByteArray> { ): ProcessedMedia {
val fileInputStream = try { val fileInputStream = try {
mediaConverterRoot.convert(fileType, file.inputStream().buffered()) mediaConverterRoot.convert(fileType, contentType, filename, file.inputStream().buffered())
} catch (e: Exception) { } catch (e: Exception) {
logger.warn("Failed convert media.", e) logger.warn("Failed convert media.", e)
throw MediaConvertException("Failed convert media.", e) throw MediaConvertException("Failed convert media.", e)
} }
val thumbnailInputStream = try { val thumbnailInputStream = try {
thumbnail?.let { mediaConverterRoot.convert(fileType, it.inputStream().buffered()) } thumbnail?.let { mediaConverterRoot.convert(fileType, contentType, filename, it.inputStream().buffered()) }
} catch (e: Exception) { } catch (e: Exception) {
logger.warn("Failed convert thumbnail media.", e) logger.warn("Failed convert thumbnail media.", e)
null null
} }
return fileInputStream to thumbnailGenerateService.generate( return ProcessedMedia(
thumbnailInputStream ?: fileInputStream, fileInputStream, thumbnailGenerateService.generate(
thumbnailInputStream?.byteArray ?: file,
2048, 2048,
2048 2048
) )
)
} }
companion object { companion object {

View File

@ -7,14 +7,7 @@ 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"
storage:
use-s3: true
endpoint: ""
public-url: ""
bucket: ""
region: ""
access-key: ""
secret-key: ""