Fix login not working in TOPTOON+. (#10602)
This commit is contained in:
parent
82d622001b
commit
f8b3501e02
|
@ -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.
|
|
@ -6,7 +6,7 @@ ext {
|
|||
extName = 'TOPTOON+'
|
||||
pkgNameSuffix = 'en.toptoonplus'
|
||||
extClass = '.TopToonPlus'
|
||||
extVersionCode = 2
|
||||
extVersionCode = 3
|
||||
isNsfw = true
|
||||
}
|
||||
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
|
|
|
@ -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
|
||||
}
|
||||
}
|
Loading…
Reference in New Issue