diff --git a/server/src/main/kotlin/eu/kanade/tachiyomi/network/interceptor/CloudflareInterceptor.kt b/server/src/main/kotlin/eu/kanade/tachiyomi/network/interceptor/CloudflareInterceptor.kt index 3b318803..ff0860fc 100644 --- a/server/src/main/kotlin/eu/kanade/tachiyomi/network/interceptor/CloudflareInterceptor.kt +++ b/server/src/main/kotlin/eu/kanade/tachiyomi/network/interceptor/CloudflareInterceptor.kt @@ -23,6 +23,7 @@ import okhttp3.MediaType.Companion.toMediaType import okhttp3.Request import okhttp3.RequestBody.Companion.toRequestBody import okhttp3.Response +import okhttp3.ResponseBody.Companion.toResponseBody import suwayomi.tachidesk.server.serverConfig import uy.kohesive.injekt.injectLazy import java.io.IOException @@ -56,11 +57,38 @@ class CloudflareInterceptor( originalResponse.close() // network.cookieStore.remove(originalRequest.url.toUri()) - val request = + val flareResponseFallback = serverConfig.flareSolverrAsResponseFallback.value + val flareResponse = runBlocking { - CFClearance.resolveWithFlareSolverr(setUserAgent, originalRequest) + CFClearance.resolveWithFlareSolver(originalRequest, !flareResponseFallback) } + if (flareResponse.message.contains("not detected", ignoreCase = true)) { + logger.debug { "FlareSolverr failed to detect Cloudflare challenge" } + + if (flareResponseFallback && + flareResponse.solution.status in 200..299 && + flareResponse.solution.response != null + ) { + val isImage = flareResponse.solution.response.contains(CHROME_IMAGE_TEMPLATE_REGEX) + if (!isImage) { + logger.debug { "Falling back to FlareSolverr response" } + + setUserAgent(flareResponse.solution.userAgent) + + return originalResponse + .newBuilder() + .code(flareResponse.solution.status) + .body(flareResponse.solution.response.toResponseBody()) + .build() + } else { + logger.debug { "FlareSolverr response is an image html template, not falling back" } + } + } + } + + val request = CFClearance.requestWithFlareSolverr(flareResponse, setUserAgent, originalRequest) + chain.proceed(request) } catch (e: Exception) { // Because OkHttp's enqueue only handles IOExceptions, wrap the exception so that @@ -73,6 +101,7 @@ class CloudflareInterceptor( private val ERROR_CODES = listOf(403, 503) private val SERVER_CHECK = arrayOf("cloudflare-nginx", "cloudflare") private val COOKIE_NAMES = listOf("cf_clearance") + private val CHROME_IMAGE_TEMPLATE_REGEX = Regex("""(.*?) \(\d+×\d+\)""") } } @@ -153,37 +182,43 @@ object CFClearance { val version: String, ) - suspend fun resolveWithFlareSolverr( + suspend fun resolveWithFlareSolver( + originalRequest: Request, + onlyCookies: Boolean, + ): FlareSolverResponse { + val timeout = serverConfig.flareSolverrTimeout.value.seconds + + return with(json) { + mutex.withLock { + client.value.newCall( + POST( + url = serverConfig.flareSolverrUrl.value.removeSuffix("/") + "/v1", + body = + Json.encodeToString( + FlareSolverRequest( + "request.get", + originalRequest.url.toString(), + session = serverConfig.flareSolverrSessionName.value, + sessionTtlMinutes = serverConfig.flareSolverrSessionTtl.value, + cookies = + network.cookieStore.get(originalRequest.url).map { + FlareSolverCookie(it.name, it.value) + }, + returnOnlyCookies = onlyCookies, + maxTimeout = timeout.inWholeMilliseconds.toInt(), + ), + ).toRequestBody(jsonMediaType), + ), + ).awaitSuccess().parseAs() + } + } + } + + fun requestWithFlareSolverr( + flareSolverResponse: FlareSolverResponse, setUserAgent: (String) -> Unit, originalRequest: Request, ): Request { - val timeout = serverConfig.flareSolverrTimeout.value.seconds - val flareSolverResponse = - with(json) { - mutex.withLock { - client.value.newCall( - POST( - url = serverConfig.flareSolverrUrl.value.removeSuffix("/") + "/v1", - body = - Json.encodeToString( - FlareSolverRequest( - "request.get", - originalRequest.url.toString(), - session = serverConfig.flareSolverrSessionName.value, - sessionTtlMinutes = serverConfig.flareSolverrSessionTtl.value, - cookies = - network.cookieStore.get(originalRequest.url).map { - FlareSolverCookie(it.name, it.value) - }, - returnOnlyCookies = true, - maxTimeout = timeout.inWholeMilliseconds.toInt(), - ), - ).toRequestBody(jsonMediaType), - ), - ).awaitSuccess().parseAs() - } - } - if (flareSolverResponse.solution.status in 200..299) { setUserAgent(flareSolverResponse.solution.userAgent) val cookies = diff --git a/server/src/main/kotlin/suwayomi/tachidesk/graphql/mutations/SettingsMutation.kt b/server/src/main/kotlin/suwayomi/tachidesk/graphql/mutations/SettingsMutation.kt index fc083ca1..325f749f 100644 --- a/server/src/main/kotlin/suwayomi/tachidesk/graphql/mutations/SettingsMutation.kt +++ b/server/src/main/kotlin/suwayomi/tachidesk/graphql/mutations/SettingsMutation.kt @@ -98,6 +98,7 @@ class SettingsMutation { updateSetting(settings.flareSolverrTimeout, serverConfig.flareSolverrTimeout) updateSetting(settings.flareSolverrSessionName, serverConfig.flareSolverrSessionName) updateSetting(settings.flareSolverrSessionTtl, serverConfig.flareSolverrSessionTtl) + updateSetting(settings.flareSolverrAsResponseFallback, serverConfig.flareSolverrAsResponseFallback) } fun setSettings(input: SetSettingsInput): SetSettingsPayload { diff --git a/server/src/main/kotlin/suwayomi/tachidesk/graphql/types/SettingsType.kt b/server/src/main/kotlin/suwayomi/tachidesk/graphql/types/SettingsType.kt index a980d5f4..8e0c0978 100644 --- a/server/src/main/kotlin/suwayomi/tachidesk/graphql/types/SettingsType.kt +++ b/server/src/main/kotlin/suwayomi/tachidesk/graphql/types/SettingsType.kt @@ -89,6 +89,7 @@ interface Settings : Node { val flareSolverrTimeout: Int? val flareSolverrSessionName: String? val flareSolverrSessionTtl: Int? + val flareSolverrAsResponseFallback: Boolean? } data class PartialSettingsType( @@ -151,6 +152,7 @@ data class PartialSettingsType( override val flareSolverrTimeout: Int?, override val flareSolverrSessionName: String?, override val flareSolverrSessionTtl: Int?, + override val flareSolverrAsResponseFallback: Boolean?, ) : Settings class SettingsType( @@ -213,6 +215,7 @@ class SettingsType( override val flareSolverrTimeout: Int, override val flareSolverrSessionName: String, override val flareSolverrSessionTtl: Int, + override val flareSolverrAsResponseFallback: Boolean, ) : Settings { constructor(config: ServerConfig = serverConfig) : this( config.ip.value, @@ -270,5 +273,6 @@ class SettingsType( config.flareSolverrTimeout.value, config.flareSolverrSessionName.value, config.flareSolverrSessionTtl.value, + config.flareSolverrAsResponseFallback.value, ) } diff --git a/server/src/main/kotlin/suwayomi/tachidesk/server/ServerConfig.kt b/server/src/main/kotlin/suwayomi/tachidesk/server/ServerConfig.kt index 21b7c7f3..71655d57 100644 --- a/server/src/main/kotlin/suwayomi/tachidesk/server/ServerConfig.kt +++ b/server/src/main/kotlin/suwayomi/tachidesk/server/ServerConfig.kt @@ -141,6 +141,7 @@ class ServerConfig(getConfig: () -> Config, val moduleName: String = SERVER_CONF val flareSolverrTimeout: MutableStateFlow by OverrideConfigValue(IntConfigAdapter) val flareSolverrSessionName: MutableStateFlow by OverrideConfigValue(StringConfigAdapter) val flareSolverrSessionTtl: MutableStateFlow by OverrideConfigValue(IntConfigAdapter) + val flareSolverrAsResponseFallback: MutableStateFlow by OverrideConfigValue(BooleanConfigAdapter) @OptIn(ExperimentalCoroutinesApi::class) fun subscribeTo( diff --git a/server/src/main/resources/server-reference.conf b/server/src/main/resources/server-reference.conf index 0cf00c4f..dee08efb 100644 --- a/server/src/main/resources/server-reference.conf +++ b/server/src/main/resources/server-reference.conf @@ -67,3 +67,4 @@ server.flareSolverrUrl = "http://localhost:8191" server.flareSolverrTimeout = 60 # time in seconds server.flareSolverrSessionName = "suwayomi" server.flareSolverrSessionTtl = 15 # time in minutes +server.flareSolverrAsResponseFallback = false diff --git a/server/src/test/resources/server-reference.conf b/server/src/test/resources/server-reference.conf index 0cf00c4f..dee08efb 100644 --- a/server/src/test/resources/server-reference.conf +++ b/server/src/test/resources/server-reference.conf @@ -67,3 +67,4 @@ server.flareSolverrUrl = "http://localhost:8191" server.flareSolverrTimeout = 60 # time in seconds server.flareSolverrSessionName = "suwayomi" server.flareSolverrSessionTtl = 15 # time in minutes +server.flareSolverrAsResponseFallback = false