From bb1e6d7f15b3df42f11e7e9c5919afa2abb4303d Mon Sep 17 00:00:00 2001 From: Shiina Kin Date: Sat, 12 Oct 2024 17:44:11 +0800 Subject: [PATCH] feat: saveImage in imageService --- .../io/sakurasou/config/InstanceCenter.kt | 5 +- .../controller/request/ImageRequest.kt | 41 ++++- .../FileExtensionNotAllowedException.kt | 14 ++ .../param/UnsupportedFileTypeException.kt | 14 ++ .../sakurasou/service/image/ImageService.kt | 18 +- .../service/image/ImageServiceImpl.kt | 161 +++++++++++++++++- .../kotlin/io/sakurasou/util/ImageUtils.kt | 91 ++++++++++ .../io/sakurasou/util/PlaceholderUtils.kt | 71 ++++++++ .../main/kotlin/io/sakurasou/util/S3Utils.kt | 68 ++++++++ 9 files changed, 477 insertions(+), 6 deletions(-) create mode 100644 app/src/main/kotlin/io/sakurasou/exception/common/FileExtensionNotAllowedException.kt create mode 100644 app/src/main/kotlin/io/sakurasou/exception/controller/param/UnsupportedFileTypeException.kt create mode 100644 app/src/main/kotlin/io/sakurasou/util/ImageUtils.kt create mode 100644 app/src/main/kotlin/io/sakurasou/util/PlaceholderUtils.kt create mode 100644 app/src/main/kotlin/io/sakurasou/util/S3Utils.kt diff --git a/app/src/main/kotlin/io/sakurasou/config/InstanceCenter.kt b/app/src/main/kotlin/io/sakurasou/config/InstanceCenter.kt index 35c4c1b5..d060aae2 100644 --- a/app/src/main/kotlin/io/sakurasou/config/InstanceCenter.kt +++ b/app/src/main/kotlin/io/sakurasou/config/InstanceCenter.kt @@ -89,13 +89,14 @@ object InstanceCenter { authService = AuthServiceImpl(userDao, relationDao) groupService = GroupServiceImpl(groupDao, relationDao) - imageService = ImageServiceImpl(imageDao, albumDao) albumService = AlbumServiceImpl(albumDao, imageDao) + roleService = RoleServiceImpl(roleDao, permissionDao, relationDao) commonService = CommonServiceImpl(userDao, albumDao, settingService) + userService = UserServiceImpl(userDao, groupDao, albumDao, imageDao, settingService) - roleService = RoleServiceImpl(roleDao, permissionDao, relationDao) + imageService = ImageServiceImpl(imageDao, albumDao, userDao, groupDao, strategyDao, settingService) } fun initSystemStatus() { diff --git a/app/src/main/kotlin/io/sakurasou/controller/request/ImageRequest.kt b/app/src/main/kotlin/io/sakurasou/controller/request/ImageRequest.kt index 2e0ebb4c..b56f8cb8 100644 --- a/app/src/main/kotlin/io/sakurasou/controller/request/ImageRequest.kt +++ b/app/src/main/kotlin/io/sakurasou/controller/request/ImageRequest.kt @@ -6,9 +6,46 @@ import kotlinx.serialization.Serializable * @author Shiina Kin * 2024/9/9 13:36 */ +data class ImageRawFile( + val name: String, + val mimeType: String, + val size: Long, + val bytes: ByteArray +) { + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + + other as ImageRawFile + + if (name != other.name) return false + if (mimeType != other.mimeType) return false + if (size != other.size) return false + if (!bytes.contentEquals(other.bytes)) return false + + return true + } + + override fun hashCode(): Int { + var result = name.hashCode() + result = 31 * result + mimeType.hashCode() + result = 31 * result + size.hashCode() + result = 31 * result + bytes.contentHashCode() + return result + } +} + @Serializable data class ImagePatchRequest( val albumId: Long? = null, - val originName: String? = null, - val description: String? = null + val description: String? = null, + val isPrivate: Boolean? = null +) + +@Serializable +data class ImageManagePatchRequest( + val userId: Long? = null, + val albumId: Long? = null, + val description: String? = null, + val isPrivate: Boolean? = null ) \ No newline at end of file diff --git a/app/src/main/kotlin/io/sakurasou/exception/common/FileExtensionNotAllowedException.kt b/app/src/main/kotlin/io/sakurasou/exception/common/FileExtensionNotAllowedException.kt new file mode 100644 index 00000000..2787b8f9 --- /dev/null +++ b/app/src/main/kotlin/io/sakurasou/exception/common/FileExtensionNotAllowedException.kt @@ -0,0 +1,14 @@ +package io.sakurasou.exception.common + +import io.sakurasou.exception.ServiceThrowable + +/** + * @author Shiina Kin + * 2024/9/9 10:38 + */ +class FileExtensionNotAllowedException : ServiceThrowable() { + override val code: Int + get() = 4001 + override val message: String + get() = "File extension not allowed" +} \ No newline at end of file diff --git a/app/src/main/kotlin/io/sakurasou/exception/controller/param/UnsupportedFileTypeException.kt b/app/src/main/kotlin/io/sakurasou/exception/controller/param/UnsupportedFileTypeException.kt new file mode 100644 index 00000000..58e9bd5b --- /dev/null +++ b/app/src/main/kotlin/io/sakurasou/exception/controller/param/UnsupportedFileTypeException.kt @@ -0,0 +1,14 @@ +package io.sakurasou.exception.controller.param + +import io.sakurasou.exception.ServiceThrowable + +/** + * @author Shiina Kin + * 2024/10/11 17:44 + */ +class UnsupportedFileTypeException : ServiceThrowable() { + override val code: Int + get() = 4000 + override val message: String + get() = "File type is not supported" +} \ No newline at end of file diff --git a/app/src/main/kotlin/io/sakurasou/service/image/ImageService.kt b/app/src/main/kotlin/io/sakurasou/service/image/ImageService.kt index 8218083f..3b84822e 100644 --- a/app/src/main/kotlin/io/sakurasou/service/image/ImageService.kt +++ b/app/src/main/kotlin/io/sakurasou/service/image/ImageService.kt @@ -1,9 +1,25 @@ package io.sakurasou.service.image +import io.sakurasou.controller.request.ImageManagePatchRequest +import io.sakurasou.controller.request.ImagePatchRequest +import io.sakurasou.controller.request.ImageRawFile +import io.sakurasou.controller.request.PageRequest +import io.sakurasou.controller.vo.ImagePageVO +import io.sakurasou.controller.vo.ImageVO +import io.sakurasou.controller.vo.PageResult + /** * @author ShiinaKin * 2024/9/5 14:42 */ interface ImageService { - + suspend fun saveImage(userId: Long, imageRawFile: ImageRawFile): String + suspend fun deleteSelfImage(userId: Long, imageId: Long) + suspend fun deleteImage(imageId: Long) + suspend fun patchSelfImage(userId: Long, imageId: Long, selfPatchRequest: ImagePatchRequest) + suspend fun patchImage(imageId: Long, managePatchRequest: ImageManagePatchRequest) + suspend fun fetchSelfImage(userId: Long, imageId: Long): ImageVO + suspend fun fetchImage(imageId: Long): ImageVO + suspend fun pageSelfImage(userId: Long, pageRequest: PageRequest): PageResult + suspend fun pageImage(pageRequest: PageRequest): PageResult } \ No newline at end of file diff --git a/app/src/main/kotlin/io/sakurasou/service/image/ImageServiceImpl.kt b/app/src/main/kotlin/io/sakurasou/service/image/ImageServiceImpl.kt index 3d459f90..9ba831df 100644 --- a/app/src/main/kotlin/io/sakurasou/service/image/ImageServiceImpl.kt +++ b/app/src/main/kotlin/io/sakurasou/service/image/ImageServiceImpl.kt @@ -1,7 +1,35 @@ package io.sakurasou.service.image +import io.sakurasou.controller.request.ImageManagePatchRequest +import io.sakurasou.controller.request.ImagePatchRequest +import io.sakurasou.controller.request.ImageRawFile +import io.sakurasou.controller.request.PageRequest +import io.sakurasou.controller.vo.ImagePageVO +import io.sakurasou.controller.vo.ImageVO +import io.sakurasou.controller.vo.PageResult +import io.sakurasou.exception.common.FileExtensionNotAllowedException +import io.sakurasou.exception.common.FileSizeException +import io.sakurasou.exception.common.UserBannedException +import io.sakurasou.exception.service.strategy.StrategyNotFoundException +import io.sakurasou.model.DatabaseSingleton.dbQuery import io.sakurasou.model.dao.album.AlbumDao +import io.sakurasou.model.dao.group.GroupDao import io.sakurasou.model.dao.image.ImageDao +import io.sakurasou.model.dao.strategy.StrategyDao +import io.sakurasou.model.dao.user.UserDao +import io.sakurasou.model.dto.ImageInsertDTO +import io.sakurasou.model.group.ImageType +import io.sakurasou.model.strategy.LocalStrategy +import io.sakurasou.model.strategy.S3Strategy +import io.sakurasou.service.setting.SettingService +import io.sakurasou.util.ImageUtils +import io.sakurasou.util.PlaceholderUtils +import kotlinx.datetime.Clock +import kotlinx.datetime.TimeZone +import kotlinx.datetime.toLocalDateTime +import org.apache.commons.codec.digest.DigestUtils +import java.io.ByteArrayInputStream +import javax.imageio.ImageIO /** * @author ShiinaKin @@ -9,7 +37,138 @@ import io.sakurasou.model.dao.image.ImageDao */ class ImageServiceImpl( private val imageDao: ImageDao, - private val albumDao: AlbumDao + private val albumDao: AlbumDao, + private val userDao: UserDao, + private val groupDao: GroupDao, + private val strategyDao: StrategyDao, + private val settingService: SettingService ) : ImageService { + override suspend fun saveImage(userId: Long, imageRawFile: ImageRawFile): String { + val siteSetting = settingService.getSiteSetting() + return dbQuery { + val user = userDao.findUserById(userId) ?: throw IllegalArgumentException("User not found") + val group = groupDao.findGroupById(user.groupId) ?: throw IllegalArgumentException("Group not found") + val defaultAlbum = + albumDao.findAlbumById(user.defaultAlbumId) ?: throw IllegalArgumentException("Default Album not found") + val strategy = strategyDao.findStrategyById(group.strategyId) ?: throw StrategyNotFoundException() + val groupConfig = group.config + // if user is banned + if (user.isBanned) throw UserBannedException() + + // if over single file maxSize + if (imageRawFile.size > groupConfig.groupStrategyConfig.singleFileMaxSize) throw FileSizeException() + + // if over group maxSize + val imageCountAndTotalSizeOfUser = imageDao.getImageCountAndTotalSizeOfUser(user.id) + val currentUsedSize = imageCountAndTotalSizeOfUser.totalSize + val maxSize = groupConfig.groupStrategyConfig.maxSize + if (currentUsedSize + imageRawFile.size > maxSize) throw FileSizeException() + + val fileNamePrefix = imageRawFile.name.split(".")[0] + val extension = imageRawFile.name.split(".")[1] + + // if extension not allow + if (!groupConfig.groupStrategyConfig.allowedImageTypes.contains(ImageType.valueOf(extension.uppercase()))) { + throw FileExtensionNotAllowedException() + } + + val fileNamingRule = groupConfig.groupStrategyConfig.fileNamingRule + val pathNamingRule = groupConfig.groupStrategyConfig.pathNamingRule + + val name = PlaceholderUtils.parsePlaceholder(fileNamingRule, fileNamePrefix, user.id) + val subFolder = PlaceholderUtils.parsePlaceholder(pathNamingRule, fileNamePrefix, user.id) + + val fileName = "$name.$extension" + + // transform image if needed + var imageBytes = imageRawFile.bytes + var image = ByteArrayInputStream(imageBytes).use { ImageIO.read(it) } + if (groupConfig.groupStrategyConfig.imageAutoTransformTarget != null) { + imageBytes = if (groupConfig.groupStrategyConfig.imageQuality != 100) { + val quality = groupConfig.groupStrategyConfig.imageQuality + if (quality !in (1..100)) throw IllegalArgumentException("Image quality must be in 1..100") + val imageQuality = quality / 100.0 + ImageUtils.transformImage( + image, + groupConfig.groupStrategyConfig.imageAutoTransformTarget, + imageQuality + ) + } else { + ImageUtils.transformImage(image, groupConfig.groupStrategyConfig.imageAutoTransformTarget) + } + image = ByteArrayInputStream(imageBytes).use { ImageIO.read(it) } + } + val md5 = DigestUtils.md5Hex(imageBytes) + val sha256 = DigestUtils.sha256Hex(imageBytes) + + val relativePath = ImageUtils.uploadImageAndGetRelativePath( + strategy = strategy, + subFolder = subFolder, + fileName = fileName, + bytes = imageBytes + ) + + val now = Clock.System.now().toLocalDateTime(TimeZone.currentSystemDefault()) + + val imageInsertDTO = ImageInsertDTO( + userId = user.id, + groupId = group.id, + albumId = defaultAlbum.id, + name = name, + path = relativePath, + strategyId = group.strategyId, + originName = imageRawFile.name, + mimeType = imageRawFile.mimeType, + extension = extension, + size = imageRawFile.size, + width = image.width, + height = image.height, + md5 = md5, + sha256 = sha256, + isPrivate = user.isDefaultImagePrivate, + createTime = now + ) + + imageDao.saveImage(imageInsertDTO) + + if (user.isDefaultImagePrivate) "" + else when (strategy.config) { + is LocalStrategy -> "${siteSetting.siteExternalUrl}/$relativePath" + is S3Strategy -> "${strategy.config.publicUrl}/$relativePath" + } + } + } + + override suspend fun deleteSelfImage(userId: Long, imageId: Long) { + TODO("Not yet implemented") + } + + override suspend fun deleteImage(imageId: Long) { + TODO("Not yet implemented") + } + + override suspend fun patchSelfImage(userId: Long, imageId: Long, selfPatchRequest: ImagePatchRequest) { + TODO("Not yet implemented") + } + + override suspend fun patchImage(imageId: Long, managePatchRequest: ImageManagePatchRequest) { + TODO("Not yet implemented") + } + + override suspend fun fetchSelfImage(userId: Long, imageId: Long): ImageVO { + TODO("Not yet implemented") + } + + override suspend fun fetchImage(imageId: Long): ImageVO { + TODO("Not yet implemented") + } + + override suspend fun pageSelfImage(userId: Long, pageRequest: PageRequest): PageResult { + TODO("Not yet implemented") + } + + override suspend fun pageImage(pageRequest: PageRequest): PageResult { + TODO("Not yet implemented") + } } \ No newline at end of file diff --git a/app/src/main/kotlin/io/sakurasou/util/ImageUtils.kt b/app/src/main/kotlin/io/sakurasou/util/ImageUtils.kt new file mode 100644 index 00000000..10fec7e2 --- /dev/null +++ b/app/src/main/kotlin/io/sakurasou/util/ImageUtils.kt @@ -0,0 +1,91 @@ +package io.sakurasou.util + +import io.github.oshai.kotlinlogging.KotlinLogging +import io.sakurasou.model.entity.Strategy +import io.sakurasou.model.group.ImageType +import io.sakurasou.model.strategy.LocalStrategy +import io.sakurasou.model.strategy.S3Strategy +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import net.coobird.thumbnailator.Thumbnails +import java.awt.image.BufferedImage +import java.io.ByteArrayOutputStream +import java.io.File + +/** + * @author Shiina Kin + * 2024/10/12 08:52 + */ +private val logger = KotlinLogging.logger {} + +object ImageUtils { + suspend fun uploadImageAndGetRelativePath( + strategy: Strategy, + subFolder: String, + fileName: String, + bytes: ByteArray + ): String { + return withContext(Dispatchers.IO) { + val strategyConfig = strategy.config + val relativePath = "$subFolder/$fileName" + when (strategyConfig) { + is LocalStrategy -> { + val uploadFolderStr = strategyConfig.uploadFolder + val uploadFolder = File(uploadFolderStr, subFolder) + if (!uploadFolder.exists() && !uploadFolder.mkdirs()) { + throw RuntimeException("Failed to create upload folder") + } + val uploadFile = File(uploadFolder, fileName) + uploadFile.writeBytes(bytes) + logger.trace { "save image $fileName at ${uploadFile.absolutePath}" } + } + + is S3Strategy -> { + S3Utils.uploadImage(relativePath, bytes, strategy) + } + } + relativePath + } + } + + suspend fun transformImage(rawImage: BufferedImage, targetImageType: ImageType): ByteArray { + return withContext(Dispatchers.IO) { + ByteArrayOutputStream().apply { + Thumbnails.of(rawImage) + .outputFormat(targetImageType.name) + .toOutputStream(this) + }.use { it.toByteArray() } + } + } + + suspend fun transformImage(rawImage: BufferedImage, targetImageType: ImageType, quality: Double): ByteArray { + return withContext(Dispatchers.IO) { + ByteArrayOutputStream().apply { + Thumbnails.of(rawImage) + .outputFormat(targetImageType.name) + .outputQuality(quality) + .toOutputStream(this) + }.use { it.toByteArray() } + } + } + + suspend fun transformImage( + rawImage: BufferedImage, + targetImageType: ImageType, + newWidth: Int, + quality: Double + ): ByteArray { + return withContext(Dispatchers.IO) { + val originalWidth = rawImage.width + val originalHeight = rawImage.height + val newHeight = (originalHeight * newWidth) / originalWidth + ByteArrayOutputStream().apply { + Thumbnails.of(rawImage) + .size(newWidth, newHeight) + .outputFormat(targetImageType.name) + .outputQuality(quality) + .toOutputStream(this) + }.use { it.toByteArray() } + } + } +} \ No newline at end of file diff --git a/app/src/main/kotlin/io/sakurasou/util/PlaceholderUtils.kt b/app/src/main/kotlin/io/sakurasou/util/PlaceholderUtils.kt new file mode 100644 index 00000000..878c6cd5 --- /dev/null +++ b/app/src/main/kotlin/io/sakurasou/util/PlaceholderUtils.kt @@ -0,0 +1,71 @@ +package io.sakurasou.util + +import kotlinx.datetime.Clock +import kotlinx.datetime.TimeZone +import kotlinx.datetime.toLocalDateTime +import org.apache.commons.codec.digest.DigestUtils +import kotlin.random.Random +import kotlin.uuid.ExperimentalUuidApi +import kotlin.uuid.Uuid + +/** + * @author Shiina Kin + * 2024/10/12 10:22 + */ +object PlaceholderUtils { + /** + * {yyyy} 年份(2022) + * {MM} 月份(01) + * {dd} 当月的第几号(04) + * {timestamp} 时间戳(秒) + * {uniq} 唯一字符串 + * {md5} 随机 md5 值 + * {str-random-16} 16位随机字符串 + * {str-random-10} 10位随机字符串 + * {fileName} 文件原始名称 + * {user-id} 用户 ID + */ + private enum class Placeholder(val placeholder: String) { + YEAR("{yyyy}"), + MONTH("{MM}"), + DAY("{dd}"), + TIMESTAMP("{timestamp}"), + UNIQ("{uniq}"), + MD5("{md5}"), + STR_RANDOM_16("{str-random-16}"), + STR_RANDOM_10("{str-random-10}"), + FILENAME("{filename}"), + USER_ID("{user-id}") + } + + @OptIn(ExperimentalUuidApi::class) + fun parsePlaceholder(namingRule: String, fileName: String, userId: Long): String { + val instant = Clock.System.now() + val localDateTime = instant.toLocalDateTime(TimeZone.currentSystemDefault()) + + // namingRule like : {yyyy}/{MM}/{dd} + return namingRule.replace(Regex("\\{[^}]+}")) { matchResult -> + when (matchResult.value) { + Placeholder.YEAR.placeholder -> localDateTime.year.toString() + Placeholder.MONTH.placeholder -> localDateTime.monthNumber.toString().padStart(2, '0') + Placeholder.DAY.placeholder -> localDateTime.dayOfMonth.toString().padStart(2, '0') + Placeholder.TIMESTAMP.placeholder -> instant.epochSeconds.toString() + Placeholder.UNIQ.placeholder -> Uuid.random().toHexString() + Placeholder.MD5.placeholder -> DigestUtils.md5Hex(generateRandomString(32)) + Placeholder.STR_RANDOM_16.placeholder -> generateRandomString(16) + Placeholder.STR_RANDOM_10.placeholder -> generateRandomString(10) + Placeholder.FILENAME.placeholder -> fileName + Placeholder.USER_ID.placeholder -> userId.toString() + else -> matchResult.value + } + } + } + + private fun generateRandomString(length: Int): String { + val dict: List = ('a'..'z') + ('A'..'Z') + ('0'..'9') + return (1..length) + .map { Random.nextInt(0, dict.size) } + .map(dict::get) + .joinToString("") + } +} \ No newline at end of file diff --git a/app/src/main/kotlin/io/sakurasou/util/S3Utils.kt b/app/src/main/kotlin/io/sakurasou/util/S3Utils.kt new file mode 100644 index 00000000..dd408fc2 --- /dev/null +++ b/app/src/main/kotlin/io/sakurasou/util/S3Utils.kt @@ -0,0 +1,68 @@ +package io.sakurasou.util + +import io.ktor.util.* +import io.sakurasou.model.entity.Strategy +import io.sakurasou.model.strategy.S3Strategy +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import org.apache.commons.codec.digest.DigestUtils +import software.amazon.awssdk.auth.credentials.AwsBasicCredentials +import software.amazon.awssdk.auth.credentials.StaticCredentialsProvider +import software.amazon.awssdk.core.exception.SdkClientException +import software.amazon.awssdk.core.sync.RequestBody +import software.amazon.awssdk.regions.Region +import software.amazon.awssdk.services.s3.S3Client +import software.amazon.awssdk.services.s3.model.PutObjectRequest +import software.amazon.awssdk.services.s3.model.S3Exception +import java.net.URI + +/** + * @author Shiina Kin + * 2024/10/12 13:56 + */ +object S3Utils { + private val s3ClientMap by lazy { mutableMapOf() } + + suspend fun uploadImage(relativePath: String, imageBytes: ByteArray, strategy: Strategy) { + withContext(Dispatchers.IO) { + val strategyConfig = strategy.config as S3Strategy + runCatching { + s3ClientMap[strategy.id] ?: run { + val s3 = S3Client.builder() + .region(Region.of(strategyConfig.region)) + .endpointOverride(URI(strategyConfig.endpoint)) + .credentialsProvider( + StaticCredentialsProvider.create( + AwsBasicCredentials.create( + strategyConfig.accessKey, + strategyConfig.secretKey + ) + ) + ) + .forcePathStyle(true) + .build() + s3ClientMap[strategy.id] = s3 + s3 + } + }.onFailure { + throw RuntimeException("Failed to create S3 client", it) + }.onSuccess { s3Client -> + val sha256 = DigestUtils.sha256(imageBytes).encodeBase64() + val putObject = PutObjectRequest.builder() + .bucket(strategyConfig.bucketName) + .key(relativePath) + .checksumSHA256(sha256) + .build() + runCatching { + s3Client.putObject(putObject, RequestBody.fromBytes(imageBytes)) + }.onFailure { + when (it) { + is SdkClientException -> throw RuntimeException("Failed to upload image to S3", it) + is S3Exception -> throw RuntimeException("Failed to upload image to S3", it) + else -> throw it + } + } + } + } + } +} \ No newline at end of file