MangaHub: try to refresh api key for all api requests (#8659)

* MangaHub: try to refresh api key for all api requests

* update in interceptor

* remove logs
This commit is contained in:
AwkwardPeak7 2025-04-28 18:17:21 +05:00 committed by Draff
parent a56eb29dec
commit e3cbc49e38
No known key found for this signature in database
GPG Key ID: E8A89F3211677653
3 changed files with 95 additions and 80 deletions

View File

@ -2,7 +2,7 @@ plugins {
id("lib-multisrc") id("lib-multisrc")
} }
baseVersionCode = 33 baseVersionCode = 34
dependencies { dependencies {
//noinspection UseTomlInstead //noinspection UseTomlInstead

View File

@ -29,14 +29,15 @@ import okhttp3.RequestBody.Companion.toRequestBody
import okhttp3.Response import okhttp3.Response
import okhttp3.ResponseBody.Companion.toResponseBody import okhttp3.ResponseBody.Companion.toResponseBody
import org.brotli.dec.BrotliInputStream import org.brotli.dec.BrotliInputStream
import rx.Observable
import java.io.ByteArrayOutputStream import java.io.ByteArrayOutputStream
import java.io.IOException import java.io.IOException
import java.net.URLEncoder import java.net.URLEncoder
import java.text.SimpleDateFormat import java.text.SimpleDateFormat
import java.util.Calendar import java.util.Calendar
import java.util.Locale import java.util.Locale
import java.util.concurrent.locks.ReentrantLock
import java.util.zip.GZIPInputStream import java.util.zip.GZIPInputStream
import kotlin.random.Random
abstract class MangaHub( abstract class MangaHub(
override val name: String, override val name: String,
@ -53,6 +54,7 @@ abstract class MangaHub(
private val baseThumbCdnUrl = "https://thumb.mghcdn.com" private val baseThumbCdnUrl = "https://thumb.mghcdn.com"
private val apiRegex = Regex("mhub_access=([^;]+)") private val apiRegex = Regex("mhub_access=([^;]+)")
private val spaceRegex = Regex("\\s+") private val spaceRegex = Regex("\\s+")
private val apiErrorRegex = Regex("""rate\s*limit|api\s*key""")
private val preferences: SharedPreferences by getPreferencesLazy() private val preferences: SharedPreferences by getPreferencesLazy()
@ -63,7 +65,6 @@ abstract class MangaHub(
override val client: OkHttpClient = network.cloudflareClient.newBuilder() override val client: OkHttpClient = network.cloudflareClient.newBuilder()
.addInterceptor(::apiAuthInterceptor) .addInterceptor(::apiAuthInterceptor)
.addInterceptor(::graphQLApiInterceptor)
.addNetworkInterceptor(::compatEncodingInterceptor) .addNetworkInterceptor(::compatEncodingInterceptor)
.build() .build()
@ -77,7 +78,7 @@ abstract class MangaHub(
.add("Sec-Fetch-Site", "same-origin") .add("Sec-Fetch-Site", "same-origin")
.add("Upgrade-Insecure-Requests", "1") .add("Upgrade-Insecure-Requests", "1")
private fun postRequestGraphQL(query: String): Request { private fun postRequestGraphQL(query: String, refreshUrl: String? = null): Request {
val requestHeaders = headersBuilder() val requestHeaders = headersBuilder()
.set("Accept", "application/json") .set("Accept", "application/json")
.set("Content-Type", "application/json") .set("Content-Type", "application/json")
@ -94,29 +95,10 @@ abstract class MangaHub(
return POST("$baseApiUrl/graphql", requestHeaders, body.toString().toRequestBody()) return POST("$baseApiUrl/graphql", requestHeaders, body.toString().toRequestBody())
.newBuilder() .newBuilder()
.tag(GraphQLTag()) .tag(GraphQLTag::class.java, GraphQLTag(refreshUrl))
.build() .build()
} }
private fun apiAuthInterceptor(chain: Interceptor.Chain): Response {
val originalRequest = chain.request()
val cookie = client.cookieJar
.loadForRequest(baseUrl.toHttpUrl())
.firstOrNull { it.name == "mhub_access" && it.value.isNotEmpty() }
val request =
if (originalRequest.url.toString() == "$baseApiUrl/graphql" && cookie != null) {
originalRequest.newBuilder()
.header("x-mhub-access", cookie.value)
.build()
} else {
originalRequest
}
return chain.proceed(request)
}
// Normally this gets handled properly but in older forks such as TachiyomiJ2K, we have to manually intercept it // Normally this gets handled properly but in older forks such as TachiyomiJ2K, we have to manually intercept it
// as they have an outdated implementation of NetworkHelper. // as they have an outdated implementation of NetworkHelper.
private fun compatEncodingInterceptor(chain: Interceptor.Chain): Response { private fun compatEncodingInterceptor(chain: Interceptor.Chain): Response {
@ -155,63 +137,99 @@ abstract class MangaHub(
.build() .build()
} }
private fun graphQLApiInterceptor(chain: Interceptor.Chain): Response { private fun apiAuthInterceptor(chain: Interceptor.Chain): Response {
val request = chain.request() val request = chain.request()
val tag = request.tag(GraphQLTag::class.java)
?: return chain.proceed(request) // We won't intercept non-graphql requests (like image retrieval)
// We won't intercept non-graphql requests (like image retrieval) return try {
if (!request.hasGraphQLTag()) { tryApiRequest(chain, request)
return chain.proceed(request) } catch (e: Throwable) {
val noCookie = e is MangaHubCookieNotFound
val apiError = e is ApiErrorException &&
apiErrorRegex.containsMatchIn(e.message ?: "")
if (noCookie || apiError) {
refreshApiKey(tag.refreshUrl)
tryApiRequest(chain, request)
} else {
throw e
}
} }
}
val response = chain.proceed(request) private fun tryApiRequest(chain: Interceptor.Chain, request: Request): Response {
val cookie = client.cookieJar
.loadForRequest(baseUrl.toHttpUrl())
.firstOrNull { it.name == "mhub_access" && it.value.isNotEmpty() }
?: throw MangaHubCookieNotFound()
val apiRequest = request.newBuilder()
.header("x-mhub-access", cookie.value)
.build()
val response = chain.proceed(apiRequest)
val apiResponse = response.peekBody(Long.MAX_VALUE).string()
.parseAs<ApiResponseError>()
// We don't care about the data, only the possible error associated with it
// If we encounter an error, we'll intercept it and throw an error for app to catch
val apiResponse = response.peekBody(Long.MAX_VALUE).string().parseAs<ApiResponseError>()
if (apiResponse.errors != null) { if (apiResponse.errors != null) {
response.close() // Avoid leaks response.close() // Avoid leaks
val errors = apiResponse.errors.joinToString("\n") { it.message } val errors = apiResponse.errors.joinToString("\n") { it.message }
throw IOException(errors) throw ApiErrorException(errors)
} }
// Everything works fine
return response return response
} }
private fun Request.hasGraphQLTag(): Boolean { private class MangaHubCookieNotFound : IOException("mhub_access cookie not found")
return this.tag() is GraphQLTag private class ApiErrorException(errorMessage: String) : IOException(errorMessage)
}
private fun refreshApiKey(chapter: SChapter) { private val lock = ReentrantLock()
val url = "$baseUrl/chapter${chapter.url}".toHttpUrl() private var refreshed = 0L
val oldKey = client.cookieJar
.loadForRequest(baseUrl.toHttpUrl())
.firstOrNull { it.name == "mhub_access" && it.value.isNotEmpty() }?.value
for (i in 1..2) { private fun refreshApiKey(refreshUrl: String? = null) {
// Clear key cookie if (refreshed + 10000 < System.currentTimeMillis() && lock.tryLock()) {
val cookie = Cookie.parse(url, "mhub_access=; Max-Age=0; Path=/")!! val url = when {
client.cookieJar.saveFromResponse(url, listOf(cookie)) refreshUrl != null -> refreshUrl
else -> "$baseUrl/chapter/martial-peak/chapter-${Random.nextInt(1000, 3000)}"
}.toHttpUrl()
// We try requesting again with param if the first one fails val oldKey = client.cookieJar
val query = if (i == 2) "?reloadKey=1" else "" .loadForRequest(baseUrl.toHttpUrl())
.firstOrNull { it.name == "mhub_access" && it.value.isNotEmpty() }?.value
try { for (i in 1..2) {
val response = client.newCall( // Clear key cookie
GET( val cookie = Cookie.parse(url, "mhub_access=; Max-Age=0; Path=/")!!
"$url$query", client.cookieJar.saveFromResponse(url, listOf(cookie))
headers.newBuilder()
.set("Referer", "$baseUrl/manga/${url.pathSegments[1]}")
.build(),
),
).execute()
val returnedKey = response.headers["set-cookie"]?.let { apiRegex.find(it)?.groupValues?.get(1) }
response.close() // Avoid potential resource leaks
if (returnedKey != oldKey) break // Break out of loop since we got an allegedly valid API key try {
} catch (_: IOException) { // We try requesting again with param if the first one fails
throw IOException("An error occurred while obtaining a new API key") // Show error val query = if (i == 2) "?reloadKey=1" else ""
val response = client.newCall(
GET(
"$url$query",
headers.newBuilder()
.set("Referer", "$baseUrl/manga/${url.pathSegments[1]}")
.build(),
),
).execute()
val returnedKey =
response.headers["set-cookie"]?.let { apiRegex.find(it)?.groupValues?.get(1) }
response.close() // Avoid potential resource leaks
if (returnedKey != oldKey) break // Break out of loop since we got an allegedly valid API key
} catch (_: Throwable) {
lock.unlock()
throw Exception("An error occurred while obtaining a new API key") // Show error
}
} }
refreshed = System.currentTimeMillis()
lock.unlock()
} else {
lock.lock() // wait here until lock is released
lock.unlock()
} }
} }
@ -300,7 +318,10 @@ abstract class MangaHub(
// manga details // manga details
override fun mangaDetailsRequest(manga: SManga): Request { override fun mangaDetailsRequest(manga: SManga): Request {
return postRequestGraphQL(mangaDetailsQuery(mangaSource, manga.url.removePrefix("/manga/"))) return postRequestGraphQL(
mangaDetailsQuery(mangaSource, manga.url.removePrefix("/manga/")),
refreshUrl = "$baseUrl${manga.url}",
)
} }
override fun mangaDetailsParse(response: Response): SManga { override fun mangaDetailsParse(response: Response): SManga {
@ -335,7 +356,10 @@ abstract class MangaHub(
// Chapters // Chapters
override fun chapterListRequest(manga: SManga): Request { override fun chapterListRequest(manga: SManga): Request {
return postRequestGraphQL(mangaChapterListQuery(mangaSource, manga.url.removePrefix("/manga/"))) return postRequestGraphQL(
mangaChapterListQuery(mangaSource, manga.url.removePrefix("/manga/")),
refreshUrl = "$baseUrl${manga.url}",
)
} }
override fun chapterListParse(response: Response): List<SChapter> { override fun chapterListParse(response: Response): List<SChapter> {
@ -379,21 +403,10 @@ abstract class MangaHub(
override fun pageListRequest(chapter: SChapter): Request { override fun pageListRequest(chapter: SChapter): Request {
val chapterUrl = chapter.url.split("/") val chapterUrl = chapter.url.split("/")
return postRequestGraphQL(pagesQuery(mangaSource, chapterUrl[1], chapterUrl[2].substringAfter("-").toFloat())) return postRequestGraphQL(
} pagesQuery(mangaSource, chapterUrl[1], chapterUrl[2].substringAfter("-").toFloat()),
refreshUrl = "$baseUrl/chapter${chapter.url}",
override fun fetchPageList(chapter: SChapter): Observable<List<Page>> { )
var doApiRefresh = true
return super.fetchPageList(chapter)
.doOnError {
// Ensure that the api refresh call will happen only once
if (doApiRefresh) {
refreshApiKey(chapter)
doApiRefresh = false
}
}
.retry(1)
} }
override fun pageListParse(response: Response): List<Page> { override fun pageListParse(response: Response): List<Page> {

View File

@ -1,6 +1,8 @@
package eu.kanade.tachiyomi.multisrc.mangahub package eu.kanade.tachiyomi.multisrc.mangahub
class GraphQLTag class GraphQLTag(
val refreshUrl: String? = null,
)
val searchQuery = { mangaSource: String, query: String, genre: String, order: String, page: Int -> val searchQuery = { mangaSource: String, query: String, genre: String, order: String, page: Int ->
""" """