diff --git a/features/common/server/src/jvmMain/kotlin/dev/inmo/postssystem/features/common/server/sessions/Qualifiers.kt b/features/common/server/src/jvmMain/kotlin/dev/inmo/postssystem/features/common/server/sessions/Qualifiers.kt index de374e5f..9d7953b9 100644 --- a/features/common/server/src/jvmMain/kotlin/dev/inmo/postssystem/features/common/server/sessions/Qualifiers.kt +++ b/features/common/server/src/jvmMain/kotlin/dev/inmo/postssystem/features/common/server/sessions/Qualifiers.kt @@ -3,13 +3,6 @@ package dev.inmo.postssystem.features.common.server.sessions import org.koin.core.qualifier.StringQualifier object Qualifiers { - val binaryFilesFolderQualifier = StringQualifier("binaryFilesFolder") - val originalFilesMetasKeyValueRepoQualifier = StringQualifier("OriginalFilesMetaKV") - val binaryOriginalFilesMetasKeyValueRepoQualifier = StringQualifier("BinaryOriginalFilesMetaKV") - val commonFilesMetasKeyValueRepoQualifier = StringQualifier("CommonFilesMetaKV") - val binaryFilesMetasKeyValueRepoQualifier = StringQualifier("BinaryFilesMetaKV") - val filesFolderQualifier = StringQualifier("rootFilesFolder") - val commonFilesFolderQualifier = StringQualifier("commonFilesFolder") + val filesFolderQualifier = StringQualifier("filesFolder") val usersRolesKeyValueFactoryQualifier = StringQualifier("usersRolesKeyValueFactory") - val binaryStorageFilesQualifier = StringQualifier("binaryContentFiles") } diff --git a/features/content/binary/server/build.gradle b/features/content/binary/server/build.gradle index b3a026c6..e3aa1611 100644 --- a/features/content/binary/server/build.gradle +++ b/features/content/binary/server/build.gradle @@ -14,5 +14,10 @@ kotlin { api project(":postssystem.features.files.server") } } + jvmMain { + dependencies { + api libs.scrimage + } + } } } diff --git a/features/content/binary/server/src/jvmMain/kotlin/dev/inmo/postssystem/features/content/binary/server/BinaryContentServerModuleLoader.kt b/features/content/binary/server/src/jvmMain/kotlin/dev/inmo/postssystem/features/content/binary/server/BinaryContentServerModuleLoader.kt index a4a7f73d..e59e7034 100644 --- a/features/content/binary/server/src/jvmMain/kotlin/dev/inmo/postssystem/features/content/binary/server/BinaryContentServerModuleLoader.kt +++ b/features/content/binary/server/src/jvmMain/kotlin/dev/inmo/postssystem/features/content/binary/server/BinaryContentServerModuleLoader.kt @@ -1,18 +1,105 @@ package dev.inmo.postssystem.features.content.binary.server +import dev.inmo.micro_utils.repos.exposed.keyvalue.ExposedKeyValueRepo +import dev.inmo.postssystem.features.common.common.singleWithBinds import dev.inmo.postssystem.features.common.common.singleWithRandomQualifier import dev.inmo.postssystem.features.common.server.sessions.Qualifiers import dev.inmo.postssystem.features.common.server.sessions.ServerModuleLoader import dev.inmo.postssystem.features.content.common.BinaryContent import dev.inmo.postssystem.features.content.server.ServerContentStorageWrapper -import kotlinx.serialization.json.JsonObject +import dev.inmo.postssystem.features.files.common.* +import dev.inmo.postssystem.features.files.common.storage.DefaultFilesStorage +import dev.inmo.postssystem.features.files.common.storage.FilesStorage +import kotlinx.serialization.json.* import org.koin.core.module.Module +import org.koin.core.qualifier.StringQualifier +import java.io.File +/** + * This provider is declaring one additional optional section: "previewDimensions". This section is an object with two + * fields: + * + * * maxWidth + * * maxHeight + * + * for preview images + */ class BinaryContentServerModuleLoader : ServerModuleLoader { override fun Module.load(config: JsonObject) { + val binaryFilesFolderQualifier = StringQualifier("binaryFilesFolder") + val binaryStorageFilesQualifier = StringQualifier("binaryContentFiles") + val binaryPreviewStorageFilesQualifier = StringQualifier("binaryPreviewContentFiles") + val binaryPreviewFilesFolderQualifier = StringQualifier("binaryPreviewFilesFolder") + val binaryFilesMetasKeyValueRepoQualifier = StringQualifier("BinaryFilesMetaKV") + val binaryPreviewFilesMetasKeyValueRepoQualifier = StringQualifier("BinaryPreviewFilesMetaKV") + val binaryOriginalFilesMetasKeyValueRepoQualifier = StringQualifier("BinaryOriginalFilesMetaKV") + val binaryOriginalPreviewFilesMetasKeyValueRepoQualifier = StringQualifier("BinaryPreviewOriginalFilesMetaKV") + + singleWithBinds(binaryFilesFolderQualifier) { + File(get(Qualifiers.filesFolderQualifier), "binary_content").apply { + mkdirs() + } + } + + single { + val dimensionsSection = config["previewDimensions"] ?: return@single PreviewImageDimensionsConfig() + get().decodeFromJsonElement( + PreviewImageDimensionsConfig.serializer(), + dimensionsSection + ) + } + + single { + ScrimageBasedImagesCropper(get()) + } + + singleWithBinds(binaryPreviewFilesFolderQualifier) { + File(get(binaryFilesFolderQualifier), "preview").apply { + mkdirs() + } + } + + singleWithBinds(binaryOriginalFilesMetasKeyValueRepoQualifier) { + ExposedKeyValueRepo(get(), { text("fileid") }, { text("metaInfo") }, "BinaryContentFileIdsToMetas") + } + + singleWithBinds(binaryOriginalPreviewFilesMetasKeyValueRepoQualifier) { + ExposedKeyValueRepo(get(), { text("fileid") }, { text("metaInfo") }, "BinaryPreviewContentFileIdsToMetas") + } + + singleWithBinds(binaryFilesMetasKeyValueRepoQualifier) { + MetasKeyValueRepo( + get(), + get(binaryOriginalFilesMetasKeyValueRepoQualifier) + ) + } + singleWithBinds(binaryPreviewFilesMetasKeyValueRepoQualifier) { + MetasKeyValueRepo( + get(), + get(binaryOriginalPreviewFilesMetasKeyValueRepoQualifier) + ) + } + single(binaryStorageFilesQualifier) { + DefaultFilesStorage( + DiskReadFilesStorage(get(binaryFilesFolderQualifier), get(binaryFilesMetasKeyValueRepoQualifier)), + WriteDistFilesStorage(get(binaryFilesFolderQualifier), get(binaryFilesMetasKeyValueRepoQualifier)) + ) + } + single(binaryPreviewStorageFilesQualifier) { + DefaultFilesStorage( + DiskReadFilesStorage(get(binaryPreviewFilesFolderQualifier), get(binaryPreviewFilesMetasKeyValueRepoQualifier)), + WriteDistFilesStorage(get(binaryPreviewFilesFolderQualifier), get(binaryPreviewFilesMetasKeyValueRepoQualifier)) + ) + } + singleWithRandomQualifier { ServerContentStorageWrapper( - BinaryServerContentStorage(get(Qualifiers.binaryStorageFilesQualifier)), + BinaryServerContentStorage( + get(binaryStorageFilesQualifier), + get(binaryPreviewStorageFilesQualifier), + get(), + get() + ), BinaryContent::class ) } diff --git a/features/content/binary/server/src/jvmMain/kotlin/dev/inmo/postssystem/features/content/binary/server/BinaryServerContentStorage.kt b/features/content/binary/server/src/jvmMain/kotlin/dev/inmo/postssystem/features/content/binary/server/BinaryServerContentStorage.kt index 6c9ffffe..a3c4aa24 100644 --- a/features/content/binary/server/src/jvmMain/kotlin/dev/inmo/postssystem/features/content/binary/server/BinaryServerContentStorage.kt +++ b/features/content/binary/server/src/jvmMain/kotlin/dev/inmo/postssystem/features/content/binary/server/BinaryServerContentStorage.kt @@ -1,16 +1,29 @@ package dev.inmo.postssystem.features.content.binary.server +import com.benasher44.uuid.uuid4 +import dev.inmo.micro_utils.coroutines.plus +import dev.inmo.micro_utils.coroutines.subscribeSafelyWithoutExceptions +import dev.inmo.micro_utils.mime_types.KnownMimeTypes import dev.inmo.micro_utils.pagination.* -import dev.inmo.micro_utils.repos.UpdatedValuePair +import dev.inmo.micro_utils.repos.* +import dev.inmo.postssystem.features.common.common.FileBasedInputProvider import dev.inmo.postssystem.features.content.common.* import dev.inmo.postssystem.features.content.server.storage.ServerContentStorage import dev.inmo.postssystem.features.files.common.* import dev.inmo.postssystem.features.files.common.storage.FilesStorage +import io.ktor.util.asStream +import io.ktor.utils.io.core.copyTo +import io.ktor.utils.io.streams.asOutput +import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.map +import java.io.File class BinaryServerContentStorage( - private val filesStorage: FilesStorage + private val filesStorage: FilesStorage, + private val previewFilesStorage: FilesStorage, + private val cropper: ImagesCropper, + private val scope: CoroutineScope ) : ServerContentStorage { private val FileId.asContentId get() = ContentId(string) @@ -35,6 +48,50 @@ class BinaryServerContentStorage( override val newObjectsFlow: Flow = filesStorage.newObjectsFlow.map { it.asRegisteredContent } override val updatedObjectsFlow: Flow = filesStorage.updatedObjectsFlow.map { it.asRegisteredContent } + private val fullsRemovedJob = deletedObjectsIdsFlow.subscribeSafelyWithoutExceptions(scope) { + previewFilesStorage.deleteById(it.asFileId) + } + + private val previewCroppingJob = (newObjectsFlow + updatedObjectsFlow).subscribeSafelyWithoutExceptions(scope) { + val content = it.content + val fileId = it.id.asFileId + if (content !is BinaryContent) { + return@subscribeSafelyWithoutExceptions + } + val fullFileInfo = filesStorage.getFullFileInfo(fileId) ?: return@subscribeSafelyWithoutExceptions + val fileInfo = fullFileInfo.fileInfo + + if (fileInfo.mimeType is KnownMimeTypes.Image) { + cropper.crop(fileInfo.inputProvider).subscribeSafelyWithoutExceptions(scope) { + val tempFile = File.createTempFile(uuid4().toString(), ".${fileInfo.name.extension}").apply { + deleteOnExit() + createNewFile() + } + runCatching { + tempFile.outputStream().use { output -> + it.asStream().use { input -> + input.copyTo(output) + } + } + val newFullFileInfo = FullFileInfo( + fileInfo.name, + fileInfo.mimeType, + FileBasedInputProvider(tempFile) + ) + if (previewFilesStorage.contains(fileId)) { + previewFilesStorage.update( + fullFileInfo.id, + newFullFileInfo + ) + } else { + previewFilesStorage.create(newFullFileInfo).firstOrNull() + } + } + tempFile.delete() + } + } + } + override suspend fun create(values: List): List { return filesStorage.create( values.map { it.asFullFileInfo } @@ -83,6 +140,8 @@ class BinaryServerContentStorage( } override suspend fun getContentPreview(id: ContentId): RegisteredContent? { - TODO("Not yet implemented") + val fileId = id.asFileId + val fileInfo = previewFilesStorage.getFullFileInfo(fileId) ?: return null + return fileInfo.asRegisteredContent } } diff --git a/features/content/binary/server/src/jvmMain/kotlin/dev/inmo/postssystem/features/content/binary/server/ImagesCropper.kt b/features/content/binary/server/src/jvmMain/kotlin/dev/inmo/postssystem/features/content/binary/server/ImagesCropper.kt new file mode 100644 index 00000000..9bc62200 --- /dev/null +++ b/features/content/binary/server/src/jvmMain/kotlin/dev/inmo/postssystem/features/content/binary/server/ImagesCropper.kt @@ -0,0 +1,9 @@ +package dev.inmo.postssystem.features.content.binary.server + +import dev.inmo.postssystem.features.common.common.SimpleInputProvider +import io.ktor.utils.io.core.Input +import kotlinx.coroutines.flow.Flow + +interface ImagesCropper { + suspend fun crop(inputProvider: SimpleInputProvider): Flow +} diff --git a/features/content/binary/server/src/jvmMain/kotlin/dev/inmo/postssystem/features/content/binary/server/PreviewImageDimensionsConfig.kt b/features/content/binary/server/src/jvmMain/kotlin/dev/inmo/postssystem/features/content/binary/server/PreviewImageDimensionsConfig.kt new file mode 100644 index 00000000..6ff97315 --- /dev/null +++ b/features/content/binary/server/src/jvmMain/kotlin/dev/inmo/postssystem/features/content/binary/server/PreviewImageDimensionsConfig.kt @@ -0,0 +1,9 @@ +package dev.inmo.postssystem.features.content.binary.server + +import kotlinx.serialization.Serializable + +@Serializable +data class PreviewImageDimensionsConfig( + val maxWidth: Int = 1024, + val maxHeight: Int = 1024, +) diff --git a/features/content/binary/server/src/jvmMain/kotlin/dev/inmo/postssystem/features/content/binary/server/ScrimageBasedImagesCropper.kt b/features/content/binary/server/src/jvmMain/kotlin/dev/inmo/postssystem/features/content/binary/server/ScrimageBasedImagesCropper.kt new file mode 100644 index 00000000..b556e363 --- /dev/null +++ b/features/content/binary/server/src/jvmMain/kotlin/dev/inmo/postssystem/features/content/binary/server/ScrimageBasedImagesCropper.kt @@ -0,0 +1,51 @@ +package dev.inmo.postssystem.features.content.binary.server + +import com.benasher44.uuid.uuid4 +import com.sksamuel.scrimage.ImmutableImage +import com.sksamuel.scrimage.nio.JpegWriter +import dev.inmo.postssystem.features.common.common.SimpleInputProvider +import io.ktor.util.asStream +import io.ktor.utils.io.core.Input +import io.ktor.utils.io.streams.asInput +import kotlinx.coroutines.currentCoroutineContext +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.isActive +import java.io.File + +class ScrimageBasedImagesCropper( + private val config: PreviewImageDimensionsConfig +) : ImagesCropper { + override suspend fun crop(inputProvider: SimpleInputProvider): Flow = flow { + val outputTempFile = File.createTempFile(uuid4().toString(), ".temp").apply { + createNewFile() + deleteOnExit() + } + ImmutableImage.loader().fromStream( + inputProvider().asStream() + ).max( + config.maxWidth, + config.maxHeight + ).let { + if (currentCoroutineContext().isActive) { + it.output( + JpegWriter(), + outputTempFile + ) + } else { + return@flow + } + } + + val input = outputTempFile.inputStream().asInput() + runCatching { + emit(input) + }.onSuccess { + if (input.endOfInput) { // remove file if it was fully read inside of emit + outputTempFile.delete() + } + }.onFailure { + outputTempFile.delete() + } + } +} diff --git a/features/content/server/src/jvmMain/kotlin/dev/inmo/postssystem/features/content/server/ServerContentStorageAggregator.kt b/features/content/server/src/jvmMain/kotlin/dev/inmo/postssystem/features/content/server/ServerContentStorageAggregator.kt index 94de5fd3..e0db9a97 100644 --- a/features/content/server/src/jvmMain/kotlin/dev/inmo/postssystem/features/content/server/ServerContentStorageAggregator.kt +++ b/features/content/server/src/jvmMain/kotlin/dev/inmo/postssystem/features/content/server/ServerContentStorageAggregator.kt @@ -115,4 +115,25 @@ class ServerContentStorageAggregator( return currentResults.createPaginationResult(pagination, count()) } + + override suspend fun getContentPreview(id: ContentId): RegisteredContent? { + val result = CompletableDeferred() + + storages.map { + scope.launch { + val content = it.getContentPreview(id) + if (content != null) { + result.complete(content) + } + }.also { job -> + result.invokeOnCompletion { job.cancel() } + } + }.joinAll() + + return if (result.isCompleted) { + result.getCompleted() + } else { + return null + } + } } diff --git a/features/files/common/src/jvmMain/kotlin/dev/inmo/postssystem/features/files/common/WriteDistFilesStorage.kt b/features/files/common/src/jvmMain/kotlin/dev/inmo/postssystem/features/files/common/WriteDistFilesStorage.kt index 82e68dbc..aba4cbf6 100644 --- a/features/files/common/src/jvmMain/kotlin/dev/inmo/postssystem/features/files/common/WriteDistFilesStorage.kt +++ b/features/files/common/src/jvmMain/kotlin/dev/inmo/postssystem/features/files/common/WriteDistFilesStorage.kt @@ -44,7 +44,9 @@ class WriteDistFilesStorage( input.copyTo(output) } } - FullFileInfoStorageWrapper(newId, it) + FullFileInfoStorageWrapper(newId, it).also { + _newObjectsFlow.emit(it) + } } override suspend fun deleteById(ids: List) { diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 565bcfb5..7a8df218 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -10,6 +10,7 @@ ktor = "1.6.8" klock = "2.6.3" exposed = "0.37.3" psql = "42.3.0" +scrimage = "4.0.31" android-dexcount = "3.0.1" android-junit = "4.12" @@ -50,6 +51,8 @@ tgbotapi = { module = "dev.inmo:tgbotapi", version.ref = "tgbotapi" } klock = { module = "com.soywiz.korlibs.klock:klock", version.ref = "klock" } +scrimage = { module = "com.sksamuel.scrimage:scrimage-core", version.ref = "scrimage" } + androidx-test-junit = { module = "androidx.test.ext:junit", version.ref = "android-test-junit" } androidx-espresso = { module = "androidx.test.espresso:espresso-core", version.ref = "android-espresso-core" } diff --git a/server/src/main/java/dev/inmo/postssystem/server/Config.kt b/server/src/main/java/dev/inmo/postssystem/server/Config.kt index cb73f270..24c60d29 100644 --- a/server/src/main/java/dev/inmo/postssystem/server/Config.kt +++ b/server/src/main/java/dev/inmo/postssystem/server/Config.kt @@ -32,9 +32,4 @@ data class Config( }.getOrNull() } } - - val commonFilesFolder: File - get() = File(filesFolderFile, "common").also { it.mkdirs() } - val binaryFilesFolder: File - get() = File(filesFolderFile, "binary_content").also { it.mkdirs() } } diff --git a/server/src/main/java/dev/inmo/postssystem/server/DI.kt b/server/src/main/java/dev/inmo/postssystem/server/DI.kt index 3186bfc9..ed6f3ef6 100644 --- a/server/src/main/java/dev/inmo/postssystem/server/DI.kt +++ b/server/src/main/java/dev/inmo/postssystem/server/DI.kt @@ -94,25 +94,8 @@ fun getDIModule( singleWithBinds { get().databaseConfig } singleWithBinds { get().authConfig } singleWithBinds(Qualifiers.filesFolderQualifier) { get().filesFolderFile } - singleWithBinds(Qualifiers.commonFilesFolderQualifier) { get().commonFilesFolder } - singleWithBinds(Qualifiers.binaryFilesFolderQualifier) { get().binaryFilesFolder } singleWithBinds { get().database } - singleWithBinds(Qualifiers.originalFilesMetasKeyValueRepoQualifier) { - ExposedKeyValueRepo(get(), { text("fileid") }, { text("metaInfo") }, "FileIdsToMetas") - } - singleWithBinds(Qualifiers.binaryOriginalFilesMetasKeyValueRepoQualifier) { - ExposedKeyValueRepo(get(), { text("fileid") }, { text("metaInfo") }, "BinaryContentFileIdsToMetas") - } - singleWithBinds(Qualifiers.commonFilesMetasKeyValueRepoQualifier) { - MetasKeyValueRepo( - get(), - get(Qualifiers.originalFilesMetasKeyValueRepoQualifier) - ) - } - single { DiskReadFilesStorage(get(Qualifiers.commonFilesFolderQualifier), get(Qualifiers.commonFilesMetasKeyValueRepoQualifier)) } - single { WriteDistFilesStorage(get(Qualifiers.commonFilesFolderQualifier), get(Qualifiers.commonFilesMetasKeyValueRepoQualifier)) } - single { DefaultFilesStorage(get(), get()) } singleWithBinds { ExposedUsersStorage(get()) } singleWithBinds { exposedUsersAuthenticator(get(), get()) } @@ -139,18 +122,6 @@ fun getDIModule( factory { baseScope.LinkedSupervisorScope() } // Content storages - singleWithBinds(Qualifiers.binaryFilesMetasKeyValueRepoQualifier) { - MetasKeyValueRepo( - get(), - get(Qualifiers.binaryOriginalFilesMetasKeyValueRepoQualifier) - ) - } - single(Qualifiers.binaryStorageFilesQualifier) { - DefaultFilesStorage( - DiskReadFilesStorage(get(Qualifiers.binaryFilesFolderQualifier), get(Qualifiers.binaryFilesMetasKeyValueRepoQualifier)), - WriteDistFilesStorage(get(Qualifiers.binaryFilesFolderQualifier), get(Qualifiers.binaryFilesMetasKeyValueRepoQualifier)) - ) - } single> { ServerContentStorageAggregator(getAll(), get()) } binds arrayOf( ServerReadContentStorage::class, diff --git a/services/posts/client/src/commonMain/kotlin/dev/inmo/postssystem/services/posts/client/ui/create/DefaultPostCreateUIModel.kt b/services/posts/client/src/commonMain/kotlin/dev/inmo/postssystem/services/posts/client/ui/create/DefaultPostCreateUIModel.kt index 80076b6e..9477c65c 100644 --- a/services/posts/client/src/commonMain/kotlin/dev/inmo/postssystem/services/posts/client/ui/create/DefaultPostCreateUIModel.kt +++ b/services/posts/client/src/commonMain/kotlin/dev/inmo/postssystem/services/posts/client/ui/create/DefaultPostCreateUIModel.kt @@ -20,7 +20,7 @@ class DefaultPostCreateUIModel( FullNewPost(content) ) ?: return@runCatching delay(1000L) - publicationService.publish(post.id) +// publicationService.publish(post.id) }.onFailure { _currentState.value = PostCreateUIState.Fail }.onSuccess {