Skip to content

Commit

Permalink
feat: saveImage in imageService
Browse files Browse the repository at this point in the history
  • Loading branch information
ShiinaKin committed Oct 12, 2024
1 parent cad23d1 commit bb1e6d7
Show file tree
Hide file tree
Showing 9 changed files with 477 additions and 6 deletions.
5 changes: 3 additions & 2 deletions app/src/main/kotlin/io/sakurasou/config/InstanceCenter.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
)
Original file line number Diff line number Diff line change
@@ -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"
}
Original file line number Diff line number Diff line change
@@ -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"
}
18 changes: 17 additions & 1 deletion app/src/main/kotlin/io/sakurasou/service/image/ImageService.kt
Original file line number Diff line number Diff line change
@@ -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<ImagePageVO>
suspend fun pageImage(pageRequest: PageRequest): PageResult<ImagePageVO>
}
161 changes: 160 additions & 1 deletion app/src/main/kotlin/io/sakurasou/service/image/ImageServiceImpl.kt
Original file line number Diff line number Diff line change
@@ -1,15 +1,174 @@
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
* 2024/9/5 15:12
*/
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<ImagePageVO> {
TODO("Not yet implemented")
}

override suspend fun pageImage(pageRequest: PageRequest): PageResult<ImagePageVO> {
TODO("Not yet implemented")
}
}
91 changes: 91 additions & 0 deletions app/src/main/kotlin/io/sakurasou/util/ImageUtils.kt
Original file line number Diff line number Diff line change
@@ -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() }
}
}
}
Loading

0 comments on commit bb1e6d7

Please sign in to comment.