diff --git a/app/src/main/java/eu/kanade/presentation/more/settings/screen/SettingsAdvancedScreen.kt b/app/src/main/java/eu/kanade/presentation/more/settings/screen/SettingsAdvancedScreen.kt index 8fb07006f61f..731c9f0edef5 100644 --- a/app/src/main/java/eu/kanade/presentation/more/settings/screen/SettingsAdvancedScreen.kt +++ b/app/src/main/java/eu/kanade/presentation/more/settings/screen/SettingsAdvancedScreen.kt @@ -3,7 +3,6 @@ package eu.kanade.presentation.more.settings.screen import android.annotation.SuppressLint import android.content.ActivityNotFoundException import android.content.Intent -import android.os.Build import android.provider.Settings import android.webkit.WebStorage import android.webkit.WebView @@ -44,6 +43,7 @@ import eu.kanade.tachiyomi.data.download.DownloadManager import eu.kanade.tachiyomi.data.library.LibraryUpdateJob import eu.kanade.tachiyomi.network.NetworkHelper import eu.kanade.tachiyomi.network.NetworkPreferences +import eu.kanade.tachiyomi.network.POST import eu.kanade.tachiyomi.network.PREF_DOH_360 import eu.kanade.tachiyomi.network.PREF_DOH_ADGUARD import eu.kanade.tachiyomi.network.PREF_DOH_ALIDNS @@ -56,6 +56,9 @@ import eu.kanade.tachiyomi.network.PREF_DOH_NJALLA import eu.kanade.tachiyomi.network.PREF_DOH_QUAD101 import eu.kanade.tachiyomi.network.PREF_DOH_QUAD9 import eu.kanade.tachiyomi.network.PREF_DOH_SHECAN +import eu.kanade.tachiyomi.network.awaitSuccess +import eu.kanade.tachiyomi.network.interceptor.FlareSolverrInterceptor +import eu.kanade.tachiyomi.network.parseAs import eu.kanade.tachiyomi.source.AndroidSourceManager import eu.kanade.tachiyomi.ui.more.OnboardingScreen import eu.kanade.tachiyomi.util.CrashLogUtil @@ -76,10 +79,17 @@ import exh.util.toAnnotatedString import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.persistentMapOf import kotlinx.collections.immutable.toImmutableMap +import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json import logcat.LogPriority import okhttp3.Headers +import okhttp3.MediaType.Companion.toMediaType +import okhttp3.OkHttpClient +import okhttp3.RequestBody.Companion.toRequestBody import tachiyomi.core.common.i18n.pluralStringResource import tachiyomi.core.common.i18n.stringResource import tachiyomi.core.common.util.lang.launchNonCancellable @@ -97,7 +107,9 @@ import tachiyomi.presentation.core.i18n.stringResource import tachiyomi.presentation.core.util.collectAsState import uy.kohesive.injekt.Injekt import uy.kohesive.injekt.api.get +import uy.kohesive.injekt.injectLazy import java.io.File +import tachiyomi.core.common.preference.Preference as BasePreference object SettingsAdvancedScreen : SearchableSettings { @@ -234,9 +246,13 @@ object SettingsAdvancedScreen : SearchableSettings { ): Preference.PreferenceGroup { val context = LocalContext.current val networkHelper = remember { Injekt.get() } + val scope = rememberCoroutineScope() val userAgentPref = networkPreferences.defaultUserAgent() val userAgent by userAgentPref.collectAsState() + val flareSolverrUrlPref = networkPreferences.flareSolverrUrl() + val enableFlareSolverrPref = networkPreferences.enableFlareSolverr() + val enableFlareSolverr by enableFlareSolverrPref.collectAsState() return Preference.PreferenceGroup( title = stringResource(MR.strings.label_network), @@ -315,6 +331,28 @@ object SettingsAdvancedScreen : SearchableSettings { context.toast(MR.strings.requires_app_restart) }, ), + Preference.PreferenceItem.SwitchPreference( + pref = enableFlareSolverrPref, + title = stringResource(MR.strings.pref_enable_flare_solverr), + subtitle = stringResource(MR.strings.pref_enable_flare_solverr_summary) + ), + Preference.PreferenceItem.EditTextPreference( + pref = flareSolverrUrlPref, + title = stringResource(MR.strings.pref_flare_solverr_url), + enabled = enableFlareSolverr, + subtitle = stringResource(MR.strings.pref_flare_solverr_url_summary), + ), + Preference.PreferenceItem.TextPreference( + title = stringResource(MR.strings.pref_test_flare_solverr_and_update_user_agent), + enabled = enableFlareSolverr, + subtitle = stringResource(MR.strings.pref_test_flare_solverr_and_update_user_agent_summary), + onClick = { + scope.launch { + testFlareSolverrAndUpdateUserAgent(flareSolverrUrlPref, userAgentPref, context) + } + }, + ) + ), ) } @@ -777,6 +815,59 @@ object SettingsAdvancedScreen : SearchableSettings { ) } + private suspend fun testFlareSolverrAndUpdateUserAgent( + flareSolverrUrlPref: BasePreference, + userAgentPref: BasePreference, + context: android.content.Context + ) { + val json: Json by injectLazy() + val jsonMediaType = "application/json".toMediaType() + val client = OkHttpClient.Builder().build() + + try { + withContext(Dispatchers.IO) { + val flareSolverUrl = flareSolverrUrlPref.get().trim() + val flareSolverResponse = with(json) { + client.newCall( + POST( + url = flareSolverUrl, + body = + Json.encodeToString( + FlareSolverrInterceptor.CFClearance.FlareSolverRequest( + "request.get", + "https://www.google.com/", + returnOnlyCookies = true, + maxTimeout = 60000, + ), + ).toRequestBody(jsonMediaType), + ), + ).awaitSuccess().parseAs() + } + + if (flareSolverResponse.solution.status in 200..299) { + // Set the user agent to the one provided by FlareSolverr + userAgentPref.set(flareSolverResponse.solution.userAgent) + + val message = SYMR.strings.flare_solver_user_agent_update_success + withContext(Dispatchers.Main) { + context.toast(message) + } + } else { + val message = SYMR.strings.flare_solver_update_user_agent_failed + withContext(Dispatchers.Main) { + context.toast(message) + } + } + } + } catch (e: Exception) { + logcat (LogPriority.ERROR, tag = "FlareSolverr") + { "Failed to resolve with FlareSolverr: ${e.message}" } + withContext(Dispatchers.Main) { + context.toast(SYMR.strings.flare_solver_error) + } + } + } + private var job: Job? = null // SY <-- } diff --git a/core/common/src/main/kotlin/eu/kanade/tachiyomi/network/AndroidCookieJar.kt b/core/common/src/main/kotlin/eu/kanade/tachiyomi/network/AndroidCookieJar.kt index f9322e840fed..b153e3442f9c 100644 --- a/core/common/src/main/kotlin/eu/kanade/tachiyomi/network/AndroidCookieJar.kt +++ b/core/common/src/main/kotlin/eu/kanade/tachiyomi/network/AndroidCookieJar.kt @@ -1,5 +1,6 @@ package eu.kanade.tachiyomi.network +import android.util.Log import android.webkit.CookieManager import okhttp3.Cookie import okhttp3.CookieJar @@ -51,4 +52,40 @@ class AndroidCookieJar : CookieJar { fun removeAll() { manager.removeAllCookies {} } + + fun addAll(url: HttpUrl, cookies: List) { + val urlString = url.toString() + Log.d("AndroidCookieJar", "Adding cookies to URL: $urlString") + + // Log incoming cookies to add + cookies.forEach { newCookie -> + Log.d("AndroidCookieJar", "Incoming cookie: ${newCookie.name}=${newCookie.value}") + } + + // Get existing cookies for the URL + val existingCookies = manager.getCookie(urlString)?.split("; ")?.associate { + val (name, value) = it.split('=', limit = 2) + name to value + }?.toMutableMap() ?: mutableMapOf() + + Log.d("AndroidCookieJar", "Existing cookies: $existingCookies") + + // Add or update the cookies + cookies.forEach { newCookie -> + Log.d("AndroidCookieJar", "Adding/updating cookie: ${newCookie.name}=${newCookie.value}") + existingCookies[newCookie.name] = newCookie.value + } + + // Convert the map back to a string and set it in the cookie manager + val finalCookiesString = existingCookies.entries.joinToString("; ") { "${it.key}=${it.value}" } + Log.d("AndroidCookieJar", "Final cookies string: $finalCookiesString") + manager.setCookie(urlString, finalCookiesString) + + // Verify if cookies are set correctly + val setCookies = manager.getCookie(urlString) + Log.d("AndroidCookieJar", "Set cookies in manager: $setCookies") + + Log.d("AndroidCookieJar", "All cookies added for URL: $urlString") + } + } diff --git a/core/common/src/main/kotlin/eu/kanade/tachiyomi/network/NetworkHelper.kt b/core/common/src/main/kotlin/eu/kanade/tachiyomi/network/NetworkHelper.kt index f508e6e08b25..8948b721250e 100755 --- a/core/common/src/main/kotlin/eu/kanade/tachiyomi/network/NetworkHelper.kt +++ b/core/common/src/main/kotlin/eu/kanade/tachiyomi/network/NetworkHelper.kt @@ -2,6 +2,7 @@ package eu.kanade.tachiyomi.network import android.content.Context import eu.kanade.tachiyomi.network.interceptor.CloudflareInterceptor +import eu.kanade.tachiyomi.network.interceptor.FlareSolverrInterceptor import eu.kanade.tachiyomi.network.interceptor.IgnoreGzipInterceptor import eu.kanade.tachiyomi.network.interceptor.UncaughtExceptionInterceptor import eu.kanade.tachiyomi.network.interceptor.UserAgentInterceptor @@ -41,6 +42,7 @@ open /* SY <-- */ class NetworkHelper( .addInterceptor(UserAgentInterceptor(::defaultUserAgentProvider)) .addNetworkInterceptor(IgnoreGzipInterceptor()) .addNetworkInterceptor(BrotliInterceptor) + .addNetworkInterceptor(FlareSolverrInterceptor(preferences)) if (isDebugBuild) { val httpLoggingInterceptor = HttpLoggingInterceptor().apply { @@ -50,7 +52,7 @@ open /* SY <-- */ class NetworkHelper( } builder.addInterceptor( - CloudflareInterceptor(context, cookieJar, ::defaultUserAgentProvider), + CloudflareInterceptor(context, cookieJar, preferences, ::defaultUserAgentProvider), ) when (preferences.dohProvider().get()) { diff --git a/core/common/src/main/kotlin/eu/kanade/tachiyomi/network/NetworkPreferences.kt b/core/common/src/main/kotlin/eu/kanade/tachiyomi/network/NetworkPreferences.kt index c32864aec60f..63bc29055c67 100644 --- a/core/common/src/main/kotlin/eu/kanade/tachiyomi/network/NetworkPreferences.kt +++ b/core/common/src/main/kotlin/eu/kanade/tachiyomi/network/NetworkPreferences.kt @@ -12,6 +12,14 @@ class NetworkPreferences( return preferenceStore.getBoolean("verbose_logging", verboseLogging) } + fun enableFlareSolverr(): Preference { + return preferenceStore.getBoolean("enable_flare_solverr", false) + } + + fun flareSolverrUrl(): Preference { + return preferenceStore.getString("flare_solverr_url", "http://localhost:8191/v1") + } + fun dohProvider(): Preference { return preferenceStore.getInt("doh_provider", -1) } diff --git a/core/common/src/main/kotlin/eu/kanade/tachiyomi/network/interceptor/CloudflareInterceptor.kt b/core/common/src/main/kotlin/eu/kanade/tachiyomi/network/interceptor/CloudflareInterceptor.kt index 6a765c680ee8..adde65311fb0 100755 --- a/core/common/src/main/kotlin/eu/kanade/tachiyomi/network/interceptor/CloudflareInterceptor.kt +++ b/core/common/src/main/kotlin/eu/kanade/tachiyomi/network/interceptor/CloudflareInterceptor.kt @@ -6,6 +6,7 @@ import android.webkit.WebView import android.widget.Toast import androidx.core.content.ContextCompat import eu.kanade.tachiyomi.network.AndroidCookieJar +import eu.kanade.tachiyomi.network.NetworkPreferences import eu.kanade.tachiyomi.util.system.WebViewClientCompat import eu.kanade.tachiyomi.util.system.isOutdated import eu.kanade.tachiyomi.util.system.toast @@ -22,12 +23,18 @@ import java.util.concurrent.CountDownLatch class CloudflareInterceptor( private val context: Context, private val cookieManager: AndroidCookieJar, + private val preferences: NetworkPreferences, defaultUserAgentProvider: () -> String, ) : WebViewInterceptor(context, defaultUserAgentProvider) { private val executor = ContextCompat.getMainExecutor(context) override fun shouldIntercept(response: Response): Boolean { + // Check if FlareSolverr is enabled if it's enabled we don't need to bypass Cloudflare through WebView + if (preferences.enableFlareSolverr().get()) { + return false + } + // Check if Cloudflare anti-bot is on return response.code in ERROR_CODES && response.header("Server") in SERVER_CHECK } @@ -134,13 +141,13 @@ class CloudflareInterceptor( context.toast(MR.strings.information_webview_outdated, Toast.LENGTH_LONG) } - throw CloudflareBypassException() + throw CloudflareBypassException("Error resolving with WebView") } } } -private val ERROR_CODES = listOf(403, 503) -private val SERVER_CHECK = arrayOf("cloudflare-nginx", "cloudflare") +val ERROR_CODES = listOf(403, 503) +val SERVER_CHECK = arrayOf("cloudflare-nginx", "cloudflare") private val COOKIE_NAMES = listOf("cf_clearance") -private class CloudflareBypassException : Exception() +class CloudflareBypassException(message: String, cause: Throwable? = null) : Exception(message, cause) diff --git a/core/common/src/main/kotlin/eu/kanade/tachiyomi/network/interceptor/FlareSolverrInterceptor.kt b/core/common/src/main/kotlin/eu/kanade/tachiyomi/network/interceptor/FlareSolverrInterceptor.kt new file mode 100644 index 000000000000..13f81aadf56c --- /dev/null +++ b/core/common/src/main/kotlin/eu/kanade/tachiyomi/network/interceptor/FlareSolverrInterceptor.kt @@ -0,0 +1,210 @@ +package eu.kanade.tachiyomi.network.interceptor + +import android.util.Log +import android.webkit.CookieManager +import eu.kanade.tachiyomi.network.NetworkHelper +import eu.kanade.tachiyomi.network.NetworkPreferences +import eu.kanade.tachiyomi.network.POST +import eu.kanade.tachiyomi.network.awaitSuccess +import eu.kanade.tachiyomi.network.parseAs +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json +import okhttp3.Interceptor +import okhttp3.MediaType.Companion.toMediaType +import okhttp3.Request +import okhttp3.RequestBody.Companion.toRequestBody +import okhttp3.Response +import okio.IOException +import uy.kohesive.injekt.injectLazy +import java.time.ZonedDateTime +import java.time.format.DateTimeFormatter + +class FlareSolverrInterceptor(private val preferences: NetworkPreferences) : Interceptor { + override fun intercept(chain: Interceptor.Chain): Response { + val originalRequest = chain.request() + + val originalResponse = chain.proceed(originalRequest) + + // Check if Cloudflare anti-bot is on + if (!(originalResponse.code in ERROR_CODES && originalResponse.header("Server") in SERVER_CHECK)) { + return originalResponse + } + + // FlareSolverr is disabled, so just proceed with the request. + if (!preferences.enableFlareSolverr().get()) { + return chain.proceed(originalRequest) + } + + Log.d("FlareSolverrInterceptor", "Intercepting request: ${originalRequest.url}") + + return try { + originalResponse.close() + + val request = + runBlocking { + CFClearance.resolveWithFlareSolverr(originalRequest) + } + + chain.proceed(request) + } catch (e: Exception) { + // Because OkHttp's enqueue only handles IOExceptions, wrap the exception so that + // we don't crash the entire app + throw IOException(e) + } + } + + object CFClearance { + private val network: NetworkHelper by injectLazy() + private val json: Json by injectLazy() + private val jsonMediaType = "application/json".toMediaType() + private val networkPreferences: NetworkPreferences by injectLazy() + private val flareSolverrUrl = networkPreferences.flareSolverrUrl().get() + private val mutex = Mutex() + + @Serializable + data class FlareSolverCookie( + val name: String, + val value: String, + ) + + @Serializable + data class FlareSolverRequest( + val cmd: String, + val url: String, + val maxTimeout: Int? = null, + val session: List? = null, + @SerialName("session_ttl_minutes") + val sessionTtlMinutes: Int? = null, + val cookies: List? = null, + val returnOnlyCookies: Boolean? = null, + val proxy: String? = null, + val postData: String? = null, // only used with cmd 'request.post' + ) + + @Serializable + data class FlareSolverSolutionCookie( + val name: String, + val value: String, + val domain: String, + val path: String, + val expires: Double? = null, + val size: Int? = null, + val httpOnly: Boolean, + val secure: Boolean, + val session: Boolean? = null, + val sameSite: String, + ) + + @Serializable + data class FlareSolverSolution( + val url: String, + val status: Int, + val headers: Map? = null, + val response: String? = null, + val cookies: List, + val userAgent: String, + ) + + @Serializable + data class FlareSolverResponse( + val solution: FlareSolverSolution, + val status: String, + val message: String, + val startTimestamp: Long, + val endTimestamp: Long, + val version: String, + ) + + suspend fun resolveWithFlareSolverr( + originalRequest: Request, + cookieManager: CookieManager = CookieManager.getInstance(), + ): Request { + val flareSolverTag = "FlareSolverr" + + Log.d( flareSolverTag, "Requesting challenge solution for ${originalRequest.url}") + + val flareSolverResponse = + with(json) { + mutex.withLock { + network.client.newCall( + POST( + url = flareSolverrUrl, + body = + Json.encodeToString( + FlareSolverRequest( + "request.get", + originalRequest.url.toString(), + cookies = + network.cookieJar.get(originalRequest.url).map { + FlareSolverCookie(it.name, it.value) + }, + returnOnlyCookies = true, + maxTimeout = 30000, + ), + ).toRequestBody(jsonMediaType), + ), + ).awaitSuccess().parseAs() + } + } + + if (flareSolverResponse.solution.status in 200..299) { + Log.d(flareSolverTag, "Received challenge solution for ${originalRequest.url}") + Log.d(flareSolverTag, "Received cookies from FlareSolverr\n${flareSolverResponse.solution.cookies.joinToString("; ")}") + + flareSolverResponse.solution.cookies.forEach { cookie -> + Log.d(flareSolverTag, "Creating cookie for ${cookie.name}") + try { + val domain = cookie.domain.removePrefix(".") + val cookieString = buildCookieString(cookie, domain) + Log.d(flareSolverTag, "Adding cookie string to CookieManager: $cookieString") + cookieManager.setCookie("https://$domain", cookieString) + } catch (e: Exception) { + Log.e(flareSolverTag, "Error creating cookie for ${cookie.name}", e) + throw e + } + } + + // Verify if the cookies are set correctly + val allCookies = flareSolverResponse.solution.cookies.mapNotNull { cookie -> + val domain = cookie.domain.removePrefix(".") + val setCookie = cookieManager.getCookie("https://$domain") + Log.d(flareSolverTag, "Set cookies in CookieManager for $domain: $setCookie") + setCookie + }.joinToString("; ") + + Log.d(flareSolverTag, "Final cookies\n$allCookies") + + return originalRequest.newBuilder() + .header("Cookie", allCookies) + .header("User-Agent", flareSolverResponse.solution.userAgent) + .build() + } else { + Log.d(flareSolverTag, "Failed to solve challenge: ${flareSolverResponse.message}") + throw CloudflareBypassException() + } + } + + private fun buildCookieString(cookie: FlareSolverSolutionCookie, domain: String): String { + val formatter = DateTimeFormatter.RFC_1123_DATE_TIME + val expires = if (cookie.expires != null && cookie.expires > 0) { + ZonedDateTime.now().plusSeconds(cookie.expires.toLong()).format(formatter) + } else { + "Fri, 31 Dec 9999 23:59:59 GMT" + } + + return StringBuilder().apply { + append("${cookie.name}=${cookie.value}; Domain=$domain; Path=${cookie.path}; Expires=$expires;") + if (cookie.httpOnly) append(" HttpOnly;") + if (cookie.secure) append(" Secure;") + }.toString() + } + + + private class CloudflareBypassException : Exception() + } +} diff --git a/i18n-sy/src/commonMain/resources/MR/base/strings.xml b/i18n-sy/src/commonMain/resources/MR/base/strings.xml index d59691a2bd64..23393e6b92c8 100644 --- a/i18n-sy/src/commonMain/resources/MR/base/strings.xml +++ b/i18n-sy/src/commonMain/resources/MR/base/strings.xml @@ -153,6 +153,9 @@ Bandwidth Hero Proxy Server Put Bandwidth Hero Proxy server url here Keep entries with read chapters + FlareSolverr is working. User agent updated. Please restart the app + FlareSolverr is not working. User agent not updated. Please check your settings + Error contacting FlareSolverr Minimal diff --git a/i18n/src/commonMain/resources/MR/base/strings.xml b/i18n/src/commonMain/resources/MR/base/strings.xml index 7b72cd3b7e95..c0d456b26e5e 100755 --- a/i18n/src/commonMain/resources/MR/base/strings.xml +++ b/i18n/src/commonMain/resources/MR/base/strings.xml @@ -584,8 +584,14 @@ Verbose logging Print verbose logs to system log (reduces app performance) Debug info - - + Enable FlareSolverr + Use FlareSolverr to bypass Cloudflare\'s anti-bot protection + FlareSolverr URL + URL of the FlareSolverr instance to use for example http://192.168.1.202:8191/v1 + Test FlareSolverr and update user agent + Test FlareSolverr to update the user agent. Only required once; then toggle as needed. + + Website Version What\'s new