-
Notifications
You must be signed in to change notification settings - Fork 0
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
- Loading branch information
Showing
9 changed files
with
477 additions
and
6 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
14 changes: 14 additions & 0 deletions
14
app/src/main/kotlin/io/sakurasou/exception/common/FileExtensionNotAllowedException.kt
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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" | ||
} |
14 changes: 14 additions & 0 deletions
14
app/src/main/kotlin/io/sakurasou/exception/controller/param/UnsupportedFileTypeException.kt
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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
18
app/src/main/kotlin/io/sakurasou/service/image/ImageService.kt
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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
161
app/src/main/kotlin/io/sakurasou/service/image/ImageServiceImpl.kt
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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") | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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() } | ||
} | ||
} | ||
} |
Oops, something went wrong.