diff --git a/CHANGELOG.md b/CHANGELOG.md index fbfb7482175..f8da494186c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,9 @@ ## 0.9.14 +* `Ktor`: + * Add temporal files uploading functionality (for clients to upload and for server to receive) + ## 0.9.13 * `Versions`: diff --git a/ktor/client/src/commonMain/kotlin/dev/inmo/micro_utils/ktor/client/TemporalUpload.kt b/ktor/client/src/commonMain/kotlin/dev/inmo/micro_utils/ktor/client/TemporalUpload.kt new file mode 100644 index 00000000000..a00f85f904d --- /dev/null +++ b/ktor/client/src/commonMain/kotlin/dev/inmo/micro_utils/ktor/client/TemporalUpload.kt @@ -0,0 +1,19 @@ +package dev.inmo.micro_utils.ktor.client + +import dev.inmo.micro_utils.common.MPPFile +import dev.inmo.micro_utils.ktor.common.* +import io.ktor.client.HttpClient + +expect suspend fun HttpClient.tempUpload( + fullTempUploadDraftPath: String, + file: MPPFile, + onUpload: (uploaded: Long, count: Long) -> Unit = { _, _ -> } +): TemporalFileId + +suspend fun UnifiedRequester.tempUpload( + fullTempUploadDraftPath: String, + file: MPPFile, + onUpload: (uploaded: Long, count: Long) -> Unit = { _, _ -> } +): TemporalFileId = client.tempUpload( + fullTempUploadDraftPath, file, onUpload +) diff --git a/ktor/client/src/jsMain/kotlin/dev/inmo/micro_utils/ktor/client/ActualTemporalUpload.kt b/ktor/client/src/jsMain/kotlin/dev/inmo/micro_utils/ktor/client/ActualTemporalUpload.kt new file mode 100644 index 00000000000..98070bd2430 --- /dev/null +++ b/ktor/client/src/jsMain/kotlin/dev/inmo/micro_utils/ktor/client/ActualTemporalUpload.kt @@ -0,0 +1,54 @@ +package dev.inmo.micro_utils.ktor.client + +import dev.inmo.micro_utils.common.MPPFile +import dev.inmo.micro_utils.ktor.common.TemporalFileId +import io.ktor.client.HttpClient +import kotlinx.coroutines.* +import org.w3c.xhr.* + +suspend fun tempUpload( + fullTempUploadDraftPath: String, + file: MPPFile, + onUpload: (Long, Long) -> Unit +): TemporalFileId { + val formData = FormData() + val answer = CompletableDeferred() + + formData.append( + "data", + file + ) + + val request = XMLHttpRequest() + request.responseType = XMLHttpRequestResponseType.TEXT + request.upload.onprogress = { + onUpload(it.loaded.toLong(), it.total.toLong()) + } + request.onload = { + if (request.status == 200.toShort()) { + answer.complete(TemporalFileId(request.responseText)) + } else { + answer.completeExceptionally(Exception("Something went wrong")) + } + } + request.onerror = { + answer.completeExceptionally(Exception("Something went wrong")) + } + request.open("POST", fullTempUploadDraftPath, true) + request.send(formData) + + currentCoroutineContext().job.invokeOnCompletion { + runCatching { + request.abort() + } + } + + return answer.await() +} + + +actual suspend fun HttpClient.tempUpload( + fullTempUploadDraftPath: String, + file: MPPFile, + onUpload: (uploaded: Long, count: Long) -> Unit +): TemporalFileId = dev.inmo.micro_utils.ktor.client.tempUpload(fullTempUploadDraftPath, file, onUpload) diff --git a/ktor/client/src/jvmMain/kotlin/dev/inmo/micro_utils/ktor/client/ActualTemporalUpload.kt b/ktor/client/src/jvmMain/kotlin/dev/inmo/micro_utils/ktor/client/ActualTemporalUpload.kt new file mode 100644 index 00000000000..f7fbdf9c727 --- /dev/null +++ b/ktor/client/src/jvmMain/kotlin/dev/inmo/micro_utils/ktor/client/ActualTemporalUpload.kt @@ -0,0 +1,39 @@ +package dev.inmo.micro_utils.ktor.client + +import dev.inmo.micro_utils.common.MPPFile +import dev.inmo.micro_utils.common.filename +import dev.inmo.micro_utils.ktor.common.TemporalFileId +import io.ktor.client.HttpClient +import io.ktor.client.features.onUpload +import io.ktor.client.request.forms.formData +import io.ktor.client.request.forms.submitFormWithBinaryData +import io.ktor.http.Headers +import io.ktor.http.HttpHeaders +import java.net.URLConnection + +internal val MPPFile.mimeType: String + get() = URLConnection.getFileNameMap().getContentTypeFor(filename.name) ?: "*/*" + +actual suspend fun HttpClient.tempUpload( + fullTempUploadDraftPath: String, + file: MPPFile, + onUpload: (Long, Long) -> Unit +): TemporalFileId { + val inputProvider = file.inputProvider() + val fileId = submitFormWithBinaryData( + fullTempUploadDraftPath, + formData = formData { + append( + "data", + inputProvider, + Headers.build { + append(HttpHeaders.ContentType, file.mimeType) + append(HttpHeaders.ContentDisposition, "filename=\"${file.filename.string}\"") + } + ) + } + ) { + onUpload(onUpload) + } + return TemporalFileId(fileId) +} diff --git a/ktor/common/build.gradle b/ktor/common/build.gradle index ad2dd123f61..7446daad0c7 100644 --- a/ktor/common/build.gradle +++ b/ktor/common/build.gradle @@ -13,6 +13,7 @@ kotlin { api internalProject("micro_utils.common") api libs.kt.serialization.cbor api libs.klock + api libs.uuid } } } diff --git a/ktor/common/src/commonMain/kotlin/dev/inmo/micro_utils/ktor/common/TemporalFiles.kt b/ktor/common/src/commonMain/kotlin/dev/inmo/micro_utils/ktor/common/TemporalFiles.kt new file mode 100644 index 00000000000..cabeffa5bdb --- /dev/null +++ b/ktor/common/src/commonMain/kotlin/dev/inmo/micro_utils/ktor/common/TemporalFiles.kt @@ -0,0 +1,8 @@ +package dev.inmo.micro_utils.ktor.common + +import kotlin.jvm.JvmInline + +const val DefaultTemporalFilesSubPath = "temp_upload" + +@JvmInline +value class TemporalFileId(val string: String) diff --git a/ktor/server/src/jvmMain/kotlin/dev/inmo/micro_utils/ktor/server/ServerRoutingShortcuts.kt b/ktor/server/src/jvmMain/kotlin/dev/inmo/micro_utils/ktor/server/ServerRoutingShortcuts.kt index f2c06bc5e52..5fd9357630e 100644 --- a/ktor/server/src/jvmMain/kotlin/dev/inmo/micro_utils/ktor/server/ServerRoutingShortcuts.kt +++ b/ktor/server/src/jvmMain/kotlin/dev/inmo/micro_utils/ktor/server/ServerRoutingShortcuts.kt @@ -92,6 +92,10 @@ class UnifiedRouter( call.respond(HttpStatusCode.BadRequest, "Request query parameters must contains $field") } } + + companion object { + val default = defaultUnifiedRouter + } } val defaultUnifiedRouter = UnifiedRouter() diff --git a/ktor/server/src/jvmMain/kotlin/dev/inmo/micro_utils/ktor/server/TemporalFilesRoutingConfigurator.kt b/ktor/server/src/jvmMain/kotlin/dev/inmo/micro_utils/ktor/server/TemporalFilesRoutingConfigurator.kt new file mode 100644 index 00000000000..9d33c127cbf --- /dev/null +++ b/ktor/server/src/jvmMain/kotlin/dev/inmo/micro_utils/ktor/server/TemporalFilesRoutingConfigurator.kt @@ -0,0 +1,132 @@ +package dev.inmo.micro_utils.ktor.server + +import com.benasher44.uuid.uuid4 +import dev.inmo.micro_utils.common.FileName +import dev.inmo.micro_utils.common.MPPFile +import dev.inmo.micro_utils.coroutines.launchSafelyWithoutExceptions +import dev.inmo.micro_utils.ktor.common.DefaultTemporalFilesSubPath +import dev.inmo.micro_utils.ktor.common.TemporalFileId +import dev.inmo.micro_utils.ktor.server.configurators.ApplicationRoutingConfigurator +import io.ktor.application.call +import io.ktor.http.HttpStatusCode +import io.ktor.http.content.PartData +import io.ktor.http.content.streamProvider +import io.ktor.request.receiveMultipart +import io.ktor.response.respond +import io.ktor.routing.Route +import io.ktor.routing.post +import kotlinx.coroutines.* +import kotlinx.coroutines.flow.* +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import java.io.File +import java.nio.file.Files +import java.nio.file.attribute.FileTime + +class TemporalFilesRoutingConfigurator( + private val subpath: String = DefaultTemporalFilesSubPath, + private val unifiedRouter: UnifiedRouter = UnifiedRouter.default, + private val temporalFilesUtilizer: TemporalFilesUtilizer = TemporalFilesUtilizer +) : ApplicationRoutingConfigurator.Element { + interface TemporalFilesUtilizer { + fun start(filesMap: MutableMap, filesMutex: Mutex, onNewFileFlow: Flow): Job + + companion object : TemporalFilesUtilizer { + class ByTimerUtilizer( + private val removeMillis: Long, + private val scope: CoroutineScope + ) : TemporalFilesUtilizer { + override fun start( + filesMap: MutableMap, + filesMutex: Mutex, + onNewFileFlow: Flow + ): Job = scope.launchSafelyWithoutExceptions { + while (isActive) { + val filesWithCreationInfo = filesMap.mapNotNull { (fileId, file) -> + fileId to ((Files.getAttribute(file.toPath(), "creationTime") as? FileTime) ?.toMillis() ?: return@mapNotNull null) + } + if (filesWithCreationInfo.isEmpty()) { + delay(removeMillis) + continue + } + var min = filesWithCreationInfo.first() + for (fileWithCreationInfo in filesWithCreationInfo) { + if (fileWithCreationInfo.second < min.second) { + min = fileWithCreationInfo + } + } + delay(System.currentTimeMillis() - (min.second + removeMillis)) + filesMutex.withLock { + filesMap.remove(min.first) + } ?.delete() + } + + } + } + + override fun start( + filesMap: MutableMap, + filesMutex: Mutex, + onNewFileFlow: Flow + ): Job = Job() + } + } + + private val temporalFilesMap = mutableMapOf() + private val temporalFilesMutex = Mutex() + private val filesFlow = MutableSharedFlow() + val utilizerJob = temporalFilesUtilizer.start(temporalFilesMap, temporalFilesMutex, filesFlow.asSharedFlow()) + + override fun Route.invoke() { + post(subpath) { + unifiedRouter.apply { + val multipart = call.receiveMultipart() + + var fileInfo: Pair? = null + var part = multipart.readPart() + + while (part != null) { + if (part is PartData.FileItem) { + break + } + part = multipart.readPart() + } + + part ?.let { + if (it is PartData.FileItem) { + val fileId = TemporalFileId(uuid4().toString()) + val fileName = it.originalFileName ?.let { FileName(it) } ?: return@let + fileInfo = fileId to File.createTempFile(fileId.string, ".${fileName.extension}").apply { + outputStream().use { outputStream -> + it.streamProvider().use { + it.copyTo(outputStream) + } + } + deleteOnExit() + } + } + } + + fileInfo ?.also { (fileId, file) -> + temporalFilesMutex.withLock { + temporalFilesMap[fileId] = file + } + call.respond(fileId.string) + launchSafelyWithoutExceptions { filesFlow.emit(fileId) } + } ?: call.respond(HttpStatusCode.BadRequest) + } + } + } + + suspend fun removeTemporalFile(temporalFileId: TemporalFileId) { + temporalFilesMutex.withLock { + temporalFilesMap.remove(temporalFileId) + } + } + + fun getTemporalFile(temporalFileId: TemporalFileId) = temporalFilesMap[temporalFileId] + + suspend fun getAndRemoveTemporalFile(temporalFileId: TemporalFileId) = temporalFilesMutex.withLock { + temporalFilesMap.remove(temporalFileId) + } +}