diff --git a/composeApp/src/androidFull/kotlin/com/nuvio/app/features/plugins/PluginCrypto.android.kt b/composeApp/src/androidFull/kotlin/com/nuvio/app/features/plugins/PluginCrypto.android.kt index 8a4c66e9..475fd8b4 100644 --- a/composeApp/src/androidFull/kotlin/com/nuvio/app/features/plugins/PluginCrypto.android.kt +++ b/composeApp/src/androidFull/kotlin/com/nuvio/app/features/plugins/PluginCrypto.android.kt @@ -1,14 +1,139 @@ package com.nuvio.app.features.plugins +import java.security.KeyFactory +import java.security.MessageDigest +import java.security.SecureRandom +import java.security.Signature +import java.security.spec.PKCS8EncodedKeySpec +import java.security.spec.X509EncodedKeySpec +import javax.crypto.Cipher +import javax.crypto.Mac +import javax.crypto.SecretKeyFactory +import javax.crypto.spec.GCMParameterSpec +import javax.crypto.spec.IvParameterSpec +import javax.crypto.spec.PBEKeySpec +import javax.crypto.spec.SecretKeySpec import kotlin.io.encoding.Base64 import kotlin.io.encoding.ExperimentalEncodingApi -import java.security.MessageDigest -import javax.crypto.Mac -import javax.crypto.spec.SecretKeySpec + +private val secureRandom = SecureRandom() + +internal fun pluginGetRandomValues(length: Int): ByteArray { + val bytes = ByteArray(length) + secureRandom.nextBytes(bytes) + return bytes +} + +internal fun pluginDigest(algorithm: String, data: ByteArray): ByteArray { + return MessageDigest.getInstance(algorithm.uppercase()).digest(data) +} + +internal fun pluginPbkdf2( + password: ByteArray, + salt: ByteArray, + iterations: Int, + keySizeBits: Int, + algorithm: String, +): ByteArray { + val normalizedAlgo = when (algorithm.uppercase()) { + "SHA256" -> "PBKDF2WithHmacSHA256" + "SHA1" -> "PBKDF2WithHmacSHA1" + else -> "PBKDF2WithHmacSHA256" + } + val factory = SecretKeyFactory.getInstance(normalizedAlgo) + val passChars = password.map { (it.toInt() and 0xFF).toChar() }.toCharArray() + val spec = PBEKeySpec(passChars, salt, iterations, keySizeBits) + return factory.generateSecret(spec).encoded +} + +internal fun pluginAesEncrypt( + mode: String, + key: ByteArray, + iv: ByteArray, + data: ByteArray, +): ByteArray { + val normalizedMode = when (mode.uppercase()) { + "AES-CBC", "CBC" -> "AES/CBC/PKCS5Padding" + "AES-GCM", "GCM" -> "AES/GCM/NoPadding" + "AES-ECB", "ECB" -> "AES/ECB/PKCS5Padding" + else -> "AES/CBC/PKCS5Padding" + } + + val cipher = Cipher.getInstance(normalizedMode) + val keySpec = SecretKeySpec(key, "AES") + + if (normalizedMode.contains("ECB")) { + cipher.init(Cipher.ENCRYPT_MODE, keySpec) + } else if (normalizedMode.contains("GCM")) { + val gcmSpec = GCMParameterSpec(128, iv) + cipher.init(Cipher.ENCRYPT_MODE, keySpec, gcmSpec) + } else { + val ivSpec = IvParameterSpec(iv) + cipher.init(Cipher.ENCRYPT_MODE, keySpec, ivSpec) + } + + return cipher.doFinal(data) +} + +internal fun pluginAesDecrypt( + mode: String, + key: ByteArray, + iv: ByteArray, + data: ByteArray, +): ByteArray { + val normalizedMode = when (mode.uppercase()) { + "AES-CBC", "CBC" -> "AES/CBC/PKCS5Padding" + "AES-GCM", "GCM" -> "AES/GCM/NoPadding" + "AES-ECB", "ECB" -> "AES/ECB/PKCS5Padding" + else -> "AES/CBC/PKCS5Padding" + } + + val cipher = Cipher.getInstance(normalizedMode) + val keySpec = SecretKeySpec(key, "AES") + + if (normalizedMode.contains("ECB")) { + cipher.init(Cipher.DECRYPT_MODE, keySpec) + } else if (normalizedMode.contains("GCM")) { + val gcmSpec = GCMParameterSpec(128, iv) + cipher.init(Cipher.DECRYPT_MODE, keySpec, gcmSpec) + } else { + val ivSpec = IvParameterSpec(iv) + cipher.init(Cipher.DECRYPT_MODE, keySpec, ivSpec) + } + + return cipher.doFinal(data) +} + +internal fun pluginSign(algorithm: String, privateKey: ByteArray, data: ByteArray): ByteArray { + val (keyAlgo, sigAlgo) = when (algorithm.uppercase()) { + "RSASSA-PKCS1-V1_5-SHA256", "RSASSA-PKCS1-V1_5" -> "RSA" to "SHA256withRSA" + "ECDSA-SHA256", "ECDSA" -> "EC" to "SHA256withECDSA" + else -> "RSA" to "SHA256withRSA" + } + val factory = KeyFactory.getInstance(keyAlgo) + val privKey = factory.generatePrivate(PKCS8EncodedKeySpec(privateKey)) + val sig = Signature.getInstance(sigAlgo) + sig.initSign(privKey) + sig.update(data) + return sig.sign() +} + +internal fun pluginVerify(algorithm: String, publicKey: ByteArray, signature: ByteArray, data: ByteArray): Boolean { + val (keyAlgo, sigAlgo) = when (algorithm.uppercase()) { + "RSASSA-PKCS1-V1_5-SHA256", "RSASSA-PKCS1-V1_5" -> "RSA" to "SHA256withRSA" + "ECDSA-SHA256", "ECDSA" -> "EC" to "SHA256withECDSA" + else -> "RSA" to "SHA256withRSA" + } + val factory = KeyFactory.getInstance(keyAlgo) + val pubKey = factory.generatePublic(X509EncodedKeySpec(publicKey)) + val sig = Signature.getInstance(sigAlgo) + sig.initVerify(pubKey) + sig.update(data) + return sig.verify(signature) +} internal fun pluginDigestHex(algorithm: String, data: String): String { - val normalized = algorithm.uppercase() - val digest = MessageDigest.getInstance(normalized).digest(data.encodeToByteArray()) + val digest = pluginDigest(algorithm, data.encodeToByteArray()) return digest.joinToString(separator = "") { byte -> byte.toUByte().toString(16).padStart(2, '0') } diff --git a/composeApp/src/fullCommonMain/kotlin/com/nuvio/app/features/plugins/runtime/PluginRuntime.kt b/composeApp/src/fullCommonMain/kotlin/com/nuvio/app/features/plugins/runtime/PluginRuntime.kt new file mode 100644 index 00000000..7349e11d --- /dev/null +++ b/composeApp/src/fullCommonMain/kotlin/com/nuvio/app/features/plugins/runtime/PluginRuntime.kt @@ -0,0 +1,175 @@ +package com.nuvio.app.features.plugins.runtime + +import com.nuvio.app.features.plugins.PluginRuntimeResult +import com.nuvio.app.features.plugins.runtime.crypto.CryptoBridge +import com.nuvio.app.features.plugins.runtime.dom.DomBridge +import com.nuvio.app.features.plugins.runtime.host.HostApiRegistry +import com.nuvio.app.features.plugins.runtime.host.HostFunctions +import com.nuvio.app.features.plugins.runtime.js.JsBindings +import com.nuvio.app.features.plugins.runtime.js.JsRuntime +import com.nuvio.app.features.plugins.runtime.network.FetchBridge +import com.nuvio.app.features.plugins.runtime.wasm.WasmBridge +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import kotlinx.coroutines.withTimeout +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonArray +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonNull +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.contentOrNull +import kotlinx.serialization.json.intOrNull +import kotlinx.serialization.json.jsonPrimitive + +private const val PLUGIN_TIMEOUT_MS = 60_000L + +internal object PluginRuntime { + private val json = Json { ignoreUnknownKeys = true } + + suspend fun executePlugin( + code: String, + tmdbId: String, + mediaType: String, + season: Int?, + episode: Int?, + scraperId: String, + scraperSettings: Map = emptyMap(), + ): List = withContext(Dispatchers.Default) { + withTimeout(PLUGIN_TIMEOUT_MS) { + executePluginInternal( + code = code, + tmdbId = tmdbId, + mediaType = mediaType, + season = season, + episode = episode, + scraperId = scraperId, + scraperSettings = scraperSettings, + ) + } + } + + private suspend fun executePluginInternal( + code: String, + tmdbId: String, + mediaType: String, + season: Int?, + episode: Int?, + scraperId: String, + scraperSettings: Map, + ): List { + val jsRuntime = JsRuntime() + var resultJson = "[]" + + val domBridge = DomBridge() + val hostRegistry = HostApiRegistry().apply { + addModule(HostFunctions(scraperId) { resultJson = it }) + addModule(FetchBridge()) + addModule(CryptoBridge()) + addModule(WasmBridge()) + addModule(domBridge) + } + + try { + jsRuntime.use { + hostRegistry.registerAll(this) + + val settingsJson = toJsonElement(scraperSettings).toString() + val polyfillCode = JsBindings.buildPolyfillCode(scraperId, settingsJson) + evaluate(polyfillCode) + + val wrappedCode = """ + var module = { exports: {} }; + var exports = module.exports; + (function() { + $code + })(); + """.trimIndent() + evaluate(wrappedCode) + + val seasonArg = season?.toString() ?: "undefined" + val episodeArg = episode?.toString() ?: "undefined" + val callCode = """ + (async function() { + try { + var getStreams = module.exports.getStreams || globalThis.getStreams; + if (!getStreams) { + console.error("getStreams function not found on module.exports or globalThis"); + __capture_result(JSON.stringify([])); + return; + } + var result = await getStreams("$tmdbId", "$mediaType", $seasonArg, $episodeArg); + __capture_result(JSON.stringify(result || [])); + } catch (e) { + console.error("getStreams error:", e && e.message ? e.message : e, e && e.stack ? e.stack : ""); + __capture_result(JSON.stringify([])); + } + })(); + """.trimIndent() + evaluate(callCode) + } + + return parseJsonResults(resultJson) + } finally { + domBridge.clear() + } + } + + private fun parseJsonResults(rawJson: String): List { + return runCatching { + val array = json.parseToJsonElement(rawJson) as? JsonArray ?: return emptyList() + array.mapNotNull { element -> + val item = element as? JsonObject ?: return@mapNotNull null + val url = when (val urlValue = item["url"]) { + is JsonPrimitive -> urlValue.contentOrNull?.takeIf { it.isNotBlank() } + is JsonObject -> urlValue["url"]?.jsonPrimitive?.contentOrNull?.takeIf { it.isNotBlank() } + else -> null + } ?: return@mapNotNull null + + val headers = (item["headers"] as? JsonObject) + ?.mapNotNull { (key, value) -> + value.jsonPrimitive.contentOrNull?.let { key to it } + } + ?.toMap() + ?.takeIf { it.isNotEmpty() } + + PluginRuntimeResult( + title = item.stringOrNull("title") ?: item.stringOrNull("name") ?: "Unknown", + name = item.stringOrNull("name"), + url = url, + quality = item.stringOrNull("quality"), + size = item.stringOrNull("size"), + language = item.stringOrNull("language"), + provider = item.stringOrNull("provider"), + type = item.stringOrNull("type"), + seeders = item["seeders"]?.jsonPrimitive?.intOrNull, + peers = item["peers"]?.jsonPrimitive?.intOrNull, + infoHash = item.stringOrNull("infoHash"), + headers = headers, + ) + }.filter { it.url.isNotBlank() } + }.getOrElse { emptyList() } + } + + private fun JsonObject.stringOrNull(key: String): String? = + this[key]?.jsonPrimitive?.contentOrNull?.takeIf { it.isNotBlank() && !it.contains("[object") } + + private fun toJsonElement(value: Any?): JsonElement = when (value) { + null -> JsonNull + is JsonElement -> value + is String -> JsonPrimitive(value) + is Boolean -> JsonPrimitive(value) + is Int -> JsonPrimitive(value) + is Long -> JsonPrimitive(value) + is Float -> JsonPrimitive(value) + is Double -> JsonPrimitive(value) + is Number -> JsonPrimitive(value.toDouble()) + is Map<*, *> -> JsonObject( + value.entries + .filter { it.key is String } + .associate { (it.key as String) to toJsonElement(it.value) }, + ) + is Iterable<*> -> JsonArray(value.map(::toJsonElement)) + else -> JsonPrimitive(value.toString()) + } +} diff --git a/composeApp/src/fullCommonMain/kotlin/com/nuvio/app/features/plugins/runtime/crypto/CryptoBridge.kt b/composeApp/src/fullCommonMain/kotlin/com/nuvio/app/features/plugins/runtime/crypto/CryptoBridge.kt new file mode 100644 index 00000000..0cbe87ce --- /dev/null +++ b/composeApp/src/fullCommonMain/kotlin/com/nuvio/app/features/plugins/runtime/crypto/CryptoBridge.kt @@ -0,0 +1,136 @@ +package com.nuvio.app.features.plugins.runtime.crypto + +import com.dokar.quickjs.QuickJs +import com.dokar.quickjs.binding.function +import com.nuvio.app.features.plugins.runtime.host.HostModule +import com.nuvio.app.features.plugins.pluginDigestHex +import com.nuvio.app.features.plugins.pluginHmacHex +import com.nuvio.app.features.plugins.pluginBase64Encode +import com.nuvio.app.features.plugins.pluginBase64Decode +import com.nuvio.app.features.plugins.pluginUtf8ToHex +import com.nuvio.app.features.plugins.pluginHexToUtf8 +import com.nuvio.app.features.plugins.pluginGetRandomValues +import com.nuvio.app.features.plugins.pluginDigest +import com.nuvio.app.features.plugins.pluginPbkdf2 +import com.nuvio.app.features.plugins.pluginAesDecrypt +import com.nuvio.app.features.plugins.pluginAesEncrypt +import com.nuvio.app.features.plugins.pluginSign +import com.nuvio.app.features.plugins.pluginVerify + +internal class CryptoBridge : HostModule { + override fun register(runtime: QuickJs) { + // --- Binary-Safe Bridges (New) --- + + runtime.function("__crypto_get_random_values") { args -> + val length = (args.getOrNull(0) as? Number)?.toInt() ?: 0 + runCatching { + pluginGetRandomValues(length) + }.getOrElse { ByteArray(0) } + } + + runtime.function("__crypto_digest_raw") { args -> + val algorithm = args.getOrNull(0)?.toString() ?: "SHA256" + val data = args.getOrNull(1) as? ByteArray ?: ByteArray(0) + runCatching { + pluginDigest(algorithm, data) + }.getOrElse { ByteArray(0) } + } + + runtime.function("__crypto_pbkdf2_raw") { args -> + val password = args.getOrNull(0) as? ByteArray ?: ByteArray(0) + val salt = args.getOrNull(1) as? ByteArray ?: ByteArray(0) + val iterations = (args.getOrNull(2) as? Number)?.toInt() ?: 1000 + val keySizeBits = (args.getOrNull(3) as? Number)?.toInt() ?: 256 + val algorithm = args.getOrNull(4)?.toString() ?: "SHA256" + runCatching { + pluginPbkdf2(password, salt, iterations, keySizeBits, algorithm) + }.getOrElse { ByteArray(0) } + } + + runtime.function("__crypto_aes_encrypt_raw") { args -> + val mode = args.getOrNull(0)?.toString() ?: "AES-CBC" + val key = args.getOrNull(1) as? ByteArray ?: ByteArray(0) + val iv = args.getOrNull(2) as? ByteArray ?: ByteArray(0) + val data = args.getOrNull(3) as? ByteArray ?: ByteArray(0) + runCatching { + pluginAesEncrypt(mode, key, iv, data) + }.getOrElse { ByteArray(0) } + } + + runtime.function("__crypto_aes_decrypt_raw") { args -> + val mode = args.getOrNull(0)?.toString() ?: "AES-CBC" + val key = args.getOrNull(1) as? ByteArray ?: ByteArray(0) + val iv = args.getOrNull(2) as? ByteArray ?: ByteArray(0) + val data = args.getOrNull(3) as? ByteArray ?: ByteArray(0) + runCatching { + pluginAesDecrypt(mode, key, iv, data) + }.getOrElse { ByteArray(0) } + } + + runtime.function("__crypto_sign_raw") { args -> + val algorithm = args.getOrNull(0)?.toString() ?: "" + val privateKey = args.getOrNull(1) as? ByteArray ?: ByteArray(0) + val data = args.getOrNull(2) as? ByteArray ?: ByteArray(0) + runCatching { + pluginSign(algorithm, privateKey, data) + }.getOrElse { ByteArray(0) } + } + + runtime.function("__crypto_verify_raw") { args -> + val algorithm = args.getOrNull(0)?.toString() ?: "" + val publicKey = args.getOrNull(1) as? ByteArray ?: ByteArray(0) + val signature = args.getOrNull(2) as? ByteArray ?: ByteArray(0) + val data = args.getOrNull(3) as? ByteArray ?: ByteArray(0) + runCatching { + pluginVerify(algorithm, publicKey, signature, data) + }.getOrDefault(false) + } + + // --- Legacy Hex/String Bridges (Backward Compatibility) --- + + runtime.function("__crypto_digest_hex") { args -> + val algorithm = args.getOrNull(0)?.toString() ?: "SHA256" + val data = args.getOrNull(1)?.toString() ?: "" + runCatching { + pluginDigestHex(algorithm, data) + }.getOrDefault("") + } + + runtime.function("__crypto_hmac_hex") { args -> + val algorithm = args.getOrNull(0)?.toString() ?: "SHA256" + val key = args.getOrNull(1)?.toString() ?: "" + val data = args.getOrNull(2)?.toString() ?: "" + runCatching { + pluginHmacHex(algorithm, key, data) + }.getOrDefault("") + } + + runtime.function("__crypto_base64_encode") { args -> + val data = args.getOrNull(0)?.toString() ?: "" + runCatching { + pluginBase64Encode(data) + }.getOrDefault("") + } + + runtime.function("__crypto_base64_decode") { args -> + val data = args.getOrNull(0)?.toString() ?: "" + runCatching { + pluginBase64Decode(data) + }.getOrDefault("") + } + + runtime.function("__crypto_utf8_to_hex") { args -> + val data = args.getOrNull(0)?.toString() ?: "" + runCatching { + pluginUtf8ToHex(data) + }.getOrDefault("") + } + + runtime.function("__crypto_hex_to_utf8") { args -> + val data = args.getOrNull(0)?.toString() ?: "" + runCatching { + pluginHexToUtf8(data) + }.getOrDefault("") + } + } +} diff --git a/composeApp/src/fullCommonMain/kotlin/com/nuvio/app/features/plugins/runtime/dom/DomBridge.kt b/composeApp/src/fullCommonMain/kotlin/com/nuvio/app/features/plugins/runtime/dom/DomBridge.kt new file mode 100644 index 00000000..0ada370e --- /dev/null +++ b/composeApp/src/fullCommonMain/kotlin/com/nuvio/app/features/plugins/runtime/dom/DomBridge.kt @@ -0,0 +1,118 @@ +package com.nuvio.app.features.plugins.runtime.dom + +import com.dokar.quickjs.QuickJs +import com.dokar.quickjs.binding.function +import com.fleeksoft.ksoup.Ksoup +import com.fleeksoft.ksoup.nodes.Document +import com.fleeksoft.ksoup.nodes.Element +import com.fleeksoft.ksoup.select.Elements +import com.nuvio.app.features.plugins.runtime.host.HostModule +import kotlin.random.Random + +internal class DomBridge : HostModule { + private val documentCache = mutableMapOf() + private val elementCache = mutableMapOf() + private var idCounter = 0 + private val containsRegex = Regex(""":contains\([\"']([^\"']+)[\"']\)""") + + override fun register(runtime: QuickJs) { + runtime.function("__cheerio_load") { args -> + val html = args.getOrNull(0)?.toString() ?: "" + val docId = "doc_${idCounter++}_${Random.nextInt(0, Int.MAX_VALUE)}" + documentCache[docId] = Ksoup.parse(html) + docId + } + + runtime.function("__cheerio_select") { args -> + val docId = args.getOrNull(0)?.toString() ?: "" + var selector = args.getOrNull(1)?.toString() ?: "" + val doc = documentCache[docId] ?: return@function "[]" + try { + selector = selector.replace(containsRegex, ":contains($1)") + val elements = if (selector.isEmpty()) Elements() else doc.select(selector) + val ids = elements.mapIndexed { index, el -> + val id = "$docId:$index:${el.hashCode()}" + elementCache[id] = el + id + } + "[" + ids.joinToString(",") { "\"${it.replace("\"", "\\\"")}\"" } + "]" + } catch (_: Exception) { + "[]" + } + } + + runtime.function("__cheerio_find") { args -> + val docId = args.getOrNull(0)?.toString() ?: "" + val elementId = args.getOrNull(1)?.toString() ?: "" + var selector = args.getOrNull(2)?.toString() ?: "" + val element = elementCache[elementId] ?: return@function "[]" + try { + selector = selector.replace(containsRegex, ":contains($1)") + val elements = element.select(selector) + val ids = elements.mapIndexed { index, el -> + val id = "$docId:find:$index:${el.hashCode()}" + elementCache[id] = el + id + } + "[" + ids.joinToString(",") { "\"${it.replace("\"", "\\\"")}\"" } + "]" + } catch (_: Exception) { + "[]" + } + } + + runtime.function("__cheerio_text") { args -> + val elementIds = args.getOrNull(1)?.toString() ?: "" + elementIds.split(",") + .filter { it.isNotEmpty() } + .mapNotNull { elementCache[it]?.text() } + .joinToString(" ") + } + + runtime.function("__cheerio_html") { args -> + val docId = args.getOrNull(0)?.toString() ?: "" + val elementId = args.getOrNull(1)?.toString() ?: "" + if (elementId.isEmpty()) { + documentCache[docId]?.html() ?: "" + } else { + elementCache[elementId]?.html() ?: "" + } + } + + runtime.function("__cheerio_inner_html") { args -> + val elementId = args.getOrNull(1)?.toString() ?: "" + elementCache[elementId]?.html() ?: "" + } + + runtime.function("__cheerio_attr") { args -> + val elementId = args.getOrNull(1)?.toString() ?: "" + val attrName = args.getOrNull(2)?.toString() ?: "" + val value = elementCache[elementId]?.attr(attrName) + if (value.isNullOrEmpty()) "__UNDEFINED__" else value + } + + runtime.function("__cheerio_next") { args -> + val docId = args.getOrNull(0)?.toString() ?: "" + val elementId = args.getOrNull(1)?.toString() ?: "" + val element = elementCache[elementId] ?: return@function "__NONE__" + val next = element.nextElementSibling() ?: return@function "__NONE__" + val nextId = "$docId:next:${next.hashCode()}" + elementCache[nextId] = next + nextId + } + + runtime.function("__cheerio_prev") { args -> + val docId = args.getOrNull(0)?.toString() ?: "" + val elementId = args.getOrNull(1)?.toString() ?: "" + val element = elementCache[elementId] ?: return@function "__NONE__" + val prev = element.previousElementSibling() ?: return@function "__NONE__" + val prevId = "$docId:prev:${prev.hashCode()}" + elementCache[prevId] = prev + prevId + } + } + + fun clear() { + documentCache.clear() + elementCache.clear() + } +} diff --git a/composeApp/src/fullCommonMain/kotlin/com/nuvio/app/features/plugins/runtime/host/HostApiRegistry.kt b/composeApp/src/fullCommonMain/kotlin/com/nuvio/app/features/plugins/runtime/host/HostApiRegistry.kt new file mode 100644 index 00000000..c002258a --- /dev/null +++ b/composeApp/src/fullCommonMain/kotlin/com/nuvio/app/features/plugins/runtime/host/HostApiRegistry.kt @@ -0,0 +1,19 @@ +package com.nuvio.app.features.plugins.runtime.host + +import com.dokar.quickjs.QuickJs + +internal interface HostModule { + fun register(runtime: QuickJs) +} + +internal class HostApiRegistry { + private val modules = mutableListOf() + + fun addModule(module: HostModule) { + modules.add(module) + } + + fun registerAll(runtime: QuickJs) { + modules.forEach { it.register(runtime) } + } +} diff --git a/composeApp/src/fullCommonMain/kotlin/com/nuvio/app/features/plugins/runtime/host/HostFunctions.kt b/composeApp/src/fullCommonMain/kotlin/com/nuvio/app/features/plugins/runtime/host/HostFunctions.kt new file mode 100644 index 00000000..552fbac6 --- /dev/null +++ b/composeApp/src/fullCommonMain/kotlin/com/nuvio/app/features/plugins/runtime/host/HostFunctions.kt @@ -0,0 +1,43 @@ +package com.nuvio.app.features.plugins.runtime.host + +import co.touchlab.kermit.Logger +import com.dokar.quickjs.QuickJs +import com.dokar.quickjs.binding.define +import com.dokar.quickjs.binding.function + +internal class HostFunctions( + private val scraperId: String, + private val onResult: (String) -> Unit +) : HostModule { + private val log = Logger.withTag("PluginRuntime") + + override fun register(runtime: QuickJs) { + runtime.define("console") { + function("log") { args -> + log.d { "Plugin:$scraperId ${args.joinToString(" ") { it?.toString() ?: "null" }}" } + null + } + function("error") { args -> + log.e { "Plugin:$scraperId ${args.joinToString(" ") { it?.toString() ?: "null" }}" } + null + } + function("warn") { args -> + log.w { "Plugin:$scraperId ${args.joinToString(" ") { it?.toString() ?: "null" }}" } + null + } + function("info") { args -> + log.i { "Plugin:$scraperId ${args.joinToString(" ") { it?.toString() ?: "null" }}" } + null + } + function("debug") { args -> + log.d { "Plugin:$scraperId ${args.joinToString(" ") { it?.toString() ?: "null" }}" } + null + } + } + + runtime.function("__capture_result") { args -> + onResult(args.getOrNull(0)?.toString() ?: "[]") + null + } + } +} diff --git a/composeApp/src/fullCommonMain/kotlin/com/nuvio/app/features/plugins/runtime/js/JsBindings.kt b/composeApp/src/fullCommonMain/kotlin/com/nuvio/app/features/plugins/runtime/js/JsBindings.kt new file mode 100644 index 00000000..0969bc70 --- /dev/null +++ b/composeApp/src/fullCommonMain/kotlin/com/nuvio/app/features/plugins/runtime/js/JsBindings.kt @@ -0,0 +1,595 @@ +package com.nuvio.app.features.plugins.runtime.js + +internal object JsBindings { + fun buildPolyfillCode(scraperId: String, settingsJson: String): String { + return """ + globalThis.SCRAPER_ID = "$scraperId"; + globalThis.SCRAPER_SETTINGS = $settingsJson; + if (typeof globalThis.global === 'undefined') globalThis.global = globalThis; + if (typeof globalThis.window === 'undefined') globalThis.window = globalThis; + if (typeof globalThis.self === 'undefined') globalThis.self = globalThis; + + ${fetchPolyfill()} + ${abortControllerPolyfill()} + ${base64Polyfill()} + ${urlPolyfill()} + ${cryptoPolyfill()} + ${cheerioPolyfill()} + ${requirePolyfill()} + ${arrayPolyfill()} + ${objectPolyfill()} + ${stringPolyfill()} + """.trimIndent() + } + + private fun fetchPolyfill() = """ + var fetch = async function(url, options) { + options = options || {}; + var method = (options.method || 'GET').toUpperCase(); + var headers = options.headers || {}; + var body = options.body || ''; + var followRedirects = options.redirect !== 'manual'; + var result = __native_fetch(url, method, JSON.stringify(headers), body, followRedirects); + var parsed = JSON.parse(result); + return { + ok: parsed.ok, + status: parsed.status, + statusText: parsed.statusText, + url: parsed.url, + headers: { + get: function(name) { + return parsed.headers[name.toLowerCase()] || null; + } + }, + text: function() { return Promise.resolve(parsed.body); }, + json: function() { + try { + if (parsed.body === null || parsed.body === undefined || parsed.body === '') { + return Promise.resolve(null); + } + return Promise.resolve(JSON.parse(parsed.body)); + } catch (e) { + return Promise.resolve(null); + } + } + }; + }; + """.trimIndent() + + private fun abortControllerPolyfill() = """ + if (typeof AbortSignal === 'undefined') { + var AbortSignal = function() { this.aborted = false; this.reason = undefined; this._listeners = []; }; + AbortSignal.prototype.addEventListener = function(type, listener) { + if (type !== 'abort' || typeof listener !== 'function') return; + this._listeners.push(listener); + }; + AbortSignal.prototype.removeEventListener = function(type, listener) { + if (type !== 'abort') return; + this._listeners = this._listeners.filter(function(l) { return l !== listener; }); + }; + AbortSignal.prototype.dispatchEvent = function(event) { + if (!event || event.type !== 'abort') return true; + for (var i = 0; i < this._listeners.length; i++) { + try { this._listeners[i].call(this, event); } catch (e) {} + } + return true; + }; + globalThis.AbortSignal = AbortSignal; + } + + if (typeof AbortController === 'undefined') { + var AbortController = function() { this.signal = new AbortSignal(); }; + AbortController.prototype.abort = function(reason) { + if (this.signal.aborted) return; + this.signal.aborted = true; + this.signal.reason = reason; + this.signal.dispatchEvent({ type: 'abort' }); + }; + globalThis.AbortController = AbortController; + } + """.trimIndent() + + private fun base64Polyfill() = """ + if (typeof atob === 'undefined') { + globalThis.atob = function(input) { + var chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/='; + var str = String(input).replace(/=+$/, ''); + if (str.length % 4 === 1) throw new Error('InvalidCharacterError'); + var output = ''; + var bc = 0, bs, buffer, idx = 0; + while ((buffer = str.charAt(idx++))) { + buffer = chars.indexOf(buffer); + if (buffer === -1) continue; + bs = bc % 4 ? bs * 64 + buffer : buffer; + if (bc++ % 4) output += String.fromCharCode(255 & (bs >> ((-2 * bc) & 6))); + } + return output; + }; + } + + if (typeof btoa === 'undefined') { + globalThis.btoa = function(input) { + var chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/='; + var str = String(input); + var output = ''; + for (var block, charCode, idx = 0, map = chars; + str.charAt(idx | 0) || (map = '=', idx % 1); + output += map.charAt(63 & (block >> (8 - (idx % 1) * 8)))) { + charCode = str.charCodeAt(idx += 3 / 4); + if (charCode > 0xFF) throw new Error('InvalidCharacterError'); + block = (block << 8) | charCode; + } + return output; + }; + } + """.trimIndent() + + private fun urlPolyfill() = """ + var __native_parse_url = typeof __parse_url !== 'undefined' ? __parse_url : function(u) { return JSON.stringify({ protocol: '', host: '', hostname: '', port: '', pathname: '/', search: '', hash: '' }); }; + var URL = function(urlString, base) { + var fullUrl = urlString; + if (base && !/^https?:\/\//i.test(urlString)) { + var b = typeof base === 'string' ? base : base.href; + if (urlString.charAt(0) === '/') { + var m = b.match(/^(https?:\/\/[^\/]+)/); + fullUrl = m ? m[1] + urlString : urlString; + } else { + fullUrl = b.replace(/\/[^\/]*$/, '/') + urlString; + } + } + var parsed = __native_parse_url(fullUrl); + var data = JSON.parse(parsed); + this.href = fullUrl; + this.protocol = data.protocol; + this.host = data.host; + this.hostname = data.hostname; + this.port = data.port; + this.pathname = data.pathname; + this.search = data.search; + this.hash = data.hash; + this.origin = data.protocol + '//' + data.host; + this.searchParams = new URLSearchParams(data.search || ''); + }; + URL.prototype.toString = function() { return this.href; }; + + var URLSearchParams = function(init) { + this._params = {}; + var self = this; + if (init && typeof init === 'object' && !Array.isArray(init)) { + Object.keys(init).forEach(function(key) { self._params[key] = String(init[key]); }); + } else if (typeof init === 'string') { + init.replace(/^\?/, '').split('&').forEach(function(pair) { + var parts = pair.split('='); + if (parts[0]) self._params[decodeURIComponent(parts[0])] = decodeURIComponent(parts[1] || ''); + }); + } + }; + URLSearchParams.prototype.toString = function() { + var self = this; + return Object.keys(this._params).map(function(key) { + return encodeURIComponent(key) + '=' + encodeURIComponent(self._params[key]); + }).join('&'); + }; + URLSearchParams.prototype.get = function(key) { return this._params.hasOwnProperty(key) ? this._params[key] : null; }; + URLSearchParams.prototype.set = function(key, value) { this._params[key] = String(value); }; + URLSearchParams.prototype.append = function(key, value) { this._params[key] = String(value); }; + URLSearchParams.prototype.has = function(key) { return this._params.hasOwnProperty(key); }; + URLSearchParams.prototype.delete = function(key) { delete this._params[key]; }; + URLSearchParams.prototype.keys = function() { return Object.keys(this._params); }; + URLSearchParams.prototype.values = function() { + var self = this; + return Object.keys(this._params).map(function(k) { return self._params[k]; }); + }; + URLSearchParams.prototype.entries = function() { + var self = this; + return Object.keys(this._params).map(function(k) { return [k, self._params[k]]; }); + }; + URLSearchParams.prototype.forEach = function(callback) { + var self = this; + Object.keys(this._params).forEach(function(key) { callback(self._params[key], key, self); }); + }; + URLSearchParams.prototype.getAll = function(key) { + return this._params.hasOwnProperty(key) ? [this._params[key]] : []; + }; + URLSearchParams.prototype.sort = function() { + var sorted = {}; + var self = this; + Object.keys(this._params).sort().forEach(function(k) { sorted[k] = self._params[k]; }); + this._params = sorted; + }; + """.trimIndent() + + private fun cryptoPolyfill() = """ + function __hexToWords(hex) { + var words = []; + for (var i = 0; i < hex.length; i += 8) { + var chunk = hex.substring(i, i + 8); + while (chunk.length < 8) chunk += '0'; + words.push(parseInt(chunk, 16) | 0); + } + return words; + } + + function __wordsToHex(words, sigBytes) { + var hex = ''; + for (var i = 0; i < sigBytes; i++) { + var word = words[i >>> 2] || 0; + var byte = (word >>> (24 - (i % 4) * 8)) & 0xff; + var part = byte.toString(16); + if (part.length < 2) part = '0' + part; + hex += part; + } + return hex; + } + + function __wordArrayToHex(value) { + if (!value) return ''; + if (typeof value.__hex === 'string') return value.__hex.toLowerCase(); + if (Array.isArray(value.words) && typeof value.sigBytes === 'number') { + return __wordsToHex(value.words, value.sigBytes); + } + return typeof __crypto_utf8_to_hex !== 'undefined' ? __crypto_utf8_to_hex(String(value)) : ''; + } + + function __buildWordArray(hex, utf8Override) { + var normalizedHex = (hex || '').toLowerCase(); + if (normalizedHex.length % 2 !== 0) normalizedHex = '0' + normalizedHex; + var wordArray = { + __hex: normalizedHex, + __utf8: utf8Override !== undefined ? utf8Override : (typeof __crypto_hex_to_utf8 !== 'undefined' ? __crypto_hex_to_utf8(normalizedHex) : ''), + sigBytes: normalizedHex.length / 2, + words: __hexToWords(normalizedHex), + toString: function(encoder) { + if (!encoder || encoder === CryptoJS.enc.Hex) return this.__hex; + if (encoder === CryptoJS.enc.Utf8) return this.__utf8; + if (encoder === CryptoJS.enc.Base64) return typeof __crypto_base64_encode !== 'undefined' ? __crypto_base64_encode(this.__utf8) : ''; + return this.__hex; + }, + clamp: function() { return this; }, + concat: function(other) { + var otherHex = __wordArrayToHex(other); + this.__hex += otherHex; + this.__utf8 = typeof __crypto_hex_to_utf8 !== 'undefined' ? __crypto_hex_to_utf8(this.__hex) : ''; + this.sigBytes = this.__hex.length / 2; + this.words = __hexToWords(this.__hex); + return this; + } + }; + return wordArray; + } + + function __wordArrayFromHex(hex) { return __buildWordArray(hex, undefined); } + function __wordArrayFromUtf8(text) { + var utf8 = text == null ? '' : String(text); + var hex = typeof __crypto_utf8_to_hex !== 'undefined' ? __crypto_utf8_to_hex(utf8) : ''; + return __buildWordArray(hex, utf8); + } + function __wordArrayFromBase64(base64) { + var utf8 = typeof __crypto_base64_decode !== 'undefined' ? __crypto_base64_decode(base64 || '') : ''; + return __wordArrayFromUtf8(utf8); + } + + function __normalizeWordArrayInput(value) { + if (value && typeof value === 'object' && typeof value.__utf8 === 'string') return value.__utf8; + if (value && typeof value === 'object' && typeof value.__hex === 'string') return typeof __crypto_hex_to_utf8 !== 'undefined' ? __crypto_hex_to_utf8(value.__hex) : ''; + if (value && typeof value === 'object' && Array.isArray(value.words) && typeof value.sigBytes === 'number') { + return typeof __crypto_hex_to_utf8 !== 'undefined' ? __crypto_hex_to_utf8(__wordsToHex(value.words, value.sigBytes)) : ''; + } + if (value == null) return ''; + return String(value); + } + + function __bufferToUint8(data) { + if (data instanceof Uint8Array) return data; + if (data instanceof ArrayBuffer) return new Uint8Array(data); + if (typeof data === 'string') return new TextEncoder().encode(data); + return new Uint8Array(0); + } + + var CryptoJS = { + enc: { + Hex: { stringify: function(wa) { return __wordArrayToHex(wa); }, parse: function(s) { return __wordArrayFromHex(s); } }, + Utf8: { stringify: function(wa) { return wa.toString(CryptoJS.enc.Utf8); }, parse: function(s) { return __wordArrayFromUtf8(s); } }, + Base64: { stringify: function(wa) { return wa.toString(CryptoJS.enc.Base64); }, parse: function(s) { return __wordArrayFromBase64(s); } } + }, + lib: { WordArray: { create: function(words, sigBytes) { return __buildWordArray(__wordsToHex(words, sigBytes), undefined); } } }, + mode: { CBC: 'AES-CBC', GCM: 'AES-GCM', ECB: 'AES-ECB' }, + pad: { Pkcs7: 'Pkcs7', NoPadding: 'NoPadding' }, + algo: { SHA256: 'SHA256' }, + MD5: function(m) { return __wordArrayFromHex(__crypto_digest_hex('MD5', __normalizeWordArrayInput(m))); }, + SHA1: function(m) { return __wordArrayFromHex(__crypto_digest_hex('SHA1', __normalizeWordArrayInput(m))); }, + SHA256: function(m) { return __wordArrayFromHex(__crypto_digest_hex('SHA256', __normalizeWordArrayInput(m))); }, + SHA512: function(m) { return __wordArrayFromHex(__crypto_digest_hex('SHA512', __normalizeWordArrayInput(m))); }, + PBKDF2: function(pass, salt, options) { + var pBytes = __bufferToUint8(__normalizeWordArrayInput(pass)); + var sBytes = __bufferToUint8(__normalizeWordArrayInput(salt)); + var iter = options.iterations || 1000; + var kSize = options.keySize || (256/32); + var algo = options.hasher === CryptoJS.algo.SHA256 ? 'SHA256' : 'SHA1'; + var resBytes = typeof __crypto_pbkdf2_raw !== 'undefined' ? __crypto_pbkdf2_raw(pBytes, sBytes, iter, kSize * 32, algo) : new Uint8Array(0); + return __wordArrayFromHex(__wordsToHex(Array.from(resBytes), resBytes.length)); + }, + AES: { + encrypt: function(message, key, options) { + var data = __bufferToUint8(__normalizeWordArrayInput(message)); + var kBytes = __bufferToUint8(__wordArrayToHex(key)); + var ivBytes = __bufferToUint8(__wordArrayToHex(options.iv || '')); + var mode = options.mode || 'AES-CBC'; + var resBytes = typeof __crypto_aes_encrypt_raw !== 'undefined' ? __crypto_aes_encrypt_raw(mode, kBytes, ivBytes, data) : new Uint8Array(0); + var wa = __wordArrayFromHex(__wordsToHex(Array.from(resBytes), resBytes.length)); + return { + ciphertext: wa, + toString: function() { return wa.toString(CryptoJS.enc.Base64); } + }; + }, + decrypt: function(cipher, key, options) { + var data = typeof cipher === 'string' ? __bufferToUint8(typeof __crypto_base64_decode !== 'undefined' ? __crypto_base64_decode(cipher) : '') : (cipher.ciphertext ? __bufferToUint8(typeof __crypto_base64_decode !== 'undefined' ? __crypto_base64_decode(cipher.ciphertext.toString(CryptoJS.enc.Base64)) : '') : __bufferToUint8(cipher)); + var kBytes = __bufferToUint8(__wordArrayToHex(key)); + var ivBytes = __bufferToUint8(__wordArrayToHex(options.iv || '')); + var mode = options.mode || 'AES-CBC'; + var resBytes = typeof __crypto_aes_decrypt_raw !== 'undefined' ? __crypto_aes_decrypt_raw(mode, kBytes, ivBytes, data) : new Uint8Array(0); + var plain = new TextDecoder().decode(resBytes); + return { toString: function(enc) { return plain; } }; + } + } + }; + globalThis.CryptoJS = CryptoJS; + + globalThis.crypto = { + subtle: { + digest: async function(algo, data) { + var bytes = __bufferToUint8(data); + var res = typeof __crypto_digest_raw !== 'undefined' ? __crypto_digest_raw(algo.name || algo, bytes) : new Uint8Array(0); + return res.buffer; + }, + importKey: async function(fmt, data, algo, ext, use) { return { _raw: data, _algo: algo }; }, + deriveBits: async function(params, key, len) { + var pBytes = __bufferToUint8(key._raw); + var sBytes = __bufferToUint8(params.salt); + var res = typeof __crypto_pbkdf2_raw !== 'undefined' ? __crypto_pbkdf2_raw(pBytes, sBytes, params.iterations, len, params.hash) : new Uint8Array(0); + return res.buffer; + }, + encrypt: async function(params, key, data) { + var kBytes = __bufferToUint8(key._raw); + var ivBytes = __bufferToUint8(params.iv || ''); + var dBytes = __bufferToUint8(data); + var res = typeof __crypto_aes_encrypt_raw !== 'undefined' ? __crypto_aes_encrypt_raw(params.name, kBytes, ivBytes, dBytes) : new Uint8Array(0); + return res.buffer; + }, + decrypt: async function(params, key, data) { + var kBytes = __bufferToUint8(key._raw); + var ivBytes = __bufferToUint8(params.iv || ''); + var dBytes = __bufferToUint8(data); + var res = typeof __crypto_aes_decrypt_raw !== 'undefined' ? __crypto_aes_decrypt_raw(params.name, kBytes, ivBytes, dBytes) : new Uint8Array(0); + return res.buffer; + }, + sign: async function(algo, key, data) { + var algoName = typeof algo === 'string' ? algo : (algo.name || ''); + var kBytes = __bufferToUint8(key._raw); + var dBytes = __bufferToUint8(data); + var res = typeof __crypto_sign_raw !== 'undefined' ? __crypto_sign_raw(algoName, kBytes, dBytes) : new Uint8Array(0); + return res.buffer; + }, + verify: async function(algo, key, sig, data) { + var algoName = typeof algo === 'string' ? algo : (algo.name || ''); + var kBytes = __bufferToUint8(key._raw); + var sBytes = __bufferToUint8(sig); + var dBytes = __bufferToUint8(data); + return typeof __crypto_verify_raw !== 'undefined' ? __crypto_verify_raw(algoName, kBytes, sBytes, dBytes) : false; + } + }, + getRandomValues: function(arr) { + + var bytes = typeof __crypto_get_random_values !== 'undefined' ? __crypto_get_random_values(arr.length) : new Uint8Array(arr.length); + for (var i = 0; i < arr.length; i++) arr[i] = bytes[i]; + return arr; + } + }; + + // WebAssembly placeholder + globalThis.WebAssembly = { + instantiate: async function(bufferSource, importObject) { + console.warn("WebAssembly.instantiate called (placeholder)"); + return { instance: { exports: {} }, module: {} }; + } + }; + """.trimIndent() + + private fun cheerioPolyfill() = """ + var cheerio = { + load: function(html) { + var docId = __cheerio_load(html); + var $ = function(selector, context) { + if (selector && selector._elementIds) return selector; + if (context && context._elementIds && context._elementIds.length > 0) { + var allIds = []; + for (var i = 0; i < context._elementIds.length; i++) { + var childIdsJson = __cheerio_find(docId, context._elementIds[i], selector); + var childIds = JSON.parse(childIdsJson); + allIds = allIds.concat(childIds); + } + return createCheerioWrapperFromIds(docId, allIds); + } + return createCheerioWrapper(docId, selector); + }; + $.html = function(el) { + if (el && el._elementIds && el._elementIds.length > 0) { + return __cheerio_html(docId, el._elementIds[0]); + } + return __cheerio_html(docId, ''); + }; + return $; + } + }; + + function createCheerioWrapper(docId, selector) { + var elementIds; + if (typeof selector === 'string') { + var idsJson = __cheerio_select(docId, selector); + elementIds = JSON.parse(idsJson); + } else { + elementIds = []; + } + return createCheerioWrapperFromIds(docId, elementIds); + } + + function createCheerioWrapperFromIds(docId, ids) { + var wrapper = { + _docId: docId, + _elementIds: ids, + length: ids.length, + each: function(callback) { + for (var i = 0; i < ids.length; i++) { + var elWrapper = createCheerioWrapperFromIds(docId, [ids[i]]); + callback.call(elWrapper, i, elWrapper); + } + return wrapper; + }, + find: function(sel) { + var allIds = []; + for (var i = 0; i < ids.length; i++) { + var childIdsJson = __cheerio_find(docId, ids[i], sel); + var childIds = JSON.parse(childIdsJson); + allIds = allIds.concat(childIds); + } + return createCheerioWrapperFromIds(docId, allIds); + }, + text: function() { + if (ids.length === 0) return ''; + return __cheerio_text(docId, ids.join(',')); + }, + html: function() { + if (ids.length === 0) return ''; + return __cheerio_inner_html(docId, ids[0]); + }, + attr: function(name) { + if (ids.length === 0) return undefined; + var val = __cheerio_attr(docId, ids[0], name); + return val === '__UNDEFINED__' ? undefined : val; + }, + first: function() { return createCheerioWrapperFromIds(docId, ids.length > 0 ? [ids[0]] : []); }, + last: function() { return createCheerioWrapperFromIds(docId, ids.length > 0 ? [ids[ids.length - 1]] : []); }, + next: function() { + var nextIds = []; + for (var i = 0; i < ids.length; i++) { + var nextId = __cheerio_next(docId, ids[i]); + if (nextId && nextId !== '__NONE__') nextIds.push(nextId); + } + return createCheerioWrapperFromIds(docId, nextIds); + }, + prev: function() { + var prevIds = []; + for (var i = 0; i < ids.length; i++) { + var prevId = __cheerio_prev(docId, ids[i]); + if (prevId && prevId !== '__NONE__') prevIds.push(prevId); + } + return createCheerioWrapperFromIds(docId, prevIds); + }, + eq: function(index) { + if (index >= 0 && index < ids.length) return createCheerioWrapperFromIds(docId, [ids[index]]); + return createCheerioWrapperFromIds(docId, []); + }, + get: function(index) { + if (typeof index === 'number') { + if (index >= 0 && index < ids.length) return createCheerioWrapperFromIds(docId, [ids[index]]); + return undefined; + } + return ids.map(function(id) { return createCheerioWrapperFromIds(docId, [id]); }); + }, + map: function(callback) { + var results = []; + for (var i = 0; i < ids.length; i++) { + var elWrapper = createCheerioWrapperFromIds(docId, [ids[i]]); + var result = callback.call(elWrapper, i, elWrapper); + if (result !== undefined && result !== null) results.push(result); + } + return { + length: results.length, + get: function(index) { return typeof index === 'number' ? results[index] : results; }, + toArray: function() { return results; } + }; + }, + filter: function(selectorOrCallback) { + if (typeof selectorOrCallback === 'function') { + var filteredIds = []; + for (var i = 0; i < ids.length; i++) { + var elWrapper = createCheerioWrapperFromIds(docId, [ids[i]]); + var result = selectorOrCallback.call(elWrapper, i, elWrapper); + if (result) filteredIds.push(ids[i]); + } + return createCheerioWrapperFromIds(docId, filteredIds); + } + return wrapper; + }, + children: function(sel) { return this.find(sel || '*'); }, + parent: function() { return createCheerioWrapperFromIds(docId, []); }, + toArray: function() { return ids.map(function(id) { return createCheerioWrapperFromIds(docId, [id]); }); } + }; + return wrapper; + } + """.trimIndent() + + private fun requirePolyfill() = """ + var require = function(moduleName) { + if (moduleName === 'cheerio' || moduleName === 'cheerio-without-node-native' || moduleName === 'react-native-cheerio') { + return cheerio; + } + if (moduleName === 'crypto-js') { + return CryptoJS; + } + throw new Error("Module '" + moduleName + "' is not available"); + }; + """.trimIndent() + + private fun arrayPolyfill() = """ + if (!Array.prototype.flat) { + Array.prototype.flat = function(depth) { + depth = depth === undefined ? 1 : Math.floor(depth); + if (depth < 1) return Array.prototype.slice.call(this); + return (function flatten(arr, d) { + return d > 0 + ? arr.reduce(function(acc, val) { return acc.concat(Array.isArray(val) ? flatten(val, d - 1) : val); }, []) + : arr.slice(); + })(this, depth); + }; + } + + if (!Array.prototype.flatMap) { + Array.prototype.flatMap = function(callback, thisArg) { return this.map(callback, thisArg).flat(); }; + } + """.trimIndent() + + private fun objectPolyfill() = """ + if (!Object.entries) { + Object.entries = function(obj) { + var result = []; + for (var key in obj) { + if (obj.hasOwnProperty(key)) result.push([key, obj[key]]); + } + return result; + }; + } + + if (!Object.fromEntries) { + Object.fromEntries = function(entries) { + var result = {}; + for (var i = 0; i < entries.length; i++) { + result[entries[i][0]] = entries[i][1]; + } + return result; + }; + } + """.trimIndent() + + private fun stringPolyfill() = """ + if (!String.prototype.replaceAll) { + String.prototype.replaceAll = function(search, replace) { + if (search instanceof RegExp) { + if (!search.global) throw new TypeError('replaceAll must be called with a global RegExp'); + return this.replace(search, replace); + } + return this.split(search).join(replace); + }; + } + """.trimIndent() +} diff --git a/composeApp/src/fullCommonMain/kotlin/com/nuvio/app/features/plugins/runtime/js/JsRuntime.kt b/composeApp/src/fullCommonMain/kotlin/com/nuvio/app/features/plugins/runtime/js/JsRuntime.kt new file mode 100644 index 00000000..704da56c --- /dev/null +++ b/composeApp/src/fullCommonMain/kotlin/com/nuvio/app/features/plugins/runtime/js/JsRuntime.kt @@ -0,0 +1,16 @@ +package com.nuvio.app.features.plugins.runtime.js + +import com.dokar.quickjs.QuickJs +import com.dokar.quickjs.quickJs +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.Dispatchers + +internal class JsRuntime( + private val dispatcher: CoroutineDispatcher = Dispatchers.Default +) { + suspend fun use(block: suspend QuickJs.() -> T): T { + return quickJs(dispatcher) { + block() + } + } +} diff --git a/composeApp/src/fullCommonMain/kotlin/com/nuvio/app/features/plugins/runtime/network/FetchBridge.kt b/composeApp/src/fullCommonMain/kotlin/com/nuvio/app/features/plugins/runtime/network/FetchBridge.kt new file mode 100644 index 00000000..5dd6d78b --- /dev/null +++ b/composeApp/src/fullCommonMain/kotlin/com/nuvio/app/features/plugins/runtime/network/FetchBridge.kt @@ -0,0 +1,103 @@ +package com.nuvio.app.features.plugins.runtime.network + +import co.touchlab.kermit.Logger +import com.dokar.quickjs.QuickJs +import com.dokar.quickjs.binding.function +import com.nuvio.app.features.addons.httpRequestRaw +import com.nuvio.app.features.plugins.runtime.host.HostModule +import kotlinx.coroutines.runBlocking +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.contentOrNull +import kotlinx.serialization.json.jsonPrimitive + +private const val MAX_FETCH_BODY_CHARS = 256 * 1024 +private const val MAX_FETCH_HEADER_VALUE_CHARS = 8 * 1024 +private const val FETCH_TRUNCATION_SUFFIX = "\n...[truncated]" + +internal class FetchBridge : HostModule { + private val log = Logger.withTag("PluginRuntime") + private val json = Json { ignoreUnknownKeys = true } + + override fun register(runtime: QuickJs) { + runtime.function("__native_fetch") { args -> + val url = args.getOrNull(0)?.toString() ?: "" + val method = args.getOrNull(1)?.toString() ?: "GET" + val headersJson = args.getOrNull(2)?.toString() ?: "{}" + val body = args.getOrNull(3)?.toString() ?: "" + val followRedirects = args.getOrNull(4) as? Boolean ?: true + try { + performNativeFetch(url, method, headersJson, body, followRedirects) + } catch (t: Throwable) { + log.e(t) { "Fetch bridge error for $method $url" } + JsonObject( + mapOf( + "ok" to JsonPrimitive(false), + "status" to JsonPrimitive(0), + "statusText" to JsonPrimitive(t.message ?: "Fetch failed"), + "url" to JsonPrimitive(url), + "body" to JsonPrimitive(""), + "headers" to JsonObject(emptyMap()), + ), + ).toString() + } + } + } + + private fun performNativeFetch( + url: String, + method: String, + headersJson: String, + body: String, + followRedirects: Boolean, + ): String { + val headers = parseHeaders(headersJson).toMutableMap() + if (!headers.containsKey("User-Agent")) { + headers["User-Agent"] = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36" + } + + val response = runBlocking { + httpRequestRaw( + method = method, + url = url, + headers = headers, + body = body, + followRedirects = followRedirects, + ) + } + + val responseHeaders = response.headers.mapValues { (_, value) -> + truncateString(value, MAX_FETCH_HEADER_VALUE_CHARS) + } + val result = JsonObject( + mapOf( + "ok" to JsonPrimitive(response.status in 200..299), + "status" to JsonPrimitive(response.status), + "statusText" to JsonPrimitive(response.statusText), + "url" to JsonPrimitive(response.url), + "body" to JsonPrimitive(truncateString(response.body, MAX_FETCH_BODY_CHARS)), + "headers" to JsonObject(responseHeaders.mapValues { JsonPrimitive(it.value) }), + ), + ) + return result.toString() + } + + private fun parseHeaders(headersJson: String): Map { + return runCatching { + val obj = json.parseToJsonElement(headersJson) as? JsonObject ?: JsonObject(emptyMap()) + obj.entries + .mapNotNull { (key, value) -> + value.jsonPrimitive.contentOrNull?.let { key to it } + } + .toMap() + }.getOrDefault(emptyMap()) + } + + private fun truncateString(value: String, maxChars: Int): String { + if (value.length <= maxChars) return value + val end = maxChars - FETCH_TRUNCATION_SUFFIX.length + if (end <= 0) return FETCH_TRUNCATION_SUFFIX.take(maxChars) + return value.substring(0, end) + FETCH_TRUNCATION_SUFFIX + } +} diff --git a/composeApp/src/fullCommonMain/kotlin/com/nuvio/app/features/plugins/runtime/wasm/WasmBridge.kt b/composeApp/src/fullCommonMain/kotlin/com/nuvio/app/features/plugins/runtime/wasm/WasmBridge.kt new file mode 100644 index 00000000..bc1e8d16 --- /dev/null +++ b/composeApp/src/fullCommonMain/kotlin/com/nuvio/app/features/plugins/runtime/wasm/WasmBridge.kt @@ -0,0 +1,16 @@ +package com.nuvio.app.features.plugins.runtime.wasm + +import com.dokar.quickjs.QuickJs +import com.nuvio.app.features.plugins.runtime.host.HostModule + +/** + * Lightweight WASM Helpers bridge. + * For now, this is a placeholder for running small WASM modules. + * In the future, this could integrate a lightweight WASM interpreter like Chasm or wasm-interp.js. + */ +internal class WasmBridge : HostModule { + override fun register(runtime: QuickJs) { + // Placeholder for WASM instantiation bridge + // runtime.function("__native_wasm_instantiate") { ... } + } +} diff --git a/composeApp/src/iosFull/kotlin/com/nuvio/app/features/plugins/PluginCrypto.ios.kt b/composeApp/src/iosFull/kotlin/com/nuvio/app/features/plugins/PluginCrypto.ios.kt index f581b27c..dc118695 100644 --- a/composeApp/src/iosFull/kotlin/com/nuvio/app/features/plugins/PluginCrypto.ios.kt +++ b/composeApp/src/iosFull/kotlin/com/nuvio/app/features/plugins/PluginCrypto.ios.kt @@ -2,6 +2,15 @@ package com.nuvio.app.features.plugins import kotlinx.cinterop.ExperimentalForeignApi import kotlinx.cinterop.refTo +import kotlinx.cinterop.addressOf +import kotlinx.cinterop.usePinned +import kotlinx.cinterop.reinterpret +import kotlinx.cinterop.UByteVar +import kotlinx.cinterop.ByteVar +import kotlinx.cinterop.alloc +import kotlinx.cinterop.memScoped +import kotlinx.cinterop.ptr +import kotlinx.cinterop.value import kotlin.io.encoding.Base64 import kotlin.io.encoding.ExperimentalEncodingApi import com.nuvio.app.features.plugins.cryptointerop.CC_MD5 @@ -17,6 +26,226 @@ import com.nuvio.app.features.plugins.cryptointerop.kCCHmacAlgMD5 import com.nuvio.app.features.plugins.cryptointerop.kCCHmacAlgSHA1 import com.nuvio.app.features.plugins.cryptointerop.kCCHmacAlgSHA256 import com.nuvio.app.features.plugins.cryptointerop.kCCHmacAlgSHA512 +import com.nuvio.app.features.plugins.cryptointerop.CCKeyDerivationPBKDF +import com.nuvio.app.features.plugins.cryptointerop.kCCPBKDF2 +import com.nuvio.app.features.plugins.cryptointerop.kCCPRFHmacAlgSHA1 +import com.nuvio.app.features.plugins.cryptointerop.kCCPRFHmacAlgSHA256 +import com.nuvio.app.features.plugins.cryptointerop.CCCrypt +import com.nuvio.app.features.plugins.cryptointerop.kCCDecrypt +import com.nuvio.app.features.plugins.cryptointerop.kCCAlgorithmAES +import com.nuvio.app.features.plugins.cryptointerop.kCCOptionECBMode +import com.nuvio.app.features.plugins.cryptointerop.kCCEncrypt +import com.nuvio.app.features.plugins.cryptointerop.kCCOptionPKCS7Padding +import com.nuvio.app.features.plugins.cryptointerop.kCCSuccess +import platform.Security.SecRandomCopyBytes +import platform.Security.kSecRandomDefault + +internal fun pluginGetRandomValues(length: Int): ByteArray { + val bytes = ByteArray(length) + @OptIn(ExperimentalForeignApi::class) + SecRandomCopyBytes(kSecRandomDefault, length.toULong(), bytes.refTo(0)) + return bytes +} + +@OptIn(ExperimentalForeignApi::class) +internal fun pluginDigest(algorithm: String, data: ByteArray): ByteArray { + val normalized = algorithm.uppercase() + val output = ByteArray( + when (normalized) { + "MD5" -> CC_MD5_DIGEST_LENGTH.toInt() + "SHA1" -> CC_SHA1_DIGEST_LENGTH.toInt() + "SHA256" -> CC_SHA256_DIGEST_LENGTH.toInt() + "SHA512" -> CC_SHA512_DIGEST_LENGTH.toInt() + else -> error("Unsupported digest algorithm: $algorithm") + }, + ) + + data.usePinned { pinnedData -> + output.usePinned { pinnedOutput -> + val dataPtr = if (data.isNotEmpty()) pinnedData.addressOf(0) else null + val outputPtr = pinnedOutput.addressOf(0).reinterpret() + + when (normalized) { + "MD5" -> CC_MD5(dataPtr, data.size.toUInt(), outputPtr) + "SHA1" -> CC_SHA1(dataPtr, data.size.toUInt(), outputPtr) + "SHA256" -> CC_SHA256(dataPtr, data.size.toUInt(), outputPtr) + "SHA512" -> CC_SHA512(dataPtr, data.size.toUInt(), outputPtr) + } + } + } + + return output +} + +@OptIn(ExperimentalForeignApi::class) +internal fun pluginPbkdf2( + password: ByteArray, + salt: ByteArray, + iterations: Int, + keySizeBits: Int, + algorithm: String, +): ByteArray { + val prf = when (algorithm.uppercase()) { + "SHA256" -> kCCPRFHmacAlgSHA256 + "SHA1" -> kCCPRFHmacAlgSHA1 + else -> kCCPRFHmacAlgSHA256 + } + + val derivedKeyLen = keySizeBits / 8 + val derivedKey = ByteArray(derivedKeyLen) + + password.usePinned { pinnedPassword -> + salt.usePinned { pinnedSalt -> + derivedKey.usePinned { pinnedDerivedKey -> + val passwordPtr = if (password.isNotEmpty()) pinnedPassword.addressOf(0).reinterpret() else null + val saltPtr = if (salt.isNotEmpty()) pinnedSalt.addressOf(0).reinterpret() else null + val derivedKeyPtr = pinnedDerivedKey.addressOf(0).reinterpret() + + val status = CCKeyDerivationPBKDF( + algorithm = kCCPBKDF2, + password = passwordPtr, + passwordLen = password.size.toULong(), + salt = saltPtr, + saltLen = salt.size.toULong(), + prf = prf, + rounds = iterations.toUInt(), + derivedKey = derivedKeyPtr, + derivedKeyLen = derivedKeyLen.toULong() + ) + + require(status == kCCSuccess) { "PBKDF2 failed with status: $status" } + } + } + } + + return derivedKey +} + +@OptIn(ExperimentalForeignApi::class) +internal fun pluginAesEncrypt( + mode: String, + key: ByteArray, + iv: ByteArray, + data: ByteArray, +): ByteArray { + val isGcm = mode.uppercase().contains("GCM") + if (isGcm) { + throw UnsupportedOperationException("AES-GCM Encrypt is not yet implemented on iOS") + } + val isEcb = mode.uppercase().contains("ECB") + + val dataOutAvailable = data.size + 16 // AES block size + val dataOut = ByteArray(dataOutAvailable) + + var finalData: ByteArray? = null + + memScoped { + val dataOutMoved = alloc() + + val options = if (isEcb) { + kCCOptionPKCS7Padding or kCCOptionECBMode + } else { + kCCOptionPKCS7Padding + } + + key.usePinned { pinnedKey -> + iv.usePinned { pinnedIv -> + data.usePinned { pinnedData -> + dataOut.usePinned { pinnedDataOut -> + val status = CCCrypt( + op = kCCEncrypt, + alg = kCCAlgorithmAES, + options = options, + key = if (key.isNotEmpty()) pinnedKey.addressOf(0) else null, + keyLength = key.size.toULong(), + iv = if (!isEcb && iv.isNotEmpty()) pinnedIv.addressOf(0) else null, + dataIn = if (data.isNotEmpty()) pinnedData.addressOf(0) else null, + dataInLength = data.size.toULong(), + dataOut = pinnedDataOut.addressOf(0), + dataOutAvailable = dataOutAvailable.toULong(), + dataOutMoved = dataOutMoved.ptr + ) + + if (status == kCCSuccess) { + finalData = dataOut.copyOf(dataOutMoved.value.toInt()) + } else { + error("CCCrypt Encrypt failed with status: $status") + } + } + } + } + } + } + + return finalData ?: ByteArray(0) +} + +@OptIn(ExperimentalForeignApi::class) +internal fun pluginAesDecrypt( + mode: String, + key: ByteArray, + iv: ByteArray, + data: ByteArray, +): ByteArray { + val isGcm = mode.uppercase().contains("GCM") + if (isGcm) { + throw UnsupportedOperationException("AES-GCM Decrypt is not yet implemented on iOS") + } + val isEcb = mode.uppercase().contains("ECB") + + val dataOutAvailable = data.size + 16 // AES block size + val dataOut = ByteArray(dataOutAvailable) + + var finalData: ByteArray? = null + + memScoped { + val dataOutMoved = alloc() + + val options = if (isEcb) { + kCCOptionPKCS7Padding or kCCOptionECBMode + } else { + kCCOptionPKCS7Padding + } + + key.usePinned { pinnedKey -> + iv.usePinned { pinnedIv -> + data.usePinned { pinnedData -> + dataOut.usePinned { pinnedDataOut -> + val status = CCCrypt( + op = kCCDecrypt, + alg = kCCAlgorithmAES, + options = options, + key = if (key.isNotEmpty()) pinnedKey.addressOf(0) else null, + keyLength = key.size.toULong(), + iv = if (!isEcb && iv.isNotEmpty()) pinnedIv.addressOf(0) else null, + dataIn = if (data.isNotEmpty()) pinnedData.addressOf(0) else null, + dataInLength = data.size.toULong(), + dataOut = pinnedDataOut.addressOf(0), + dataOutAvailable = dataOutAvailable.toULong(), + dataOutMoved = dataOutMoved.ptr + ) + + if (status == kCCSuccess) { + finalData = dataOut.copyOf(dataOutMoved.value.toInt()) + } else { + error("CCCrypt failed with status: $status") + } + } + } + } + } + } + + return finalData ?: ByteArray(0) +} + +internal fun pluginSign(algorithm: String, privateKey: ByteArray, data: ByteArray): ByteArray { + throw UnsupportedOperationException("Asymmetric signing is currently implemented natively only on Android") +} + +internal fun pluginVerify(algorithm: String, publicKey: ByteArray, signature: ByteArray, data: ByteArray): Boolean { + throw UnsupportedOperationException("Asymmetric verification is currently implemented natively only on Android") +} private fun UByteArray.toHex(): String = joinToString(separator = "") { byte -> byte.toString(16).padStart(2, '0') @@ -36,11 +265,18 @@ internal fun pluginDigestHex(algorithm: String, data: String): String { }, ) - when (normalized) { - "MD5" -> CC_MD5(input.refTo(0), input.size.toUInt(), output.refTo(0)) - "SHA1" -> CC_SHA1(input.refTo(0), input.size.toUInt(), output.refTo(0)) - "SHA256" -> CC_SHA256(input.refTo(0), input.size.toUInt(), output.refTo(0)) - "SHA512" -> CC_SHA512(input.refTo(0), input.size.toUInt(), output.refTo(0)) + input.usePinned { pinnedInput -> + output.usePinned { pinnedOutput -> + val dataPtr = if (input.isNotEmpty()) pinnedInput.addressOf(0) else null + val outputPtr = pinnedOutput.addressOf(0) + + when (normalized) { + "MD5" -> CC_MD5(dataPtr, input.size.toUInt(), outputPtr) + "SHA1" -> CC_SHA1(dataPtr, input.size.toUInt(), outputPtr) + "SHA256" -> CC_SHA256(dataPtr, input.size.toUInt(), outputPtr) + "SHA512" -> CC_SHA512(dataPtr, input.size.toUInt(), outputPtr) + } + } } return output.toHex() @@ -61,14 +297,25 @@ internal fun pluginHmacHex(algorithm: String, key: String, data: String): String } val output = UByteArray(outputSize) - CCHmac( - alg, - keyBytes.refTo(0), - keyBytes.size.toULong(), - input.refTo(0), - input.size.toULong(), - output.refTo(0), - ) + + keyBytes.usePinned { pinnedKey -> + input.usePinned { pinnedInput -> + output.usePinned { pinnedOutput -> + val keyPtr = if (keyBytes.isNotEmpty()) pinnedKey.addressOf(0) else null + val inputPtr = if (input.isNotEmpty()) pinnedInput.addressOf(0) else null + val outputPtr = pinnedOutput.addressOf(0) + + CCHmac( + alg, + keyPtr, + keyBytes.size.toULong(), + inputPtr, + input.size.toULong(), + outputPtr, + ) + } + } + } return output.toHex() } diff --git a/composeApp/src/nativeInterop/cinterop/commoncrypto_shim.h b/composeApp/src/nativeInterop/cinterop/commoncrypto_shim.h index cc394555..b2620dc1 100644 --- a/composeApp/src/nativeInterop/cinterop/commoncrypto_shim.h +++ b/composeApp/src/nativeInterop/cinterop/commoncrypto_shim.h @@ -31,3 +31,84 @@ void CCHmac( size_t dataLength, void *macOut ); + +typedef uint32_t CCPBKDFAlgorithm; +enum { + kCCPBKDF2 = 2, +}; + +typedef uint32_t CCPseudoRandomAlgorithm; +enum { + kCCPRFHmacAlgSHA1 = 1, + kCCPRFHmacAlgSHA224 = 2, + kCCPRFHmacAlgSHA256 = 3, + kCCPRFHmacAlgSHA384 = 4, + kCCPRFHmacAlgSHA512 = 5, +}; + +int CCKeyDerivationPBKDF( + CCPBKDFAlgorithm algorithm, + const char *password, + size_t passwordLen, + const uint8_t *salt, + size_t saltLen, + CCPseudoRandomAlgorithm prf, + uint32_t rounds, + uint8_t *derivedKey, + size_t derivedKeyLen +); + +typedef int32_t CCCryptorStatus; +enum { + kCCSuccess = 0, + kCCParamError = -4300, + kCCBufferTooSmall = -4301, + kCCMemoryFailure = -4302, + kCCAlignmentError = -4303, + kCCDecodeError = -4304, + kCCUnimplemented = -4305, + kCCOverflow = -4306, + kCCRNGFailure = -4307, + kCCUnspecifiedError = -4308, + kCCCallSequenceError = -4309, + kCCKeySizeError = -4310, + kCCInvalidKey = -4311, +}; + +typedef uint32_t CCOperation; +enum { + kCCEncrypt = 0, + kCCDecrypt = 1, +}; + +typedef uint32_t CCAlgorithm; +enum { + kCCAlgorithmAES128 = 0, + kCCAlgorithmAES = 0, + kCCAlgorithmDES = 1, + kCCAlgorithm3DES = 2, + kCCAlgorithmCAST = 3, + kCCAlgorithmRC4 = 4, + kCCAlgorithmRC2 = 5, + kCCAlgorithmBlowfish = 6, +}; + +typedef uint32_t CCOptions; +enum { + kCCOptionPKCS7Padding = 1, + kCCOptionECBMode = 2, +}; + +CCCryptorStatus CCCrypt( + CCOperation op, + CCAlgorithm alg, + CCOptions options, + const void *key, + size_t keyLength, + const void *iv, + const void *dataIn, + size_t dataInLength, + void *dataOut, + size_t dataOutAvailable, + size_t *dataOutMoved +);