From f8b3501e027406733c43c49d00b169384c759f84 Mon Sep 17 00:00:00 2001 From: Alessandro Jean Date: Mon, 31 Jan 2022 09:13:11 -0300 Subject: [PATCH] Fix login not working in TOPTOON+. (#10602) --- src/en/toptoonplus/README.md | 50 ++++ src/en/toptoonplus/build.gradle | 2 +- .../extension/en/toptoonplus/TopToonPlus.kt | 215 +----------------- .../TopToonPlusWebViewInterceptor.kt | 163 +++++++++++++ 4 files changed, 225 insertions(+), 205 deletions(-) create mode 100644 src/en/toptoonplus/README.md create mode 100644 src/en/toptoonplus/src/eu/kanade/tachiyomi/extension/en/toptoonplus/TopToonPlusWebViewInterceptor.kt diff --git a/src/en/toptoonplus/README.md b/src/en/toptoonplus/README.md new file mode 100644 index 000000000..02e06d824 --- /dev/null +++ b/src/en/toptoonplus/README.md @@ -0,0 +1,50 @@ +# TOPTOON+ + +Table of Content +- [FAQ](#FAQ) + - [Why are some chapters missing?](#why-are-some-chapters-missing) + - [Why I can not see mature titles?](#why-i-cant-see-mature-titles) +- [Guides](#Guides) + - [Reading already paid chapters](#reading-already-paid-chapters) + +Don't find the question you are looking for? Go check out our general FAQs and Guides +over at [Extension FAQ] or [Getting Started]. + +[Extension FAQ]: https://tachiyomi.org/help/faq/#extensions +[Getting Started]: https://tachiyomi.org/help/guides/getting-started/#installation + +## FAQ + +### Why are some chapters missing? + +TOPTOON+ have series with paid chapters. These will be filtered out from +the chapter list by default if you didn't buy it before or if you're not signed in. +To sign in with your existing account, follow the guide available above. + +### Why I can not see mature titles? + +You need to sign in with your existing account in WebView and toggle the +Mature switch to on in order to these titles appear in the extension. +More details about how to sign in in the guide available above. + +## Guides + +### Reading already paid chapters + +The **TOPTOON+** source allows the reading of paid chapters in your account. +Follow the following steps to be able to sign in and get access to them: + +1. Open the popular or latest section of the source. +2. Open the WebView by clicking the button with a globe icon. +3. Do the login with your existing account *(read the observations section)*. +4. Close the WebView and refresh the chapter list of the titles + you want to read the already paid chapters. + +#### Observations + +- Sign in with your Google account is not supported due to WebView restrictions + access that Google have. You need to have a simple account in order to be able + to login via WebView. +- The extension **will not** bypass any payment requirement. You still do need + to buy the chapters you want to read or wait until they become available and + added to your account. diff --git a/src/en/toptoonplus/build.gradle b/src/en/toptoonplus/build.gradle index 12be9737f..871fa938e 100644 --- a/src/en/toptoonplus/build.gradle +++ b/src/en/toptoonplus/build.gradle @@ -6,7 +6,7 @@ ext { extName = 'TOPTOON+' pkgNameSuffix = 'en.toptoonplus' extClass = '.TopToonPlus' - extVersionCode = 2 + extVersionCode = 3 isNsfw = true } diff --git a/src/en/toptoonplus/src/eu/kanade/tachiyomi/extension/en/toptoonplus/TopToonPlus.kt b/src/en/toptoonplus/src/eu/kanade/tachiyomi/extension/en/toptoonplus/TopToonPlus.kt index e95db6de1..76f8eabc3 100644 --- a/src/en/toptoonplus/src/eu/kanade/tachiyomi/extension/en/toptoonplus/TopToonPlus.kt +++ b/src/en/toptoonplus/src/eu/kanade/tachiyomi/extension/en/toptoonplus/TopToonPlus.kt @@ -1,17 +1,9 @@ package eu.kanade.tachiyomi.extension.en.toptoonplus -import android.app.Application -import android.content.SharedPreferences -import android.text.InputType import android.util.Base64 -import androidx.preference.EditTextPreference -import androidx.preference.PreferenceScreen -import androidx.preference.SwitchPreferenceCompat import eu.kanade.tachiyomi.lib.ratelimit.RateLimitInterceptor import eu.kanade.tachiyomi.network.GET -import eu.kanade.tachiyomi.network.POST 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 import eu.kanade.tachiyomi.source.model.Page @@ -20,29 +12,22 @@ import eu.kanade.tachiyomi.source.model.SManga import eu.kanade.tachiyomi.source.online.HttpSource import kotlinx.serialization.decodeFromString import kotlinx.serialization.json.Json -import kotlinx.serialization.json.buildJsonObject -import kotlinx.serialization.json.put import okhttp3.CacheControl import okhttp3.Headers import okhttp3.HttpUrl.Companion.toHttpUrl -import okhttp3.Interceptor -import okhttp3.MediaType.Companion.toMediaType import okhttp3.OkHttpClient import okhttp3.Request -import okhttp3.RequestBody.Companion.toRequestBody import okhttp3.Response import rx.Observable -import uy.kohesive.injekt.Injekt import uy.kohesive.injekt.api.get import uy.kohesive.injekt.injectLazy -import java.io.IOException import java.text.SimpleDateFormat import java.util.Calendar import java.util.Locale import java.util.UUID import java.util.concurrent.TimeUnit -class TopToonPlus : HttpSource(), ConfigurableSource { +class TopToonPlus : HttpSource() { override val name = "TOPTOON+" @@ -52,8 +37,8 @@ class TopToonPlus : HttpSource(), ConfigurableSource { override val supportsLatest = true - override val client: OkHttpClient = network.cloudflareClient.newBuilder() - .addInterceptor(::authIntercept) + override val client: OkHttpClient = network.client.newBuilder() + .addInterceptor(TopToonPlusWebViewInterceptor(baseUrl, headersBuilder().build())) .addInterceptor(RateLimitInterceptor(2, 1, TimeUnit.SECONDS)) .build() @@ -64,32 +49,16 @@ class TopToonPlus : HttpSource(), ConfigurableSource { .getDisplayName(Calendar.DAY_OF_WEEK, Calendar.SHORT, Locale.US)!! .toUpperCase(Locale.US) - private val preferences: SharedPreferences by lazy { - Injekt.get().getSharedPreferences("source_$id", 0x0000) - } - - private val email: String - get() = preferences.getString(EMAIL_PREF_KEY, "")!! - - private val password: String - get() = preferences.getString(PASSWORD_PREF_KEY, "")!! - - private val showMatureTitles: Boolean - get() = preferences.getBoolean(MATURE_PREF_KEY, false) - private val deviceId: String by lazy { UUID.randomUUID().toString() } - private var token: String? = null - - private var userMature: Boolean = false - - override fun headersBuilder(): Headers.Builder = Headers.Builder() + override fun headersBuilder(): Headers.Builder = super.headersBuilder() .add("Origin", baseUrl) .add("Referer", "$baseUrl/") override fun popularMangaRequest(page: Int): Request { val newHeaders = headersBuilder() .add("Accept", ACCEPT_JSON) + .add("UA", "web") .add("X-Api-Key", API_KEY) .build() @@ -117,6 +86,7 @@ class TopToonPlus : HttpSource(), ConfigurableSource { override fun latestUpdatesRequest(page: Int): Request { val newHeaders = headersBuilder() .add("Accept", ACCEPT_JSON) + .add("UA", "web") .add("X-Api-Key", API_KEY) .build() @@ -149,7 +119,7 @@ class TopToonPlus : HttpSource(), ConfigurableSource { val newHeaders = headersBuilder() .add("Accept", ACCEPT_JSON) .add("Language", lang) - .add("Mature", if (showMatureTitles) "1" else "0") + .add("UA", "web") .add("X-Api-Key", API_KEY) .build() @@ -186,6 +156,7 @@ class TopToonPlus : HttpSource(), ConfigurableSource { private fun mangaDetailsApiRequest(mangaUrl: String): Request { val newHeaders = headersBuilder() .add("Accept", ACCEPT_JSON) + .add("UA", "web") .add("X-Api-Key", API_KEY) .build() @@ -246,7 +217,7 @@ class TopToonPlus : HttpSource(), ConfigurableSource { val newHeaders = headersBuilder() .add("Accept", ACCEPT_JSON) .add("Language", "en") - .add("Token", token.orEmpty().ifEmpty { "null" }) + .add("UA", "web") .add("X-Api-Key", API_KEY) .build() @@ -291,6 +262,7 @@ class TopToonPlus : HttpSource(), ConfigurableSource { private fun viewerRequest(comicId: Int, episodeId: Int): Request { val newHeaders = headersBuilder() .add("Accept", ACCEPT_JSON) + .add("UA", "web") .add("X-Api-Key", API_KEY) .build() @@ -315,155 +287,6 @@ class TopToonPlus : HttpSource(), ConfigurableSource { return GET(page.imageUrl!!, newHeaders) } - override fun setupPreferenceScreen(screen: PreferenceScreen) { - val emailPref = EditTextPreference(screen.context).apply { - key = EMAIL_PREF_KEY - title = EMAIL_PREF_TITLE - setDefaultValue("") - summary = EMAIL_PREF_SUMMARY - dialogTitle = EMAIL_PREF_TITLE - - setOnBindEditTextListener { - it.inputType = InputType.TYPE_CLASS_TEXT or InputType.TYPE_TEXT_VARIATION_EMAIL_ADDRESS - } - - setOnPreferenceChangeListener { _, newValue -> - token = null - - preferences.edit() - .putString(EMAIL_PREF_KEY, newValue as String) - .commit() - } - } - - val passwordPref = EditTextPreference(screen.context).apply { - key = PASSWORD_PREF_KEY - title = PASSWORD_PREF_TITLE - setDefaultValue("") - summary = PASSWORD_PREF_SUMMARY - dialogTitle = PASSWORD_PREF_TITLE - - setOnBindEditTextListener { - it.inputType = InputType.TYPE_CLASS_TEXT or InputType.TYPE_TEXT_VARIATION_PASSWORD - } - - setOnPreferenceChangeListener { _, newValue -> - token = null - - preferences.edit() - .putString(PASSWORD_PREF_KEY, newValue as String) - .commit() - } - } - - val maturePref = SwitchPreferenceCompat(screen.context).apply { - key = MATURE_PREF_KEY - title = MATURE_PREF_TITLE - setDefaultValue(MATURE_PREF_DEFAULT) - summary = MATURE_PREF_SUMMARY - - setOnPreferenceChangeListener { _, newValue -> - preferences.edit() - .putBoolean(MATURE_PREF_KEY, newValue as Boolean) - .commit() - } - } - - screen.addPreference(emailPref) - screen.addPreference(passwordPref) - screen.addPreference(maturePref) - } - - private fun authIntercept(chain: Interceptor.Chain): Response { - val isApiCall = chain.request().url.toString().contains(API_URL) - - if (isApiCall && email.isNotBlank() && password.isNotBlank()) { - if (token == null) { - val loginRequest = loginRequest(email, password) - val loginResponse = chain.proceed(loginRequest) - token = loginParse(loginResponse) - - loginResponse.close() - } - - if (userMature != showMatureTitles && token != null) { - // Preference takes precedence over website. - val matureRequest = matureRequest(token!!, showMatureTitles) - val matureResponse = chain.proceed(matureRequest) - userMature = showMatureTitles - - matureResponse.close() - } - - val newRequest = chain.request().newBuilder() - - if (token.orEmpty().isNotEmpty()) { - newRequest.removeHeader("Token") - .addHeader("Token", token!!) - } - - return chain.proceed(newRequest.build()) - } - - return chain.proceed(chain.request()) - } - - private fun loginRequest(email: String, password: String): Request { - val requestPayload = buildJsonObject { - put("auth", 0) - put("deviceId", deviceId) - put("is17", false) - put("password", password) - put("userId", email) - } - - val requestBody = requestPayload.toString().toRequestBody(JSON_MEDIA_TYPE) - - val newHeaders = headersBuilder() - .add("Accept", ACCEPT_JSON) - .add("Content-Length", requestBody.contentLength().toString()) - .add("Content-Type", requestBody.contentType().toString()) - .add("X-Api-Key", API_KEY) - .build() - - return POST("$API_URL/auth/generateToken", newHeaders, requestBody, CacheControl.FORCE_NETWORK) - } - - private fun loginParse(response: Response): String { - if (response.code != 200) { - throw IOException(COULD_NOT_LOGIN) - } - - val result = response.parseAs() - - if (result.data == null) { - throw IOException(COULD_NOT_LOGIN) - } - - userMature = result.data.mature == 1 - - return result.data.token - } - - private fun matureRequest(token: String, mature: Boolean): Request { - val requestPayload = buildJsonObject { - put("mature", if (mature) 1 else 0) - } - - val requestBody = requestPayload.toString().toRequestBody(JSON_MEDIA_TYPE) - - val newHeaders = headersBuilder() - .add("Accept", ACCEPT_JSON) - .add("Content-Length", requestBody.contentLength().toString()) - .add("Content-Type", requestBody.contentType().toString()) - .add("Token", token) - .add("Uuid", deviceId) - .add("X-Api-Key", API_KEY) - .build() - - return POST("$API_URL/users/setUser", newHeaders, requestBody, CacheControl.FORCE_NETWORK) - } - private inline fun Response.parseAs(): TopToonResult = use { json.decodeFromString(it.body?.string().orEmpty()) } @@ -474,7 +297,7 @@ class TopToonPlus : HttpSource(), ConfigurableSource { } companion object { - private const val API_URL = "https://api.toptoonplus.com" + const val API_URL = "https://api.toptoonplus.com" private val API_KEY by lazy { Base64.decode("U1VQRVJDT09MQVBJS0VZMjAyMSNAIyg=", Base64.DEFAULT) @@ -484,25 +307,9 @@ class TopToonPlus : HttpSource(), ConfigurableSource { private const val ACCEPT_JSON = "application/json, text/plain, */*" private const val ACCEPT_IMAGE = "image/avif,image/webp,image/apng,image/*,*/*;q=0.8" - private val JSON_MEDIA_TYPE = "application/json; charset=UTF-8".toMediaType() - private const val COULD_NOT_PARSE_RESPONSE = "Could not parse the API response." - private const val COULD_NOT_LOGIN = "The e-mail or password provided are incorrect." private const val CHAPTER_NOT_FREE = "This chapter is not free to read." - private const val EMAIL_PREF_KEY = "email" - private const val EMAIL_PREF_TITLE = "E-mail" - private const val EMAIL_PREF_SUMMARY = "Define here the e-mail of your existing account." - - private const val PASSWORD_PREF_KEY = "password" - private const val PASSWORD_PREF_TITLE = "Password" - private const val PASSWORD_PREF_SUMMARY = "Define here your account password." - - private const val MATURE_PREF_KEY = "mature" - private const val MATURE_PREF_TITLE = "Show mature titles" - private const val MATURE_PREF_SUMMARY = "This setting only takes effect if you are signed in." - private const val MATURE_PREF_DEFAULT = false - private val DATE_FORMATTER by lazy { SimpleDateFormat("yyyy-MM-dd", Locale.ENGLISH) } diff --git a/src/en/toptoonplus/src/eu/kanade/tachiyomi/extension/en/toptoonplus/TopToonPlusWebViewInterceptor.kt b/src/en/toptoonplus/src/eu/kanade/tachiyomi/extension/en/toptoonplus/TopToonPlusWebViewInterceptor.kt new file mode 100644 index 000000000..eaaf0cd0e --- /dev/null +++ b/src/en/toptoonplus/src/eu/kanade/tachiyomi/extension/en/toptoonplus/TopToonPlusWebViewInterceptor.kt @@ -0,0 +1,163 @@ +package eu.kanade.tachiyomi.extension.en.toptoonplus + +import android.annotation.SuppressLint +import android.app.Application +import android.os.Handler +import android.os.Looper +import android.webkit.JavascriptInterface +import android.webkit.WebView +import android.webkit.WebViewClient +import eu.kanade.tachiyomi.network.GET +import okhttp3.Headers +import okhttp3.Interceptor +import okhttp3.Request +import okhttp3.Response +import uy.kohesive.injekt.Injekt +import uy.kohesive.injekt.api.get +import java.io.IOException +import java.util.UUID +import java.util.concurrent.CountDownLatch +import java.util.concurrent.TimeUnit + +/** + * WebView interceptor to get the access token from the user. + * It was created because the website started to use reCAPTCHA. + */ +class TopToonPlusWebViewInterceptor( + private val baseUrl: String, + private val headers: Headers +) : Interceptor { + + private val handler = Handler(Looper.getMainLooper()) + + private val windowKey: String by lazy { + UUID.randomUUID().toString().replace("-", "") + } + + private var token: String? = null + + internal class JsInterface(private val latch: CountDownLatch, var payload: String = "") { + @JavascriptInterface + fun passPayload(passedPayload: String) { + payload = passedPayload + latch.countDown() + } + } + + @Synchronized + override fun intercept(chain: Interceptor.Chain): Response { + var request = chain.request() + + if (!request.url.toString().startsWith(TopToonPlus.API_URL)) { + return chain.proceed(request) + } + + if (token != null) { + request = request.newBuilder() + .header("Token", token!!) + .build() + + val response = chain.proceed(request) + + // The API throws 463 if the token is invalid. + if (response.code != 463) { + return response + } + + token = null + request = request.newBuilder() + .removeHeader("Token") + .build() + + response.close() + } + + try { + val websiteRequest = GET(baseUrl, headers) + token = proceedWithWebView(websiteRequest) + } catch (e: Exception) { + throw IOException(e.message) + } + + if (token != null) { + request = request.newBuilder() + .header("Token", token!!) + .build() + } + + return chain.proceed(request) + } + + @SuppressLint("SetJavaScriptEnabled", "AddJavascriptInterface") + private fun proceedWithWebView(websiteRequest: Request): String? { + val latch = CountDownLatch(1) + var webView: WebView? = null + + val requestUrl = websiteRequest.url.toString() + val headers = websiteRequest.headers.toMultimap() + .mapValues { it.value.getOrNull(0) ?: "" } + .toMutableMap() + val userAgent = headers["User-Agent"] + val jsInterface = JsInterface(latch) + + handler.post { + val webview = WebView(Injekt.get()) + webView = webview + + with(webview.settings) { + javaScriptEnabled = true + domStorageEnabled = true + databaseEnabled = true + useWideViewPort = false + loadWithOverviewMode = false + userAgentString = userAgent.orEmpty().ifEmpty { userAgentString } + } + + webview.addJavascriptInterface(jsInterface, windowKey) + + webview.webViewClient = object : WebViewClient() { + override fun onPageFinished(view: WebView, url: String?) { + view.evaluateJavascript(createScript()) {} + } + } + + webview.loadUrl(requestUrl, headers) + } + + latch.await(TIMEOUT_SEC, TimeUnit.SECONDS) + + handler.postDelayed({ webView?.destroy() }, DELAY_MILLIS) + + if (jsInterface.payload.isBlank()) { + return null + } + + return jsInterface.payload + } + + private fun createScript(): String = """ + (function () { + var database = JSON.parse(localStorage.getItem("persist:topco")); + + if (!database) { + window["$windowKey"].passPayload(""); + return; + } + + var userDatabase = JSON.parse(database.user); + + if (!userDatabase) { + window["$windowKey"].passPayload(""); + return; + } + + var accessToken = userDatabase.accessToken; + window["$windowKey"].passPayload(accessToken || ""); + })(); + """.trimIndent() + + companion object { + private const val TIMEOUT_SEC: Long = 20 + private const val DELAY_MILLIS: Long = 10 * 1000 + } +}