fix: bound generic Android raw responses

This commit is contained in:
tapframe 2026-07-18 03:40:53 +05:30
parent 250d9d79cf
commit 50bb2bcc52
4 changed files with 58 additions and 1 deletions

View file

@ -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<String, String>.withoutAcceptEncoding(): Map<String, String> =
private fun Map<String, String>.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<String, String>,
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, _) ->

View file

@ -15,6 +15,9 @@ data class RawHttpResponse(
val headers: Map<String, String>,
)
/** 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<String, String>,
body: String,
followRedirects: Boolean = true,
maxResponseBodyBytes: Int = DefaultRawHttpResponseMaxBytes,
): RawHttpResponse

View file

@ -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<TorboxEnvelopeDto<List<TorboxCloudItemDto>>> =
@ -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<TorboxEnvelopeDto<List<TorboxCloudItemDto>>> =
@ -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<T> {
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,

View file

@ -167,6 +167,7 @@ actual suspend fun httpRequestRaw(
headers: Map<String, String>,
body: String,
followRedirects: Boolean,
maxResponseBodyBytes: Int,
): RawHttpResponse =
addonHttpClient
.request {