mirror of
https://github.com/Suwayomi/Suwayomi-Server.git
synced 2026-07-04 03:14:40 -05:00
WebView based cloudflare interceptor (#456)
* WebView based cloudflare interceptor ported https://github.com/vvanglro/cf-clearance to kotlin * code clean up * Forgot to commit these * Get ResolveWithWebView working 1. Make sure to .use all closeable resources 2. Use 10 seconds instead of 1 second for waiting for cloudflare(this was the most probable issue) 3. Use Extension UA when possible 4. Minor cleanup of logging * rewrite and refactor Co-authored-by: Syer10 <syer10@users.noreply.github.com>
This commit is contained in:
@@ -18,8 +18,8 @@ allprojects {
|
|||||||
repositories {
|
repositories {
|
||||||
mavenCentral()
|
mavenCentral()
|
||||||
google()
|
google()
|
||||||
maven("https://jitpack.io")
|
|
||||||
maven("https://github.com/Suwayomi/Tachidesk-Server/raw/android-jar/")
|
maven("https://github.com/Suwayomi/Tachidesk-Server/raw/android-jar/")
|
||||||
|
maven("https://jitpack.io")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -57,7 +57,7 @@ dependencies {
|
|||||||
implementation("com.github.junrar:junrar:7.5.3")
|
implementation("com.github.junrar:junrar:7.5.3")
|
||||||
|
|
||||||
// CloudflareInterceptor
|
// CloudflareInterceptor
|
||||||
implementation("net.sourceforge.htmlunit:htmlunit:2.65.1")
|
implementation("com.microsoft.playwright:playwright:1.28.0")
|
||||||
|
|
||||||
// AES/CBC/PKCS7Padding Cypher provider for zh.copymanga
|
// AES/CBC/PKCS7Padding Cypher provider for zh.copymanga
|
||||||
implementation("org.bouncycastle:bcprov-jdk18on:1.72")
|
implementation("org.bouncycastle:bcprov-jdk18on:1.72")
|
||||||
|
|||||||
@@ -39,6 +39,7 @@ class NetworkHelper(context: Context) {
|
|||||||
.cookieJar(cookieManager)
|
.cookieJar(cookieManager)
|
||||||
.connectTimeout(30, TimeUnit.SECONDS)
|
.connectTimeout(30, TimeUnit.SECONDS)
|
||||||
.readTimeout(30, TimeUnit.SECONDS)
|
.readTimeout(30, TimeUnit.SECONDS)
|
||||||
|
.callTimeout(2, TimeUnit.MINUTES)
|
||||||
.addInterceptor(UserAgentInterceptor())
|
.addInterceptor(UserAgentInterceptor())
|
||||||
|
|
||||||
if (serverConfig.debugLogsEnabled) {
|
if (serverConfig.debugLogsEnabled) {
|
||||||
|
|||||||
@@ -62,7 +62,7 @@ suspend fun Call.await(): Response {
|
|||||||
object : Callback {
|
object : Callback {
|
||||||
override fun onResponse(call: Call, response: Response) {
|
override fun onResponse(call: Call, response: Response) {
|
||||||
if (!response.isSuccessful) {
|
if (!response.isSuccessful) {
|
||||||
continuation.resumeWithException(Exception("HTTP error ${response.code}"))
|
continuation.resumeWithException(HttpException(response.code))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -94,7 +94,7 @@ fun Call.asObservableSuccess(): Observable<Response> {
|
|||||||
.doOnNext { response ->
|
.doOnNext { response ->
|
||||||
if (!response.isSuccessful) {
|
if (!response.isSuccessful) {
|
||||||
response.close()
|
response.close()
|
||||||
throw Exception("HTTP error ${response.code}")
|
throw HttpException(response.code)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -136,3 +136,5 @@ inline fun <reified T> Response.parseAs(): T {
|
|||||||
return json.decodeFromString(responseBody)
|
return json.decodeFromString(responseBody)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
class HttpException(val code: Int) : IllegalStateException("HTTP error $code")
|
||||||
|
|||||||
@@ -1,19 +1,24 @@
|
|||||||
package eu.kanade.tachiyomi.network.interceptor
|
package eu.kanade.tachiyomi.network.interceptor
|
||||||
|
|
||||||
import com.gargoylesoftware.htmlunit.BrowserVersion
|
import com.microsoft.playwright.Browser
|
||||||
import com.gargoylesoftware.htmlunit.WebClient
|
import com.microsoft.playwright.BrowserType.LaunchOptions
|
||||||
import com.gargoylesoftware.htmlunit.html.HtmlPage
|
import com.microsoft.playwright.Page
|
||||||
|
import com.microsoft.playwright.Playwright
|
||||||
import eu.kanade.tachiyomi.network.NetworkHelper
|
import eu.kanade.tachiyomi.network.NetworkHelper
|
||||||
|
import eu.kanade.tachiyomi.network.interceptor.CFClearance.resolveWithWebView
|
||||||
import mu.KotlinLogging
|
import mu.KotlinLogging
|
||||||
import okhttp3.Cookie
|
import okhttp3.Cookie
|
||||||
import okhttp3.HttpUrl
|
import okhttp3.HttpUrl
|
||||||
import okhttp3.Interceptor
|
import okhttp3.Interceptor
|
||||||
import okhttp3.Request
|
import okhttp3.Request
|
||||||
import okhttp3.Response
|
import okhttp3.Response
|
||||||
|
import suwayomi.tachidesk.server.ServerConfig
|
||||||
|
import suwayomi.tachidesk.server.serverConfig
|
||||||
import uy.kohesive.injekt.injectLazy
|
import uy.kohesive.injekt.injectLazy
|
||||||
import java.io.IOException
|
import java.io.IOException
|
||||||
|
import kotlin.time.Duration.Companion.seconds
|
||||||
|
import kotlin.time.DurationUnit
|
||||||
|
|
||||||
// from TachiWeb-Server
|
|
||||||
class CloudflareInterceptor : Interceptor {
|
class CloudflareInterceptor : Interceptor {
|
||||||
private val logger = KotlinLogging.logger {}
|
private val logger = KotlinLogging.logger {}
|
||||||
|
|
||||||
@@ -25,20 +30,22 @@ class CloudflareInterceptor : Interceptor {
|
|||||||
|
|
||||||
logger.trace { "CloudflareInterceptor is being used." }
|
logger.trace { "CloudflareInterceptor is being used." }
|
||||||
|
|
||||||
val response = chain.proceed(originalRequest)
|
val originalResponse = chain.proceed(chain.request())
|
||||||
|
|
||||||
// Check if Cloudflare anti-bot is on
|
// Check if Cloudflare anti-bot is on
|
||||||
if (response.code != 503 || response.header("Server") !in SERVER_CHECK) {
|
if (!(originalResponse.code in ERROR_CODES && originalResponse.header("Server") in SERVER_CHECK)) {
|
||||||
return response
|
return originalResponse
|
||||||
}
|
}
|
||||||
|
|
||||||
logger.debug { "Cloudflare anti-bot is on, CloudflareInterceptor is kicking in..." }
|
logger.debug { "Cloudflare anti-bot is on, CloudflareInterceptor is kicking in..." }
|
||||||
|
|
||||||
return try {
|
return try {
|
||||||
response.close()
|
originalResponse.close()
|
||||||
network.cookies.remove(originalRequest.url.toUri())
|
network.cookies.remove(originalRequest.url.toUri())
|
||||||
|
|
||||||
chain.proceed(resolveChallenge(response))
|
val request = resolveWithWebView(originalRequest)
|
||||||
|
|
||||||
|
chain.proceed(request)
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
// Because OkHttp's enqueue only handles IOExceptions, wrap the exception so that
|
// Because OkHttp's enqueue only handles IOExceptions, wrap the exception so that
|
||||||
// we don't crash the entire app
|
// we don't crash the entire app
|
||||||
@@ -46,65 +53,148 @@ class CloudflareInterceptor : Interceptor {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun resolveChallenge(response: Response): Request {
|
companion object {
|
||||||
val browserVersion = BrowserVersion.BrowserVersionBuilder(BrowserVersion.BEST_SUPPORTED)
|
private val ERROR_CODES = listOf(403, 503)
|
||||||
.setUserAgent(response.request.header("User-Agent") ?: BrowserVersion.BEST_SUPPORTED.userAgent)
|
private val SERVER_CHECK = arrayOf("cloudflare-nginx", "cloudflare")
|
||||||
.build()
|
private val COOKIE_NAMES = listOf("cf_clearance")
|
||||||
val convertedCookies = WebClient(browserVersion).use { webClient ->
|
}
|
||||||
webClient.options.isThrowExceptionOnFailingStatusCode = false
|
}
|
||||||
webClient.options.isThrowExceptionOnScriptError = false
|
|
||||||
webClient.getPage<HtmlPage>(response.request.url.toString())
|
/*
|
||||||
webClient.waitForBackgroundJavaScript(10000)
|
* This class is ported from https://github.com/vvanglro/cf-clearance
|
||||||
// Challenge solved, process cookies
|
* The original code is licensed under Apache 2.0
|
||||||
webClient.cookieManager.cookies.filter {
|
*/
|
||||||
// Only include Cloudflare cookies
|
object CFClearance {
|
||||||
it.name.startsWith("__cf") || it.name.startsWith("cf_")
|
private val logger = KotlinLogging.logger {}
|
||||||
}.map {
|
private val network: NetworkHelper by injectLazy()
|
||||||
// Convert cookies -> OkHttp format
|
|
||||||
Cookie.Builder()
|
fun resolveWithWebView(originalRequest: Request): Request {
|
||||||
.domain(it.domain.removePrefix("."))
|
val url = originalRequest.url.toString()
|
||||||
.expiresAt(it.expires?.time ?: Long.MAX_VALUE)
|
|
||||||
.name(it.name)
|
logger.debug { "resolveWithWebView($url)" }
|
||||||
.path(it.path)
|
|
||||||
.value(it.value).apply {
|
val cookies = Playwright.create().use { playwright ->
|
||||||
if (it.isHttpOnly) httpOnly()
|
playwright.chromium().launch(
|
||||||
if (it.isSecure) secure()
|
LaunchOptions()
|
||||||
}.build()
|
.setHeadless(false)
|
||||||
|
.apply {
|
||||||
|
if (serverConfig.socksProxyEnabled) {
|
||||||
|
setProxy("socks5://${serverConfig.socksProxyHost}:${serverConfig.socksProxyPort}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
).use { browser ->
|
||||||
|
val userAgent = originalRequest.header("User-Agent")
|
||||||
|
if (userAgent != null) {
|
||||||
|
browser.newContext(Browser.NewContextOptions().setUserAgent(userAgent)).use { browserContext ->
|
||||||
|
browserContext.newPage().use { getCookies(it, url) }
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
browser.newPage().use { getCookies(it, url) }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Copy cookies to cookie store
|
// Copy cookies to cookie store
|
||||||
convertedCookies.forEach {
|
cookies.groupBy { it.domain }.forEach { (domain, cookies) ->
|
||||||
network.cookies.addAll(
|
network.cookies.addAll(
|
||||||
HttpUrl.Builder()
|
url = HttpUrl.Builder()
|
||||||
.scheme("http")
|
.scheme("http")
|
||||||
.host(it.domain)
|
.host(domain)
|
||||||
.build(),
|
.build(),
|
||||||
listOf(it)
|
cookies = cookies
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
// Merge new and existing cookies for this request
|
// Merge new and existing cookies for this request
|
||||||
// Find the cookies that we need to merge into this request
|
// Find the cookies that we need to merge into this request
|
||||||
val convertedForThisRequest = convertedCookies.filter {
|
val convertedForThisRequest = cookies.filter {
|
||||||
it.matches(response.request.url)
|
it.matches(originalRequest.url)
|
||||||
}
|
}
|
||||||
// Extract cookies from current request
|
// Extract cookies from current request
|
||||||
val existingCookies = Cookie.parseAll(
|
val existingCookies = Cookie.parseAll(
|
||||||
response.request.url,
|
originalRequest.url,
|
||||||
response.request.headers
|
originalRequest.headers
|
||||||
)
|
)
|
||||||
// Filter out existing values of cookies that we are about to merge in
|
// Filter out existing values of cookies that we are about to merge in
|
||||||
val filteredExisting = existingCookies.filter { existing ->
|
val filteredExisting = existingCookies.filter { existing ->
|
||||||
convertedForThisRequest.none { converted -> converted.name == existing.name }
|
convertedForThisRequest.none { converted -> converted.name == existing.name }
|
||||||
}
|
}
|
||||||
|
logger.trace { "Existing cookies" }
|
||||||
|
logger.trace { existingCookies.joinToString("; ") }
|
||||||
val newCookies = filteredExisting + convertedForThisRequest
|
val newCookies = filteredExisting + convertedForThisRequest
|
||||||
return response.request.newBuilder()
|
logger.trace { "New cookies" }
|
||||||
.header("Cookie", newCookies.map { it.toString() }.joinToString("; "))
|
logger.trace { newCookies.joinToString("; ") }
|
||||||
|
return originalRequest.newBuilder()
|
||||||
|
.header("Cookie", newCookies.joinToString("; ") { "${it.name}=${it.value}" })
|
||||||
.build()
|
.build()
|
||||||
}
|
}
|
||||||
|
|
||||||
companion object {
|
private fun getCookies(page: Page, url: String): List<Cookie> {
|
||||||
private val SERVER_CHECK = arrayOf("cloudflare-nginx", "cloudflare")
|
applyStealthInitScripts(page)
|
||||||
private val COOKIE_NAMES = listOf("cf_clearance")
|
page.navigate(url)
|
||||||
|
val challengeResolved = waitForChallengeResolve(page)
|
||||||
|
|
||||||
|
return if (challengeResolved) {
|
||||||
|
val cookies = page.context().cookies()
|
||||||
|
|
||||||
|
logger.debug {
|
||||||
|
val userAgent = page.evaluate("() => {return navigator.userAgent}")
|
||||||
|
"Playwright User-Agent is $userAgent"
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convert PlayWright cookies to OkHttp cookies
|
||||||
|
cookies.map {
|
||||||
|
Cookie.Builder()
|
||||||
|
.domain(it.domain.removePrefix("."))
|
||||||
|
.expiresAt(it.expires?.times(1000)?.toLong() ?: Long.MAX_VALUE)
|
||||||
|
.name(it.name)
|
||||||
|
.path(it.path)
|
||||||
|
.value(it.value).apply {
|
||||||
|
if (it.httpOnly) httpOnly()
|
||||||
|
if (it.secure) secure()
|
||||||
|
}.build()
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
logger.debug { "Cloudflare challenge failed to resolve" }
|
||||||
|
throw CloudflareBypassException()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ref: https://github.com/vvanglro/cf-clearance/blob/44124a8f06d8d0ecf2bf558a027082ff88dab435/cf_clearance/stealth.py#L18
|
||||||
|
private val stealthInitScripts by lazy {
|
||||||
|
arrayOf(
|
||||||
|
ServerConfig::class.java.getResource("/cloudflare-js/canvas.fingerprinting.js")!!.readText(),
|
||||||
|
ServerConfig::class.java.getResource("/cloudflare-js/chrome.global.js")!!.readText(),
|
||||||
|
ServerConfig::class.java.getResource("/cloudflare-js/emulate.touch.js")!!.readText(),
|
||||||
|
ServerConfig::class.java.getResource("/cloudflare-js/navigator.permissions.js")!!.readText(),
|
||||||
|
ServerConfig::class.java.getResource("/cloudflare-js/navigator.webdriver.js")!!.readText(),
|
||||||
|
ServerConfig::class.java.getResource("/cloudflare-js/chrome.runtime.js")!!.readText(),
|
||||||
|
ServerConfig::class.java.getResource("/cloudflare-js/chrome.plugin.js")!!.readText()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ref: https://github.com/vvanglro/cf-clearance/blob/44124a8f06d8d0ecf2bf558a027082ff88dab435/cf_clearance/stealth.py#L76
|
||||||
|
private fun applyStealthInitScripts(page: Page) {
|
||||||
|
for (script in stealthInitScripts) {
|
||||||
|
page.addInitScript(script)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ref: https://github.com/vvanglro/cf-clearance/blob/44124a8f06d8d0ecf2bf558a027082ff88dab435/cf_clearance/retry.py#L21
|
||||||
|
private fun waitForChallengeResolve(page: Page): Boolean {
|
||||||
|
// sometimes the user has to solve the captcha challenge manually, potentially wait a long time
|
||||||
|
val timeoutSeconds = 120
|
||||||
|
repeat(timeoutSeconds) {
|
||||||
|
page.waitForTimeout(1.seconds.toDouble(DurationUnit.MILLISECONDS))
|
||||||
|
val success = try {
|
||||||
|
page.querySelector("#challenge-form") == null
|
||||||
|
} catch (e: Exception) {
|
||||||
|
logger.debug(e) { "query Error" }
|
||||||
|
false
|
||||||
|
}
|
||||||
|
if (success) return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
private class CloudflareBypassException : Exception()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,28 @@
|
|||||||
|
(function () {
|
||||||
|
const ORIGINAL_CANVAS = HTMLCanvasElement.prototype[name];
|
||||||
|
Object.defineProperty(HTMLCanvasElement.prototype, name, {
|
||||||
|
"value": function () {
|
||||||
|
var shift = {
|
||||||
|
'r': Math.floor(Math.random() * 10) - 5,
|
||||||
|
'g': Math.floor(Math.random() * 10) - 5,
|
||||||
|
'b': Math.floor(Math.random() * 10) - 5,
|
||||||
|
'a': Math.floor(Math.random() * 10) - 5
|
||||||
|
};
|
||||||
|
var width = this.width,
|
||||||
|
height = this.height,
|
||||||
|
context = this.getContext("2d");
|
||||||
|
var imageData = context.getImageData(0, 0, width, height);
|
||||||
|
for (var i = 0; i < height; i++) {
|
||||||
|
for (var j = 0; j < width; j++) {
|
||||||
|
var n = ((i * (width * 4)) + (j * 4));
|
||||||
|
imageData.data[n + 0] = imageData.data[n + 0] + shift.r;
|
||||||
|
imageData.data[n + 1] = imageData.data[n + 1] + shift.g;
|
||||||
|
imageData.data[n + 2] = imageData.data[n + 2] + shift.b;
|
||||||
|
imageData.data[n + 3] = imageData.data[n + 3] + shift.a;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
context.putImageData(imageData, 0, 0);
|
||||||
|
return ORIGINAL_CANVAS.apply(this, arguments);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
})(this);
|
||||||
52
server/src/main/resources/cloudflare-js/chrome.global.js
Normal file
52
server/src/main/resources/cloudflare-js/chrome.global.js
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
Object.defineProperty(window, 'chrome', {
|
||||||
|
value: new Proxy(window.chrome, {
|
||||||
|
has: (target, key) => true,
|
||||||
|
get: (target, key) => {
|
||||||
|
return {
|
||||||
|
app: {
|
||||||
|
isInstalled: false,
|
||||||
|
},
|
||||||
|
webstore: {
|
||||||
|
onInstallStageChanged: {},
|
||||||
|
onDownloadProgress: {},
|
||||||
|
},
|
||||||
|
runtime: {
|
||||||
|
PlatformOs: {
|
||||||
|
MAC: 'mac',
|
||||||
|
WIN: 'win',
|
||||||
|
ANDROID: 'android',
|
||||||
|
CROS: 'cros',
|
||||||
|
LINUX: 'linux',
|
||||||
|
OPENBSD: 'openbsd',
|
||||||
|
},
|
||||||
|
PlatformArch: {
|
||||||
|
ARM: 'arm',
|
||||||
|
X86_32: 'x86-32',
|
||||||
|
X86_64: 'x86-64',
|
||||||
|
},
|
||||||
|
PlatformNaclArch: {
|
||||||
|
ARM: 'arm',
|
||||||
|
X86_32: 'x86-32',
|
||||||
|
X86_64: 'x86-64',
|
||||||
|
},
|
||||||
|
RequestUpdateCheckStatus: {
|
||||||
|
THROTTLED: 'throttled',
|
||||||
|
NO_UPDATE: 'no_update',
|
||||||
|
UPDATE_AVAILABLE: 'update_available',
|
||||||
|
},
|
||||||
|
OnInstalledReason: {
|
||||||
|
INSTALL: 'install',
|
||||||
|
UPDATE: 'update',
|
||||||
|
CHROME_UPDATE: 'chrome_update',
|
||||||
|
SHARED_MODULE_UPDATE: 'shared_module_update',
|
||||||
|
},
|
||||||
|
OnRestartRequiredReason: {
|
||||||
|
APP_UPDATE: 'app_update',
|
||||||
|
OS_UPDATE: 'os_update',
|
||||||
|
PERIODIC: 'periodic',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
});
|
||||||
203
server/src/main/resources/cloudflare-js/chrome.plugin.js
Normal file
203
server/src/main/resources/cloudflare-js/chrome.plugin.js
Normal file
@@ -0,0 +1,203 @@
|
|||||||
|
(function () {
|
||||||
|
const plugin0 = Object.create(Plugin.prototype);
|
||||||
|
|
||||||
|
const mimeType0 = Object.create(MimeType.prototype);
|
||||||
|
const mimeType1 = Object.create(MimeType.prototype);
|
||||||
|
Object.defineProperties(mimeType0, {
|
||||||
|
type: {
|
||||||
|
get: () => 'application/pdf',
|
||||||
|
},
|
||||||
|
suffixes: {
|
||||||
|
get: () => 'pdf',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
Object.defineProperties(mimeType1, {
|
||||||
|
type: {
|
||||||
|
get: () => 'text/pdf',
|
||||||
|
},
|
||||||
|
suffixes: {
|
||||||
|
get: () => 'pdf',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
Object.defineProperties(plugin0, {
|
||||||
|
name: {
|
||||||
|
get: () => 'Chrome PDF Viewer',
|
||||||
|
},
|
||||||
|
description: {
|
||||||
|
get: () => 'Portable Document Format',
|
||||||
|
},
|
||||||
|
0: {
|
||||||
|
get: () => {
|
||||||
|
return mimeType0;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
1: {
|
||||||
|
get: () => {
|
||||||
|
return mimeType1;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
length: {
|
||||||
|
get: () => 2,
|
||||||
|
},
|
||||||
|
filename: {
|
||||||
|
get: () => 'internal-pdf-viewer',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const plugin1 = Object.create(Plugin.prototype);
|
||||||
|
Object.defineProperties(plugin1, {
|
||||||
|
name: {
|
||||||
|
get: () => 'Chromium PDF Viewer',
|
||||||
|
},
|
||||||
|
description: {
|
||||||
|
get: () => 'Portable Document Format',
|
||||||
|
},
|
||||||
|
0: {
|
||||||
|
get: () => {
|
||||||
|
return mimeType0;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
1: {
|
||||||
|
get: () => {
|
||||||
|
return mimeType1;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
length: {
|
||||||
|
get: () => 2,
|
||||||
|
},
|
||||||
|
filename: {
|
||||||
|
get: () => 'internal-pdf-viewer',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const plugin2 = Object.create(Plugin.prototype);
|
||||||
|
Object.defineProperties(plugin2, {
|
||||||
|
name: {
|
||||||
|
get: () => 'Microsoft Edge PDF Viewer',
|
||||||
|
},
|
||||||
|
description: {
|
||||||
|
get: () => 'Portable Document Format',
|
||||||
|
},
|
||||||
|
0: {
|
||||||
|
get: () => {
|
||||||
|
return mimeType0;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
1: {
|
||||||
|
get: () => {
|
||||||
|
return mimeType1;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
length: {
|
||||||
|
get: () => 2,
|
||||||
|
},
|
||||||
|
filename: {
|
||||||
|
get: () => 'internal-pdf-viewer',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const plugin3 = Object.create(Plugin.prototype);
|
||||||
|
Object.defineProperties(plugin3, {
|
||||||
|
name: {
|
||||||
|
get: () => 'PDF Viewer',
|
||||||
|
},
|
||||||
|
description: {
|
||||||
|
get: () => 'Portable Document Format',
|
||||||
|
},
|
||||||
|
0: {
|
||||||
|
get: () => {
|
||||||
|
return mimeType0;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
1: {
|
||||||
|
get: () => {
|
||||||
|
return mimeType1;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
length: {
|
||||||
|
get: () => 2,
|
||||||
|
},
|
||||||
|
filename: {
|
||||||
|
get: () => 'internal-pdf-viewer',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const plugin4 = Object.create(Plugin.prototype);
|
||||||
|
Object.defineProperties(plugin4, {
|
||||||
|
name: {
|
||||||
|
get: () => 'WebKit built-in PDF',
|
||||||
|
},
|
||||||
|
description: {
|
||||||
|
get: () => 'Portable Document Format',
|
||||||
|
},
|
||||||
|
0: {
|
||||||
|
get: () => {
|
||||||
|
return mimeType0;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
1: {
|
||||||
|
get: () => {
|
||||||
|
return mimeType1;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
length: {
|
||||||
|
get: () => 2,
|
||||||
|
},
|
||||||
|
filename: {
|
||||||
|
get: () => 'internal-pdf-viewer',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const pluginArray = Object.create(PluginArray.prototype);
|
||||||
|
|
||||||
|
pluginArray['0'] = plugin0;
|
||||||
|
pluginArray['1'] = plugin1;
|
||||||
|
pluginArray['2'] = plugin2;
|
||||||
|
pluginArray['3'] = plugin3;
|
||||||
|
pluginArray['4'] = plugin4;
|
||||||
|
|
||||||
|
let refreshValue;
|
||||||
|
|
||||||
|
Object.defineProperties(pluginArray, {
|
||||||
|
length: {
|
||||||
|
get: () => 5,
|
||||||
|
},
|
||||||
|
item: {
|
||||||
|
value: (index) => {
|
||||||
|
if (index > 4294967295) {
|
||||||
|
index = index % 4294967296;
|
||||||
|
}
|
||||||
|
switch (index) {
|
||||||
|
case 0:
|
||||||
|
return plugin3;
|
||||||
|
case 1:
|
||||||
|
return plugin0;
|
||||||
|
case 2:
|
||||||
|
return plugin1;
|
||||||
|
case 3:
|
||||||
|
return plugin2;
|
||||||
|
case 4:
|
||||||
|
return plugin4;
|
||||||
|
default:
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
refresh: {
|
||||||
|
get: () => {
|
||||||
|
return refreshValue;
|
||||||
|
},
|
||||||
|
set: (value) => {
|
||||||
|
refreshValue = value;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
Object.defineProperty(Object.getPrototypeOf(navigator), 'plugins', {
|
||||||
|
get: () => {
|
||||||
|
return pluginArray;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
})();
|
||||||
170
server/src/main/resources/cloudflare-js/chrome.runtime.js
Normal file
170
server/src/main/resources/cloudflare-js/chrome.runtime.js
Normal file
@@ -0,0 +1,170 @@
|
|||||||
|
(function () {
|
||||||
|
window.chrome = {};
|
||||||
|
window.chrome.app = {
|
||||||
|
InstallState: {
|
||||||
|
DISABLED: 'disabled',
|
||||||
|
INSTALLED: 'installed',
|
||||||
|
NOT_INSTALLED: 'not_installed',
|
||||||
|
},
|
||||||
|
RunningState: {
|
||||||
|
CANNOT_RUN: 'cannot_run',
|
||||||
|
READY_TO_RUN: 'ready_to_run',
|
||||||
|
RUNNING: 'running',
|
||||||
|
},
|
||||||
|
getDetails: () => {
|
||||||
|
'[native code]';
|
||||||
|
},
|
||||||
|
getIsInstalled: () => {
|
||||||
|
'[native code]';
|
||||||
|
},
|
||||||
|
installState: () => {
|
||||||
|
'[native code]';
|
||||||
|
},
|
||||||
|
get isInstalled() {
|
||||||
|
return false;
|
||||||
|
},
|
||||||
|
runningState: () => {
|
||||||
|
'[native code]';
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
window.chrome.runtime = {
|
||||||
|
OnInstalledReason: {
|
||||||
|
CHROME_UPDATE: 'chrome_update',
|
||||||
|
INSTALL: 'install',
|
||||||
|
SHARED_MODULE_UPDATE: 'shared_module_update',
|
||||||
|
UPDATE: 'update',
|
||||||
|
},
|
||||||
|
OnRestartRequiredReason: {
|
||||||
|
APP_UPDATE: 'app_update',
|
||||||
|
OS_UPDATE: 'os_update',
|
||||||
|
PERIODIC: 'periodic',
|
||||||
|
},
|
||||||
|
PlatformArch: {
|
||||||
|
ARM: 'arm',
|
||||||
|
ARM64: 'arm64',
|
||||||
|
MIPS: 'mips',
|
||||||
|
MIPS64: 'mips64',
|
||||||
|
X86_32: 'x86-32',
|
||||||
|
X86_64: 'x86-64',
|
||||||
|
},
|
||||||
|
PlatformNaclArch: {
|
||||||
|
ARM: 'arm',
|
||||||
|
MIPS: 'mips',
|
||||||
|
MIPS64: 'mips64',
|
||||||
|
X86_32: 'x86-32',
|
||||||
|
X86_64: 'x86-64',
|
||||||
|
},
|
||||||
|
PlatformOs: {
|
||||||
|
ANDROID: 'android',
|
||||||
|
CROS: 'cros',
|
||||||
|
FUCHSIA: 'fuchsia',
|
||||||
|
LINUX: 'linux',
|
||||||
|
MAC: 'mac',
|
||||||
|
OPENBSD: 'openbsd',
|
||||||
|
WIN: 'win',
|
||||||
|
},
|
||||||
|
RequestUpdateCheckStatus: {
|
||||||
|
NO_UPDATE: 'no_update',
|
||||||
|
THROTTLED: 'throttled',
|
||||||
|
UPDATE_AVAILABLE: 'update_available',
|
||||||
|
},
|
||||||
|
connect() {
|
||||||
|
'[native code]';
|
||||||
|
},
|
||||||
|
sendMessage() {
|
||||||
|
'[native code]';
|
||||||
|
},
|
||||||
|
id: undefined,
|
||||||
|
};
|
||||||
|
|
||||||
|
let startE = Date.now();
|
||||||
|
window.chrome.csi = function () {
|
||||||
|
'[native code]';
|
||||||
|
return {
|
||||||
|
startE: startE,
|
||||||
|
onloadT: startE + 281,
|
||||||
|
pageT: 3947.235,
|
||||||
|
tran: 15,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
window.chrome.loadTimes = function () {
|
||||||
|
'[native code]';
|
||||||
|
return {
|
||||||
|
get requestTime() {
|
||||||
|
return startE / 1000;
|
||||||
|
},
|
||||||
|
get startLoadTime() {
|
||||||
|
return startE / 1000;
|
||||||
|
},
|
||||||
|
get commitLoadTime() {
|
||||||
|
return startE / 1000 + 0.324;
|
||||||
|
},
|
||||||
|
get finishDocumentLoadTime() {
|
||||||
|
return startE / 1000 + 0.498;
|
||||||
|
},
|
||||||
|
get finishLoadTime() {
|
||||||
|
return startE / 1000 + 0.534;
|
||||||
|
},
|
||||||
|
get firstPaintTime() {
|
||||||
|
return startE / 1000 + 0.437;
|
||||||
|
},
|
||||||
|
get firstPaintAfterLoadTime() {
|
||||||
|
return 0;
|
||||||
|
},
|
||||||
|
get navigationType() {
|
||||||
|
return 'Other';
|
||||||
|
},
|
||||||
|
get wasFetchedViaSpdy() {
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
get wasNpnNegotiated() {
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
get npnNegotiatedProtocol() {
|
||||||
|
return 'h3';
|
||||||
|
},
|
||||||
|
get wasAlternateProtocolAvailable() {
|
||||||
|
return false;
|
||||||
|
},
|
||||||
|
get connectionInfo() {
|
||||||
|
return 'h3';
|
||||||
|
},
|
||||||
|
};
|
||||||
|
};
|
||||||
|
})();
|
||||||
|
|
||||||
|
// Bypass OOPIF test
|
||||||
|
(function performance_memory() {
|
||||||
|
const jsHeapSizeLimitInt = 4294705152;
|
||||||
|
|
||||||
|
const total_js_heap_size = 35244183;
|
||||||
|
const used_js_heap_size = [
|
||||||
|
17632315, 17632315, 17632315, 17634847, 17636091, 17636751,
|
||||||
|
];
|
||||||
|
|
||||||
|
let counter = 0;
|
||||||
|
|
||||||
|
let MemoryInfoProto = Object.getPrototypeOf(performance.memory);
|
||||||
|
Object.defineProperties(MemoryInfoProto, {
|
||||||
|
jsHeapSizeLimit: {
|
||||||
|
get: () => {
|
||||||
|
return jsHeapSizeLimitInt;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
totalJSHeapSize: {
|
||||||
|
get: () => {
|
||||||
|
return total_js_heap_size;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
usedJSHeapSize: {
|
||||||
|
get: () => {
|
||||||
|
if (counter > 5) {
|
||||||
|
counter = 0;
|
||||||
|
}
|
||||||
|
return used_js_heap_size[counter++];
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
})();
|
||||||
3
server/src/main/resources/cloudflare-js/emulate.touch.js
Normal file
3
server/src/main/resources/cloudflare-js/emulate.touch.js
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
Object.defineProperty(navigator, 'maxTouchPoints', {
|
||||||
|
get: () => 1
|
||||||
|
});
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
// https://github.com/microlinkhq/browserless/blob/master/packages/goto/src/evasions/navigator-permissions.js
|
||||||
|
if (!window.Notification) {
|
||||||
|
window.Notification = {
|
||||||
|
permission: 'denied'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const originalQuery = window.navigator.permissions.query
|
||||||
|
window.navigator.permissions.__proto__.query = parameters =>
|
||||||
|
parameters.name === 'notifications'
|
||||||
|
? Promise.resolve({state: window.Notification.permission})
|
||||||
|
: originalQuery(parameters)
|
||||||
|
const oldCall = Function.prototype.call
|
||||||
|
|
||||||
|
function call() {
|
||||||
|
return oldCall.apply(this, arguments)
|
||||||
|
}
|
||||||
|
|
||||||
|
Function.prototype.call = call
|
||||||
|
const nativeToStringFunctionString = Error.toString().replace(/Error/g, 'toString')
|
||||||
|
const oldToString = Function.prototype.toString
|
||||||
|
|
||||||
|
function functionToString() {
|
||||||
|
if (this === window.navigator.permissions.query) {
|
||||||
|
return 'function query() { [native code] }'
|
||||||
|
}
|
||||||
|
if (this === functionToString) {
|
||||||
|
return nativeToStringFunctionString
|
||||||
|
}
|
||||||
|
return oldCall.call(oldToString, this)
|
||||||
|
}
|
||||||
|
|
||||||
|
// eslint-disable-next-line
|
||||||
|
Function.prototype.toString = functionToString
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
Object.defineProperty(Navigator.prototype, 'webdriver', {
|
||||||
|
get() {
|
||||||
|
return false;
|
||||||
|
},
|
||||||
|
});
|
||||||
58
server/src/test/kotlin/masstest/CloudFlareTest.kt
Normal file
58
server/src/test/kotlin/masstest/CloudFlareTest.kt
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
package masstest
|
||||||
|
|
||||||
|
import eu.kanade.tachiyomi.source.online.HttpSource
|
||||||
|
import kotlinx.coroutines.runBlocking
|
||||||
|
import kotlinx.coroutines.test.runTest
|
||||||
|
import mu.KotlinLogging
|
||||||
|
import org.junit.jupiter.api.BeforeAll
|
||||||
|
import org.junit.jupiter.api.Test
|
||||||
|
import org.junit.jupiter.api.TestInstance
|
||||||
|
import suwayomi.tachidesk.manga.impl.Source
|
||||||
|
import suwayomi.tachidesk.manga.impl.extension.Extension
|
||||||
|
import suwayomi.tachidesk.manga.impl.extension.ExtensionsList
|
||||||
|
import suwayomi.tachidesk.manga.impl.util.lang.awaitSingle
|
||||||
|
import suwayomi.tachidesk.manga.impl.util.source.GetCatalogueSource
|
||||||
|
import suwayomi.tachidesk.server.applicationSetup
|
||||||
|
import suwayomi.tachidesk.test.BASE_PATH
|
||||||
|
import suwayomi.tachidesk.test.setLoggingEnabled
|
||||||
|
import xyz.nulldev.ts.config.CONFIG_PREFIX
|
||||||
|
import java.io.File
|
||||||
|
|
||||||
|
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
|
||||||
|
class CloudFlareTest {
|
||||||
|
lateinit var nhentai: HttpSource
|
||||||
|
|
||||||
|
@BeforeAll
|
||||||
|
fun setup() {
|
||||||
|
val dataRoot = File(BASE_PATH).absolutePath
|
||||||
|
System.setProperty("$CONFIG_PREFIX.server.rootDir", dataRoot)
|
||||||
|
applicationSetup()
|
||||||
|
setLoggingEnabled(false)
|
||||||
|
|
||||||
|
runBlocking {
|
||||||
|
val extensions = ExtensionsList.getExtensionList()
|
||||||
|
with(extensions.first { it.name == "NHentai" }) {
|
||||||
|
if (!installed) {
|
||||||
|
Extension.installExtension(pkgName)
|
||||||
|
} else if (hasUpdate) {
|
||||||
|
Extension.updateExtension(pkgName)
|
||||||
|
}
|
||||||
|
Unit
|
||||||
|
}
|
||||||
|
|
||||||
|
nhentai = Source.getSourceList()
|
||||||
|
.firstNotNullOf { it.id.toLong().takeIf { it == 3122156392225024195L } }
|
||||||
|
.let(GetCatalogueSource::getCatalogueSourceOrNull) as HttpSource
|
||||||
|
}
|
||||||
|
setLoggingEnabled(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
private val logger = KotlinLogging.logger {}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `test nhentai browse`() = runTest {
|
||||||
|
assert(nhentai.fetchPopularManga(1).awaitSingle().mangas.isNotEmpty()) {
|
||||||
|
"NHentai results were empty"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user