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")
}
baseVersionCode = 33
baseVersionCode = 34
dependencies {
//noinspection UseTomlInstead

View File

@ -29,14 +29,15 @@ import okhttp3.RequestBody.Companion.toRequestBody
import okhttp3.Response
import okhttp3.ResponseBody.Companion.toResponseBody
import org.brotli.dec.BrotliInputStream
import rx.Observable
import java.io.ByteArrayOutputStream
import java.io.IOException
import java.net.URLEncoder
import java.text.SimpleDateFormat
import java.util.Calendar
import java.util.Locale
import java.util.concurrent.locks.ReentrantLock
import java.util.zip.GZIPInputStream
import kotlin.random.Random
abstract class MangaHub(
override val name: String,
@ -53,6 +54,7 @@ abstract class MangaHub(
private val baseThumbCdnUrl = "https://thumb.mghcdn.com"
private val apiRegex = Regex("mhub_access=([^;]+)")
private val spaceRegex = Regex("\\s+")
private val apiErrorRegex = Regex("""rate\s*limit|api\s*key""")
private val preferences: SharedPreferences by getPreferencesLazy()
@ -63,7 +65,6 @@ abstract class MangaHub(
override val client: OkHttpClient = network.cloudflareClient.newBuilder()
.addInterceptor(::apiAuthInterceptor)
.addInterceptor(::graphQLApiInterceptor)
.addNetworkInterceptor(::compatEncodingInterceptor)
.build()
@ -77,7 +78,7 @@ abstract class MangaHub(
.add("Sec-Fetch-Site", "same-origin")
.add("Upgrade-Insecure-Requests", "1")
private fun postRequestGraphQL(query: String): Request {
private fun postRequestGraphQL(query: String, refreshUrl: String? = null): Request {
val requestHeaders = headersBuilder()
.set("Accept", "application/json")
.set("Content-Type", "application/json")
@ -94,29 +95,10 @@ abstract class MangaHub(
return POST("$baseApiUrl/graphql", requestHeaders, body.toString().toRequestBody())
.newBuilder()
.tag(GraphQLTag())
.tag(GraphQLTag::class.java, GraphQLTag(refreshUrl))
.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
// as they have an outdated implementation of NetworkHelper.
private fun compatEncodingInterceptor(chain: Interceptor.Chain): Response {
@ -155,35 +137,64 @@ abstract class MangaHub(
.build()
}
private fun graphQLApiInterceptor(chain: Interceptor.Chain): Response {
private fun apiAuthInterceptor(chain: Interceptor.Chain): Response {
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)
if (!request.hasGraphQLTag()) {
return chain.proceed(request)
return try {
tryApiRequest(chain, 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) {
response.close() // Avoid leaks
val errors = apiResponse.errors.joinToString("\n") { it.message }
throw IOException(errors)
throw ApiErrorException(errors)
}
// Everything works fine
return response
}
private fun Request.hasGraphQLTag(): Boolean {
return this.tag() is GraphQLTag
}
private class MangaHubCookieNotFound : IOException("mhub_access cookie not found")
private class ApiErrorException(errorMessage: String) : IOException(errorMessage)
private val lock = ReentrantLock()
private var refreshed = 0L
private fun refreshApiKey(refreshUrl: String? = null) {
if (refreshed + 10000 < System.currentTimeMillis() && lock.tryLock()) {
val url = when {
refreshUrl != null -> refreshUrl
else -> "$baseUrl/chapter/martial-peak/chapter-${Random.nextInt(1000, 3000)}"
}.toHttpUrl()
private fun refreshApiKey(chapter: SChapter) {
val url = "$baseUrl/chapter${chapter.url}".toHttpUrl()
val oldKey = client.cookieJar
.loadForRequest(baseUrl.toHttpUrl())
.firstOrNull { it.name == "mhub_access" && it.value.isNotEmpty() }?.value
@ -193,10 +204,9 @@ abstract class MangaHub(
val cookie = Cookie.parse(url, "mhub_access=; Max-Age=0; Path=/")!!
client.cookieJar.saveFromResponse(url, listOf(cookie))
try {
// We try requesting again with param if the first one fails
val query = if (i == 2) "?reloadKey=1" else ""
try {
val response = client.newCall(
GET(
"$url$query",
@ -205,14 +215,22 @@ abstract class MangaHub(
.build(),
),
).execute()
val returnedKey = response.headers["set-cookie"]?.let { apiRegex.find(it)?.groupValues?.get(1) }
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 (_: IOException) {
throw IOException("An error occurred while obtaining a new API key") // Show error
} 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()
}
}
data class SMangaDTO(
@ -300,7 +318,10 @@ abstract class MangaHub(
// manga details
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 {
@ -335,7 +356,10 @@ abstract class MangaHub(
// Chapters
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> {
@ -379,21 +403,10 @@ abstract class MangaHub(
override fun pageListRequest(chapter: SChapter): Request {
val chapterUrl = chapter.url.split("/")
return postRequestGraphQL(pagesQuery(mangaSource, chapterUrl[1], chapterUrl[2].substringAfter("-").toFloat()))
}
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)
return postRequestGraphQL(
pagesQuery(mangaSource, chapterUrl[1], chapterUrl[2].substringAfter("-").toFloat()),
refreshUrl = "$baseUrl/chapter${chapter.url}",
)
}
override fun pageListParse(response: Response): List<Page> {

View File

@ -1,6 +1,8 @@
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 ->
"""