Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: Handle downloading transfers with password #159

Merged
merged 12 commits into from
Jan 20, 2025
Original file line number Diff line number Diff line change
Expand Up @@ -18,9 +18,11 @@
package com.infomaniak.multiplatform_swisstransfer

import com.infomaniak.multiplatform_swisstransfer.common.exceptions.RealmException
import com.infomaniak.multiplatform_swisstransfer.common.interfaces.transfers.Transfer
import com.infomaniak.multiplatform_swisstransfer.common.utils.ApiEnvironment
import com.infomaniak.multiplatform_swisstransfer.database.controllers.TransferController
import com.infomaniak.multiplatform_swisstransfer.database.controllers.UploadController
import com.infomaniak.multiplatform_swisstransfer.network.repositories.TransferRepository
import com.infomaniak.multiplatform_swisstransfer.network.utils.SharedApiRoutes
import kotlin.coroutines.cancellation.CancellationException

Expand All @@ -31,6 +33,7 @@ class SharedApiUrlCreator internal constructor(
private val environment: ApiEnvironment,
private val transferController: TransferController,
private val uploadController: UploadController,
private val transferRepository: TransferRepository,
) {
val createUploadContainerUrl: String = SharedApiRoutes.createUploadContainer(environment)

Expand All @@ -39,19 +42,22 @@ class SharedApiUrlCreator internal constructor(
@Throws(RealmException::class, CancellationException::class)
suspend fun downloadFilesUrl(transferUUID: String): String? {
val transfer = transferController.getTransfer(transferUUID) ?: return null
return SharedApiRoutes.downloadFiles(transfer.downloadHost, transfer.linkUUID)
val token = generateToken(transfer, transfer.password)
return SharedApiRoutes.downloadFiles(transfer.downloadHost, transfer.linkUUID, token)
}

@Throws(RealmException::class, CancellationException::class)
suspend fun downloadFileUrl(transferUUID: String, fileUUID: String?): String? {
val transfer = transferController.getTransfer(transferUUID) ?: return null
return SharedApiRoutes.downloadFile(transfer.downloadHost, transfer.linkUUID, fileUUID)
val token = generateToken(transfer, transfer.password)
return SharedApiRoutes.downloadFile(transfer.downloadHost, transfer.linkUUID, fileUUID, token)
}

@Throws(RealmException::class, CancellationException::class)
suspend fun downloadFolderUrl(transferUUID: String, folderPath: String): String? {
val transfer = transferController.getTransfer(transferUUID) ?: return null
return SharedApiRoutes.downloadFolder(transfer.downloadHost, transfer.linkUUID, folderPath)
val token = generateToken(transfer, transfer.password)
return SharedApiRoutes.downloadFolder(transfer.downloadHost, transfer.linkUUID, folderPath, token)
}

@Throws(RealmException::class)
Expand All @@ -61,4 +67,10 @@ class SharedApiUrlCreator internal constructor(
val uploadHost = upload.remoteUploadHost ?: return null
return SharedApiRoutes.uploadChunk(uploadHost, containerUUID, fileUUID, chunkIndex, isLastChunk)
}

private suspend fun generateToken(transfer: Transfer, password: String?): String? {
return password?.let {
transferRepository.generateDownloadToken(transfer.containerUUID, fileUUID = null, password = it)
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -99,5 +99,5 @@ class SwissTransferInjection(
}

/** An utils to help use shared routes */
val sharedApiUrlCreator by lazy { SharedApiUrlCreator(environment, transferController, uploadController) }
val sharedApiUrlCreator by lazy { SharedApiUrlCreator(environment, transferController, uploadController, transferRepository) }
}
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,9 @@ import com.infomaniak.multiplatform_swisstransfer.network.models.transfer.Transf
import com.infomaniak.multiplatform_swisstransfer.network.requests.TransferRequest
import io.ktor.client.HttpClient
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonNull
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonPrimitive
import kotlin.coroutines.cancellation.CancellationException

class TransferRepository internal constructor(private val transferRequest: TransferRequest) {
Expand Down Expand Up @@ -77,5 +80,26 @@ class TransferRepository internal constructor(private val transferRequest: Trans
return getTransferByLinkUUID(extractUUID(url), password)
}

@Throws(
CancellationException::class,
ApiException::class,
UnexpectedApiErrorFormatException::class,
NetworkException::class,
UnknownException::class,
)
suspend fun generateDownloadToken(
containerUUID: String,
fileUUID: String?,
password: String,
): String {
val fileUUIDJson = fileUUID?.let { JsonPrimitive(it) } ?: JsonNull
val bodyMap = mapOf(
"containerUUID" to JsonPrimitive(containerUUID),
"fileUUID" to fileUUIDJson,
"password" to JsonPrimitive(password),
)
return transferRequest.generateDownloadToken(JsonObject(bodyMap))
}

internal fun extractUUID(url: String) = url.substringAfterLast("/")
}
Original file line number Diff line number Diff line change
Expand Up @@ -22,8 +22,14 @@ import com.infomaniak.multiplatform_swisstransfer.network.models.ApiResponse
import com.infomaniak.multiplatform_swisstransfer.network.models.transfer.TransferApi
import com.infomaniak.multiplatform_swisstransfer.network.utils.ApiRoutes
import io.ktor.client.HttpClient
import io.ktor.client.request.post
import io.ktor.client.request.setBody
import io.ktor.client.statement.bodyAsText
import io.ktor.http.ContentType
import io.ktor.http.HttpHeaders
import io.ktor.http.contentType
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonObject
import kotlin.io.encoding.Base64
import kotlin.io.encoding.ExperimentalEncodingApi

Expand All @@ -44,4 +50,12 @@ internal class TransferRequest(
}
)
}

suspend fun generateDownloadToken(jsonBody: JsonObject): String {
val httpResponse = httpClient.post(url = createUrl(ApiRoutes.generateDownloadToken())) {
contentType(ContentType.Application.Json)
setBody(jsonBody)
}
return httpResponse.bodyAsText()
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,8 @@ internal object ApiRoutes {

//region Transfer
fun getTransfer(linkUUID: String): String = "links/$linkUUID"

fun generateDownloadToken() = "generateDownloadToken"
//endRegion

//region Upload
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -25,17 +25,23 @@ object SharedApiRoutes {

fun shareTransfer(environment: ApiEnvironment, linkUUID: String): String = "${environment.baseUrl}/d/$linkUUID"

fun downloadFiles(downloadHost: String, linkUUID: String): String = "https://$downloadHost/api/download/$linkUUID"
internal fun downloadFilesBase(downloadHost: String, linkUUID: String) = "https://$downloadHost/api/download/$linkUUID"

fun downloadFile(downloadHost: String, linkUUID: String, fileUUID: String?): String {
return "${downloadFiles(downloadHost, linkUUID)}/$fileUUID"
fun downloadFiles(downloadHost: String, linkUUID: String, token: String?): String {
return "${downloadFilesBase(downloadHost, linkUUID)}${withToken(token)}"
}

fun downloadFolder(downloadHost: String, linkUUID: String, folderPath: String): String {
return "${downloadFiles(downloadHost, linkUUID)}?folder=$folderPath"
fun downloadFile(downloadHost: String, linkUUID: String, fileUUID: String?, token: String?): String {
return "${downloadFilesBase(downloadHost, linkUUID)}/$fileUUID${withToken(token)}"
}

fun downloadFolder(downloadHost: String, linkUUID: String, folderPath: String, token: String?): String {
return "${downloadFilesBase(downloadHost, linkUUID)}?folder=$folderPath${withToken(token, prefix = "&")}"
}

fun uploadChunk(uploadHost: String, containerUUID: String, fileUUID: String, chunkIndex: Int, isLastChunk: Boolean): String {
return "https://$uploadHost/api/uploadChunk/$containerUUID/$fileUUID/$chunkIndex/${isLastChunk.int()}"
}

private fun withToken(token: String?, prefix: String = "?") = if (token == null) "" else "${prefix}token=$token"
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
/*
* Infomaniak SwissTransfer - Multiplatform
* Copyright (C) 2025 Infomaniak Network SA
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package com.infomaniak.multiplatform_swisstransfer.network

import com.infomaniak.multiplatform_swisstransfer.network.utils.SharedApiRoutes
import kotlin.test.Test
import kotlin.test.assertEquals

class SharedApiRoutesTest {

@Test
fun downloadFilesBase_IsCorrect() {
val result = SharedApiRoutes.downloadFilesBase("host", "linkUUID")
assertEquals(expected = "https://host/api/download/linkUUID", result)
}

@Test
fun downloadFilesUrl_IsCorrect() {
val result = SharedApiRoutes.downloadFiles(downloadHost = "host", linkUUID = "linkUUID", token = null)
val expected = SharedApiRoutes.downloadFilesBase(downloadHost = "host", linkUUID = "linkUUID")
assertEquals(expected, result)
}

@Test
fun downloadFilesUrlWithPassword_IsCorrect() {
val result = SharedApiRoutes.downloadFiles(downloadHost = "host", linkUUID = "linkUUID", token = "token")
val expected = SharedApiRoutes.downloadFilesBase(downloadHost = "host", linkUUID = "linkUUID") + "?token=token"
assertEquals(expected, result)
}

@Test
fun downloadFileUrl_IsCorrect() {
val result = SharedApiRoutes.downloadFile(
downloadHost = "host",
linkUUID = "linkUUID",
fileUUID = "fileUUID",
token = null,
)
val expected = SharedApiRoutes.downloadFilesBase(downloadHost = "host", linkUUID = "linkUUID") + "/fileUUID"
assertEquals(expected, result)
}

@Test
fun downloadFileUrlWithPassword_IsCorrect() {
val result = SharedApiRoutes.downloadFile(
downloadHost = "host",
linkUUID = "linkUUID",
fileUUID = "fileUUID",
token = "token",
)
val expected = SharedApiRoutes.downloadFilesBase(downloadHost = "host", linkUUID = "linkUUID") + "/fileUUID?token=token"
assertEquals(expected, result)
}

@Test
fun downloadFolderUrl_IsCorrect() {
val result = SharedApiRoutes.downloadFolder(
downloadHost = "host",
linkUUID = "linkUUID",
folderPath = "folderPath",
token = null,
)
val expected = SharedApiRoutes.downloadFilesBase(downloadHost = "host", linkUUID = "linkUUID") + "?folder=folderPath"
assertEquals(expected, result)
}

@Test
fun downloadFolderUrlWithPassword_IsCorrect() {
val result = SharedApiRoutes.downloadFolder(
downloadHost = "host",
linkUUID = "linkUUID",
folderPath = "folderPath",
token = "token",
)
val query = "?folder=folderPath&token=token"
val expected = SharedApiRoutes.downloadFilesBase(downloadHost = "host", linkUUID = "linkUUID") + query
assertEquals(expected, result)
}
}
Loading