diff --git a/src/all/mangafire/build.gradle b/src/all/mangafire/build.gradle index d07d8194e..dd034173c 100644 --- a/src/all/mangafire/build.gradle +++ b/src/all/mangafire/build.gradle @@ -1,7 +1,7 @@ ext { extName = 'MangaFire' extClass = '.MangaFireFactory' - extVersionCode = 15 + extVersionCode = 16 isNsfw = true } diff --git a/src/all/mangafire/src/eu/kanade/tachiyomi/extension/all/mangafire/MangaFire.kt b/src/all/mangafire/src/eu/kanade/tachiyomi/extension/all/mangafire/MangaFire.kt index a93a1c479..0c4727929 100644 --- a/src/all/mangafire/src/eu/kanade/tachiyomi/extension/all/mangafire/MangaFire.kt +++ b/src/all/mangafire/src/eu/kanade/tachiyomi/extension/all/mangafire/MangaFire.kt @@ -1,16 +1,10 @@ package eu.kanade.tachiyomi.extension.all.mangafire import android.annotation.SuppressLint -import android.app.Application -import android.util.Log -import android.webkit.WebResourceRequest -import android.webkit.WebResourceResponse -import android.webkit.WebView -import android.webkit.WebViewClient import androidx.preference.PreferenceScreen import androidx.preference.SwitchPreferenceCompat import eu.kanade.tachiyomi.network.GET -import eu.kanade.tachiyomi.network.await +import eu.kanade.tachiyomi.network.asObservableSuccess import eu.kanade.tachiyomi.source.ConfigurableSource import eu.kanade.tachiyomi.source.model.FilterList import eu.kanade.tachiyomi.source.model.MangasPage @@ -22,24 +16,17 @@ import eu.kanade.tachiyomi.util.asJsoup import keiyoushi.utils.getPreferencesLazy import keiyoushi.utils.parseAs import keiyoushi.utils.tryParse -import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.runBlocking -import kotlinx.coroutines.suspendCancellableCoroutine -import kotlinx.coroutines.withContext -import kotlinx.coroutines.withTimeoutOrNull import kotlinx.serialization.Serializable import kotlinx.serialization.json.JsonPrimitive import kotlinx.serialization.json.int import okhttp3.HttpUrl.Companion.toHttpUrl import okhttp3.Request import okhttp3.Response -import okhttp3.internal.charset -import okio.Buffer import org.jsoup.Jsoup import org.jsoup.nodes.Document import org.jsoup.nodes.Element -import uy.kohesive.injekt.Injekt -import uy.kohesive.injekt.api.get +import rx.Observable import java.security.SecureRandom import java.security.cert.X509Certificate import java.text.SimpleDateFormat @@ -47,9 +34,6 @@ import java.util.Locale import javax.net.ssl.SSLContext import javax.net.ssl.TrustManager import javax.net.ssl.X509TrustManager -import kotlin.coroutines.resume -import kotlin.coroutines.resumeWithException -import kotlin.time.Duration.Companion.seconds class MangaFire( override val lang: String, @@ -85,6 +69,15 @@ class MangaFire( override fun headersBuilder() = super.headersBuilder() .add("Referer", "$baseUrl/") + private val webViewHelper = WebViewHelper(client, headers) + + // dirty hack to disable suggested mangas on Komikku + // we don't want to spawn N webviews for N search token + // https://github.com/komikku-app/komikku/blob/4323fd5841b390213aa4c4af77e07ad42eb423fc/source-api/src/commonMain/kotlin/eu/kanade/tachiyomi/source/CatalogueSource.kt#L176-L184 + @Suppress("Unused") + @JvmName("getDisableRelatedMangasBySearch") + fun disableRelatedMangasBySearch() = true + // ============================== Popular =============================== override fun popularMangaRequest(page: Int): Request { @@ -111,12 +104,18 @@ class MangaFire( // =============================== Search =============================== + private val vrfCache = object : LinkedHashMap() { + override fun removeEldestEntry(eldest: Map.Entry?) = size > 20 + } + override fun searchMangaRequest(page: Int, query: String, filters: FilterList): Request { + val stdQuery = query.replace("\"", " ").trim() + val url = baseUrl.toHttpUrl().newBuilder().apply { addPathSegment("filter") - if (query.isNotBlank()) { - addQueryParameter("keyword", query.trim()) + if (stdQuery.isNotBlank()) { + addQueryParameter("keyword", stdQuery) } val filterList = filters.ifEmpty { getFilterList() } @@ -127,8 +126,39 @@ class MangaFire( addQueryParameter("language[]", langCode) addQueryParameter("page", page.toString()) - if (query.isNotBlank()) { - val vrf = VrfGenerator.generate(query.trim()) + if (stdQuery.isNotBlank()) { + val vrf = vrfCache.get(stdQuery) + ?: runBlocking { + webViewHelper.loadInWebView( + url = "$baseUrl/home", + requestIntercept = { request -> + val url = request.url + if ( + url.host == "mangafire.to" && + url.encodedPath.orEmpty().contains("ajax/manga/search") + ) { + WebViewHelper.RequestIntercept.Capture + } else { + WebViewHelper.RequestIntercept.Block + } + }, + onPageFinish = { view -> + view.evaluateJavascript( + """ + $(function() { + setInterval(() => { + $(".search-inner input[name=keyword]").val("$stdQuery").trigger("keyup"); + }, 1000); + }); + """.trimIndent(), + ) {} + }, + ) + }.toHttpUrl().queryParameter("vrf") + ?.takeIf { it.isNotBlank() } + ?.also { vrfCache.put(stdQuery, it) } + ?: throw Exception("Unable to find vrf token") + addQueryParameter("vrf", vrf) } }.build() @@ -267,171 +297,49 @@ class MangaFire( // =============================== Pages ================================ - override fun pageListParse(response: Response): List { - val document = response.asJsoup() - val ajaxUrl = runBlocking { getVrfFromWebview(document) } - - return client.newCall(GET(ajaxUrl, headers)).execute() - .parseAs>().result - .pages.mapIndexed { index, image -> - val url = image.url - val offset = image.offset - val imageUrl = if (offset > 0) "$url#${ImageInterceptor.SCRAMBLED}_$offset" else url - - Page(index, imageUrl = imageUrl) - } - } - - @SuppressLint("SetJavaScriptEnabled") - private suspend fun getVrfFromWebview(document: Document): String = withContext(Dispatchers.Main.immediate) { - withTimeoutOrNull(20.seconds) { - suspendCancellableCoroutine { continuation -> - val emptyWebViewResponse = runCatching { - WebResourceResponse("text/html", "utf-8", Buffer().inputStream()) - }.getOrElse { - continuation.resumeWithException(it) - return@suspendCancellableCoroutine - } - - val context = Injekt.get() - var webview: WebView? = WebView(context) - - fun cleanup() = runBlocking(Dispatchers.Main.immediate) { - webview?.stopLoading() - webview?.destroy() - webview = null - } - - webview?.apply { - with(settings) { - javaScriptEnabled = true - domStorageEnabled = true - databaseEnabled = true - blockNetworkImage = true - } - - webViewClient = object : WebViewClient() { - private val ajaxCalls = setOf("ajax/read/chapter", "ajax/read/volume") - - override fun shouldInterceptRequest( - view: WebView, - request: WebResourceRequest, - ): WebResourceResponse? { - val url = request.url - - // allow script from their cdn - if ( - url.host.orEmpty().contains("mfcdn.cc") && - url.pathSegments.lastOrNull().orEmpty().contains("js") - ) { - Log.d(name, "allowed: $url") - - runCatching { fetchWebResource(request) } - .onSuccess { return it } - .onFailure { - if (continuation.isActive) { - continuation.resumeWithException(it) - cleanup() - } - } - } - - // allow jquery script - if ( - url.host.orEmpty().contains("cloudflare.com") && - url.encodedPath.orEmpty().contains("jquery") - ) { - Log.d(name, "allowed: $url") - - runCatching { fetchWebResource(request) } - .onSuccess { return it } - .onFailure { - if (continuation.isActive) { - continuation.resumeWithException(it) - cleanup() - } - } - } - - // allow ajax/read calls and intercept ajax/read/chapter or ajax/read/volume - if ( - url.host == "mangafire.to" && - url.encodedPath.orEmpty().contains("ajax/read") - ) { - if (ajaxCalls.any { url.encodedPath!!.contains(it) }) { - Log.d(name, "found: $url") - - if (url.getQueryParameter("vrf") != null) { - if (continuation.isActive) { - continuation.resume(url.toString()) - cleanup() - } - } else { - if (continuation.isActive) { - continuation.resumeWithException( - Exception("Unable to find vrf token"), - ) - cleanup() - } - } - } else { - // need to allow other call to ajax/read - Log.d(name, "allowed: $url") - runCatching { fetchWebResource(request) } - .onSuccess { return it } - .onFailure { - if (continuation.isActive) { - continuation.resumeWithException(it) - cleanup() - } - } - } - } - - Log.d(name, "denied: $url") - return emptyWebViewResponse + override fun fetchPageList(chapter: SChapter): Observable> { + val intercepted = runBlocking { + webViewHelper.loadInWebView( + url = "$baseUrl${chapter.url}", + requestIntercept = { request -> + val url = request.url + if ( + url.host == "mangafire.to" && + url.encodedPath.orEmpty().contains("ajax/read") + ) { + if (setOf("ajax/read/chapter", "ajax/read/volume").any { url.encodedPath!!.contains(it) }) { + WebViewHelper.RequestIntercept.Capture + } else { + // need to allow other call to ajax/read + WebViewHelper.RequestIntercept.Allow } + } else { + WebViewHelper.RequestIntercept.Block } - - loadDataWithBaseURL( - document.location(), - document.outerHtml(), - "text/html", - "utf-8", - "", - ) - } - - continuation.invokeOnCancellation { - cleanup() - } - } - } ?: throw Exception("Timeout getting vrf token") - } - - private fun fetchWebResource(request: WebResourceRequest): WebResourceResponse = runBlocking(Dispatchers.IO) { - val okhttpRequest = Request.Builder().apply { - url(request.url.toString()) - headers(headers) - - val skipHeaders = setOf("referer", "user-agent", "sec-ch-ua", "sec-ch-ua-mobile", "sec-ch-ua-platform", "x-requested-with") - for ((name, value) in request.requestHeaders) { - if (skipHeaders.contains(name.lowercase())) continue - addHeader(name, value) - } - }.build() - - client.newCall(okhttpRequest).await().use { response -> - val mediaType = response.body.contentType() - - WebResourceResponse( - mediaType?.let { "${it.type}/${it.subtype}" }, - mediaType?.charset()?.name(), - Buffer().readFrom( - response.body.byteStream(), - ).inputStream(), + }, + onPageFinish = {}, ) } + if (intercepted.toHttpUrl().queryParameter("vrf") == null) { + throw Exception("Unable to find vrf token") + } + + return client.newCall(GET(intercepted, headers)) + .asObservableSuccess().map { + it.parseAs>().result + .pages.mapIndexed { index, image -> + val url = image.url + val offset = image.offset + val imageUrl = + if (offset > 0) "$url#${ImageInterceptor.SCRAMBLED}_$offset" else url + + Page(index, imageUrl = imageUrl) + } + } + } + + override fun pageListParse(response: Response): List { + throw UnsupportedOperationException() } @Serializable diff --git a/src/all/mangafire/src/eu/kanade/tachiyomi/extension/all/mangafire/VrfGenerator.kt b/src/all/mangafire/src/eu/kanade/tachiyomi/extension/all/mangafire/VrfGenerator.kt deleted file mode 100644 index fb562a5ed..000000000 --- a/src/all/mangafire/src/eu/kanade/tachiyomi/extension/all/mangafire/VrfGenerator.kt +++ /dev/null @@ -1,186 +0,0 @@ -package eu.kanade.tachiyomi.extension.all.mangafire -import android.util.Base64 - -/** - * Original script by @Trung0246 on Github - */ -object VrfGenerator { - private fun atob(data: String): ByteArray = Base64.decode(data, Base64.DEFAULT) - private fun btoa(data: ByteArray): String = Base64.encodeToString(data, Base64.DEFAULT) - - private fun rc4(key: ByteArray, input: ByteArray): ByteArray { - val s = IntArray(256) { it } - var j = 0 - - // KSA - for (i in 0..255) { - j = (j + s[i] + key[i % key.size].toInt().and(0xFF)) and 0xFF - val temp = s[i] - s[i] = s[j] - s[j] = temp - } - - // PRGA - val output = ByteArray(input.size) - var i = 0 - j = 0 - for (y in input.indices) { - i = (i + 1) and 0xFF - j = (j + s[i]) and 0xFF - val temp = s[i] - s[i] = s[j] - s[j] = temp - val k = s[(s[i] + s[j]) and 0xFF] - output[y] = (input[y].toInt() xor k).toByte() - } - return output - } - - private fun transform( - input: ByteArray, - initSeedBytes: ByteArray, - prefixKeyBytes: ByteArray, - prefixLen: Int, - schedule: List<(Int) -> Int>, - ): ByteArray { - val out = mutableListOf() - for (i in input.indices) { - if (i < prefixLen) { - out.add(prefixKeyBytes[i]) - } - val transformed = schedule[i % 10]( - (input[i].toInt() xor initSeedBytes[i % 32].toInt()) and 0xFF, - ) and 0xFF - out.add(transformed.toByte()) - } - return out.toByteArray() - } - - private val scheduleC = listOf<(Int) -> Int>( - { c -> (c - 48 + 256) and 0xFF }, - { c -> (c - 19 + 256) and 0xFF }, - { c -> (c xor 241) and 0xFF }, - { c -> (c - 19 + 256) and 0xFF }, - { c -> (c + 223) and 0xFF }, - { c -> (c - 19 + 256) and 0xFF }, - { c -> (c - 170 + 256) and 0xFF }, - { c -> (c - 19 + 256) and 0xFF }, - { c -> (c - 48 + 256) and 0xFF }, - { c -> (c xor 8) and 0xFF }, - ) - - private val scheduleY = listOf<(Int) -> Int>( - { c -> ((c shl 4) or (c ushr 4)) and 0xFF }, - { c -> (c + 223) and 0xFF }, - { c -> ((c shl 4) or (c ushr 4)) and 0xFF }, - { c -> (c xor 163) and 0xFF }, - { c -> (c - 48 + 256) and 0xFF }, - { c -> (c + 82) and 0xFF }, - { c -> (c + 223) and 0xFF }, - { c -> (c - 48 + 256) and 0xFF }, - { c -> (c xor 83) and 0xFF }, - { c -> ((c shl 4) or (c ushr 4)) and 0xFF }, - ) - - private val scheduleB = listOf<(Int) -> Int>( - { c -> (c - 19 + 256) and 0xFF }, - { c -> (c + 82) and 0xFF }, - { c -> (c - 48 + 256) and 0xFF }, - { c -> (c - 170 + 256) and 0xFF }, - { c -> ((c shl 4) or (c ushr 4)) and 0xFF }, - { c -> (c - 48 + 256) and 0xFF }, - { c -> (c - 170 + 256) and 0xFF }, - { c -> (c xor 8) and 0xFF }, - { c -> (c + 82) and 0xFF }, - { c -> (c xor 163) and 0xFF }, - ) - - private val scheduleJ = listOf<(Int) -> Int>( - { c -> (c + 223) and 0xFF }, - { c -> ((c shl 4) or (c ushr 4)) and 0xFF }, - { c -> (c + 223) and 0xFF }, - { c -> (c xor 83) and 0xFF }, - { c -> (c - 19 + 256) and 0xFF }, - { c -> (c + 223) and 0xFF }, - { c -> (c - 170 + 256) and 0xFF }, - { c -> (c + 223) and 0xFF }, - { c -> (c - 170 + 256) and 0xFF }, - { c -> (c xor 83) and 0xFF }, - ) - - private val scheduleE = listOf<(Int) -> Int>( - { c -> (c + 82) and 0xFF }, - { c -> (c xor 83) and 0xFF }, - { c -> (c xor 163) and 0xFF }, - { c -> (c + 82) and 0xFF }, - { c -> (c - 170 + 256) and 0xFF }, - { c -> (c xor 8) and 0xFF }, - { c -> (c xor 241) and 0xFF }, - { c -> (c + 82) and 0xFF }, - { c -> (c + 176) and 0xFF }, - { c -> ((c shl 4) or (c ushr 4)) and 0xFF }, - ) - - private val rc4Keys = mapOf( - "l" to "u8cBwTi1CM4XE3BkwG5Ble3AxWgnhKiXD9Cr279yNW0=", - "g" to "t00NOJ/Fl3wZtez1xU6/YvcWDoXzjrDHJLL2r/IWgcY=", - "B" to "S7I+968ZY4Fo3sLVNH/ExCNq7gjuOHjSRgSqh6SsPJc=", - "m" to "7D4Q8i8dApRj6UWxXbIBEa1UqvjI+8W0UvPH9talJK8=", - "F" to "0JsmfWZA1kwZeWLk5gfV5g41lwLL72wHbam5ZPfnOVE=", - ) - - private val seeds32 = mapOf( - "A" to "pGjzSCtS4izckNAOhrY5unJnO2E1VbrU+tXRYG24vTo=", - "V" to "dFcKX9Qpu7mt/AD6mb1QF4w+KqHTKmdiqp7penubAKI=", - "N" to "owp1QIY/kBiRWrRn9TLN2CdZsLeejzHhfJwdiQMjg3w=", - "P" to "H1XbRvXOvZAhyyPaO68vgIUgdAHn68Y6mrwkpIpEue8=", - "k" to "2Nmobf/mpQ7+Dxq1/olPSDj3xV8PZkPbKaucJvVckL0=", - ) - - private val prefixKeys = mapOf( - "O" to "Rowe+rg/0g==", - "v" to "8cULcnOMJVY8AA==", - "L" to "n2+Og2Gth8Hh", - "p" to "aRpvzH+yoA==", - "W" to "ZB4oBi0=", - ) - - fun generate(input: String): String { - var bytes = input.toByteArray() - // RC4 1 - bytes = rc4(atob(rc4Keys["l"]!!), bytes) - - // Step C1 - bytes = transform(bytes, atob(seeds32["A"]!!), atob(prefixKeys["O"]!!), 7, scheduleC) - - // RC4 2 - bytes = rc4(atob(rc4Keys["g"]!!), bytes) - - // Step Y - bytes = transform(bytes, atob(seeds32["V"]!!), atob(prefixKeys["v"]!!), 10, scheduleY) - - // RC4 3 - bytes = rc4(atob(rc4Keys["B"]!!), bytes) - - // Step B - bytes = transform(bytes, atob(seeds32["N"]!!), atob(prefixKeys["L"]!!), 9, scheduleB) - - // RC4 4 - bytes = rc4(atob(rc4Keys["m"]!!), bytes) - - // Step J - bytes = transform(bytes, atob(seeds32["P"]!!), atob(prefixKeys["p"]!!), 7, scheduleJ) - - // RC4 5 - bytes = rc4(atob(rc4Keys["F"]!!), bytes) - - // Step E - bytes = transform(bytes, atob(seeds32["k"]!!), atob(prefixKeys["W"]!!), 5, scheduleE) - - // Base64URL encode - return btoa(bytes) - .replace("+", "-") - .replace("/", "_") - .replace("=", "") - } -} diff --git a/src/all/mangafire/src/eu/kanade/tachiyomi/extension/all/mangafire/WebViewHelper.kt b/src/all/mangafire/src/eu/kanade/tachiyomi/extension/all/mangafire/WebViewHelper.kt new file mode 100644 index 000000000..856c08890 --- /dev/null +++ b/src/all/mangafire/src/eu/kanade/tachiyomi/extension/all/mangafire/WebViewHelper.kt @@ -0,0 +1,198 @@ +package eu.kanade.tachiyomi.extension.all.mangafire + +import android.annotation.SuppressLint +import android.app.Application +import android.util.Log +import android.webkit.WebResourceRequest +import android.webkit.WebResourceResponse +import android.webkit.WebView +import android.webkit.WebViewClient +import eu.kanade.tachiyomi.network.await +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.suspendCancellableCoroutine +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import kotlinx.coroutines.withContext +import kotlinx.coroutines.withTimeout +import okhttp3.Headers +import okhttp3.OkHttpClient +import okhttp3.Request +import okio.Buffer +import uy.kohesive.injekt.Injekt +import uy.kohesive.injekt.api.get +import kotlin.coroutines.resume +import kotlin.coroutines.resumeWithException +import kotlin.time.Duration.Companion.seconds + +class WebViewHelper( + private val client: OkHttpClient, + private val headers: Headers, +) { + private val name = "MangaFire" + private val mutex = Mutex() + + @SuppressLint("SetJavaScriptEnabled") + suspend fun loadInWebView( + url: String, + requestIntercept: (request: WebResourceRequest) -> RequestIntercept, + onPageFinish: (view: WebView) -> Unit = {}, + ): String = mutex.withLock { + withContext(Dispatchers.Main.immediate) { + withTimeout(20.seconds) { + suspendCancellableCoroutine { continuation -> + val emptyWebViewResponse = runCatching { + WebResourceResponse("text/html", "utf-8", Buffer().inputStream()) + }.getOrElse { + continuation.resumeWithException(it) + return@suspendCancellableCoroutine + } + + val context = Injekt.get() + var webview: WebView? = WebView(context) + + fun cleanup() = runBlocking(Dispatchers.Main.immediate) { + webview?.stopLoading() + webview?.destroy() + webview = null + } + + webview?.apply { + with(settings) { + javaScriptEnabled = true + domStorageEnabled = true + blockNetworkImage = true + } + + webViewClient = object : WebViewClient() { + override fun shouldInterceptRequest( + view: WebView, + request: WebResourceRequest, + ): WebResourceResponse? { + // allow main page + if (request.url.toString() == url) { + Log.d(name, "allowed: ${request.url}") + + runCatching { fetchWebResource(request) } + .onSuccess { return it } + .onFailure { + if (continuation.isActive) { + continuation.resumeWithException(it) + cleanup() + } + } + } + + // allow script from their cdn + if ( + request.url.host.orEmpty().contains("mfcdn.cc") && + request.url.pathSegments.lastOrNull().orEmpty().contains("js") + ) { + Log.d(name, "allowed: ${request.url}") + + runCatching { fetchWebResource(request) } + .onSuccess { return it } + .onFailure { + if (continuation.isActive) { + continuation.resumeWithException(it) + cleanup() + } + } + } + + // allow jquery script + if ( + request.url.host.orEmpty().contains("cloudflare.com") && + request.url.encodedPath.orEmpty().contains("jquery") + ) { + Log.d(name, "allowed: ${request.url}") + + runCatching { fetchWebResource(request) } + .onSuccess { return it } + .onFailure { + if (continuation.isActive) { + continuation.resumeWithException(it) + cleanup() + } + } + } + + when (requestIntercept(request)) { + RequestIntercept.Allow -> { + Log.d(name, "allowed: ${request.url}") + runCatching { fetchWebResource(request) } + .onSuccess { return it } + .onFailure { + if (continuation.isActive) { + continuation.resumeWithException(it) + cleanup() + } + } + } + + RequestIntercept.Block -> { + Log.d(name, "denied: ${request.url}") + return emptyWebViewResponse + } + + RequestIntercept.Capture -> { + Log.d(name, "captured: ${request.url}") + if (continuation.isActive) { + continuation.resume(request.url.toString()) + cleanup() + } + return emptyWebViewResponse + } + } + + return emptyWebViewResponse + } + + override fun onPageFinished(view: WebView, url: String) { + super.onPageFinished(view, url) + onPageFinish(view) + } + } + + loadUrl(url) + } + + continuation.invokeOnCancellation { + cleanup() + } + } + } + } + } + + enum class RequestIntercept { + Allow, + Block, + Capture, + } + + private fun fetchWebResource(request: WebResourceRequest): WebResourceResponse = runBlocking(Dispatchers.IO) { + val okhttpRequest = Request.Builder().apply { + url(request.url.toString()) + headers(headers) + + val skipHeaders = setOf("user-agent", "sec-ch-ua", "sec-ch-ua-mobile", "sec-ch-ua-platform", "x-requested-with") + for ((name, value) in request.requestHeaders) { + if (skipHeaders.contains(name.lowercase())) continue + header(name, value) + } + }.build() + + client.newCall(okhttpRequest).await().use { response -> + val mediaType = response.body.contentType() + + WebResourceResponse( + mediaType?.let { "${it.type}/${it.subtype}" }, + mediaType?.charset()?.name(), + Buffer().readFrom( + response.body.byteStream(), + ).inputStream(), + ) + } + } +}