Fix login not working in TOPTOON+. (#10602)

This commit is contained in:
Alessandro Jean 2022-01-31 09:13:11 -03:00 committed by GitHub
parent 82d622001b
commit f8b3501e02
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
4 changed files with 225 additions and 205 deletions

View File

@ -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.

View File

@ -6,7 +6,7 @@ ext {
extName = 'TOPTOON+'
pkgNameSuffix = 'en.toptoonplus'
extClass = '.TopToonPlus'
extVersionCode = 2
extVersionCode = 3
isNsfw = true
}

View File

@ -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<Application>().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<TopToonAuth>()
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 <reified T> Response.parseAs(): TopToonResult<T> = 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)
}

View File

@ -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<Application>())
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
}
}