diff --git a/composeApp/src/androidMain/kotlin/com/nuvio/app/features/addons/AddonPlatform.android.kt b/composeApp/src/androidMain/kotlin/com/nuvio/app/features/addons/AddonPlatform.android.kt index 03b8bd842..5e41de7d4 100644 --- a/composeApp/src/androidMain/kotlin/com/nuvio/app/features/addons/AddonPlatform.android.kt +++ b/composeApp/src/androidMain/kotlin/com/nuvio/app/features/addons/AddonPlatform.android.kt @@ -16,6 +16,8 @@ import okhttp3.OkHttpClient import okhttp3.Request import okhttp3.RequestBody.Companion.toRequestBody import java.net.Proxy +import java.io.ByteArrayOutputStream +import java.io.InputStream import kotlin.text.Charsets import java.util.concurrent.TimeUnit @@ -87,6 +89,11 @@ private val addonHttpClient = OkHttpClient.Builder() private val jsonMediaType = "application/json; charset=utf-8".toMediaType() +private data class LimitedReadResult( + val bytes: ByteArray, + val truncated: Boolean, +) + private fun requestAllowsBody(method: String): Boolean = when (method.uppercase()) { "POST", "PUT", "PATCH", "DELETE" -> true @@ -101,6 +108,42 @@ private fun Map.withoutAcceptEncoding(): Map = private fun Map.getHeaderIgnoreCase(name: String): String? = entries.firstOrNull { (key, _) -> key.equals(name, ignoreCase = true) }?.value +private fun readAtMostBytes(stream: InputStream, maxBytes: Int): LimitedReadResult { + val out = ByteArrayOutputStream(minOf(maxBytes, 16 * 1024)) + val buffer = ByteArray(8 * 1024) + var remaining = maxBytes + var truncated = false + + while (remaining > 0) { + val read = stream.read(buffer, 0, minOf(buffer.size, remaining)) + if (read <= 0) break + out.write(buffer, 0, read) + remaining -= read + } + + if (remaining == 0) { + truncated = stream.read() != -1 + } + + return LimitedReadResult(out.toByteArray(), truncated) +} + +private fun readResponseBodyLimited(body: ResponseBody?, maxBytes: Int): String { + if (body == null) return "" + val charset = body.contentType()?.charset(Charsets.UTF_8) ?: Charsets.UTF_8 + val readResult = body.byteStream().use { stream -> + readAtMostBytes(stream, maxBytes.coerceAtLeast(0)) + } + + val decoded = try { + String(readResult.bytes, charset) + } catch (_: Exception) { + String(readResult.bytes, Charsets.UTF_8) + } + + return if (readResult.truncated) "$decoded\n...[truncated]" else decoded +} + private fun readResponseBody(body: ResponseBody?): String { if (body == null) return "" val bytes = body.bytes() @@ -196,6 +239,7 @@ actual suspend fun httpRequestRaw( headers: Map, body: String, followRedirects: Boolean, + maxResponseBodyBytes: Int, ): RawHttpResponse = withContext(Dispatchers.IO) { val normalizedMethod = method.uppercase() @@ -228,7 +272,7 @@ actual suspend fun httpRequestRaw( status = response.code, statusText = response.message, url = response.request.url.toString(), - body = readResponseBody(response.body), + body = readResponseBodyLimited(response.body, maxResponseBodyBytes), headers = response.headers.toMultimap().mapValues { (_, values) -> values.joinToString(",") }.mapKeys { (name, _) -> diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/addons/AddonPlatform.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/addons/AddonPlatform.kt index e416e64ca..75e68beb4 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/addons/AddonPlatform.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/addons/AddonPlatform.kt @@ -15,6 +15,9 @@ data class RawHttpResponse( val headers: Map, ) +/** Default safety limit for generic and plugin-provided HTTP responses. */ +internal const val DefaultRawHttpResponseMaxBytes = 1024 * 1024 + expect suspend fun httpGetText(url: String): String expect suspend fun httpPostJson(url: String, body: String): String @@ -36,4 +39,5 @@ expect suspend fun httpRequestRaw( headers: Map, body: String, followRedirects: Boolean = true, + maxResponseBodyBytes: Int = DefaultRawHttpResponseMaxBytes, ): RawHttpResponse diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/DebridApiClients.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/DebridApiClients.kt index f27870be5..4e505706a 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/DebridApiClients.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/DebridApiClients.kt @@ -1,6 +1,7 @@ package com.nuvio.app.features.debrid import com.nuvio.app.features.addons.RawHttpResponse +import com.nuvio.app.features.addons.DefaultRawHttpResponseMaxBytes import com.nuvio.app.features.addons.httpRequestRaw import kotlinx.serialization.ExperimentalSerializationApi import kotlinx.serialization.SerializationException @@ -27,6 +28,8 @@ internal object DebridApiJson { internal object TorboxApiClient { private const val BASE_URL = "https://api.torbox.app" + // Torbox returns up to 1,000 items, each with its full files array. + private const val cloudListResponseMaxBytes = 16 * 1024 * 1024 suspend fun startDeviceAuthorization( appName: String, @@ -120,6 +123,7 @@ internal object TorboxApiClient { method = "GET", url = "$BASE_URL/v1/api/torrents/mylist", apiKey = apiKey, + maxResponseBodyBytes = cloudListResponseMaxBytes, ) suspend fun listCloudUsenet(apiKey: String): DebridApiResponse>> = @@ -127,6 +131,7 @@ internal object TorboxApiClient { method = "GET", url = "$BASE_URL/v1/api/usenet/mylist", apiKey = apiKey, + maxResponseBodyBytes = cloudListResponseMaxBytes, ) suspend fun listCloudWebDownloads(apiKey: String): DebridApiResponse>> = @@ -134,6 +139,7 @@ internal object TorboxApiClient { method = "GET", url = "$BASE_URL/v1/api/webdl/mylist", apiKey = apiKey, + maxResponseBodyBytes = cloudListResponseMaxBytes, ) suspend fun requestDownloadLink( @@ -222,6 +228,7 @@ internal object TorboxApiClient { apiKey: String, body: String = "", contentType: String? = null, + maxResponseBodyBytes: Int = DefaultRawHttpResponseMaxBytes, ): DebridApiResponse { val headers = authHeaders(apiKey) + listOfNotNull( contentType?.let { "Content-Type" to it }, @@ -232,6 +239,7 @@ internal object TorboxApiClient { url = url, headers = headers, body = body, + maxResponseBodyBytes = maxResponseBodyBytes, ) return DebridApiResponse( status = response.status, diff --git a/composeApp/src/iosMain/kotlin/com/nuvio/app/features/addons/AddonPlatform.ios.kt b/composeApp/src/iosMain/kotlin/com/nuvio/app/features/addons/AddonPlatform.ios.kt index 8b6cfc112..ec291bd5e 100644 --- a/composeApp/src/iosMain/kotlin/com/nuvio/app/features/addons/AddonPlatform.ios.kt +++ b/composeApp/src/iosMain/kotlin/com/nuvio/app/features/addons/AddonPlatform.ios.kt @@ -167,6 +167,7 @@ actual suspend fun httpRequestRaw( headers: Map, body: String, followRedirects: Boolean, + maxResponseBodyBytes: Int, ): RawHttpResponse = addonHttpClient .request {