Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion src/pt/mangalivre/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ plugins {

keiyoushi {
name = "Manga Livre"
versionCode = 86
versionCode = 87
contentWarning = ContentWarning.SAFE
libVersion = "1.4"

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,24 +21,35 @@ class ReadingGateInterceptor(

private var cachedSeed: CachedSeed? = null

private val cachedRouteTokens = mutableMapOf<ChapterReference, CachedRouteToken>()

override fun intercept(chain: Interceptor.Chain): Response {
val request = chain.request()
if (request.url.host != baseUrlHost) {
return chain.proceed(request)
}
return proceedDecrypted(chain, request, seedRetried = false)
return proceedDecrypted(chain, request, seedRetried = false, routeRetried = false)
}

private fun proceedDecrypted(
chain: Interceptor.Chain,
request: Request,
seedRetried: Boolean,
routeRetried: Boolean,
): Response {
val response = chain.proceed(request.withSignature(forceRefresh = seedRetried))
val signedRequest = request.withSignature(forceRefresh = seedRetried)
val response = chain.proceed(
signedRequest.withRouteToken(forceRefresh = routeRetried || seedRetried),
)

if (response.code == 403 && !seedRetried) {
response.close()
return proceedDecrypted(chain, request, seedRetried = true)
return proceedDecrypted(chain, request, seedRetried = true, routeRetried)
}

if (response.code == 404 && request.chapterReference() != null && !routeRetried) {
response.close()
return proceedDecrypted(chain, request, seedRetried, routeRetried = true)
}

val dataKey = response.headers["x-toon-datakey"] ?: return response
Expand All @@ -59,6 +70,21 @@ class ReadingGateInterceptor(
.header(SIGNATURE_HEADER, resolveSeed(this, forceRefresh))
.build()

private fun Request.withRouteToken(forceRefresh: Boolean): Request {
val reference = chapterReference() ?: return this
return newBuilder()
.header(ROUTE_TOKEN_HEADER, resolveRouteToken(this, reference, forceRefresh))
.build()
}

private fun Request.chapterReference(): ChapterReference? {
val segments = url.pathSegments
if (segments.size != 5 || segments[0] != "api" || segments[1] != "mangas" || segments[3] != "chapters") {
return null
}
return ChapterReference(segments[2], segments[4])
}

private fun resolveSeed(request: Request, forceRefresh: Boolean): String = synchronized(this) {
val now = System.currentTimeMillis()
if (!forceRefresh) {
Expand All @@ -76,16 +102,61 @@ class ReadingGateInterceptor(
token
}

private fun resolveRouteToken(
request: Request,
reference: ChapterReference,
forceRefresh: Boolean,
): String = synchronized(this) {
val now = System.currentTimeMillis()
if (!forceRefresh) {
cachedRouteTokens[reference]
?.takeIf { it.expiresAt > now }
?.let { return@synchronized it.token }
}

val routeTokenRequest = request.newBuilder()
.url("$baseUrl/api/chapter-token/${reference.mangaId}/${reference.chapterId}")
.get()
.removeHeader(ROUTE_TOKEN_HEADER)
.cacheControl(CacheControl.FORCE_NETWORK)
.build()
val routeToken = seedClient.newCall(routeTokenRequest).execute().use { response ->
if (!response.isSuccessful) throw IOException(INVALID_ROUTE_TOKEN_MESSAGE)
response.parseAs<RouteTokenDto>()
}
val cacheDuration = routeToken.expiresMs
?.coerceIn(MIN_ROUTE_TOKEN_CACHE_MS, MAX_ROUTE_TOKEN_CACHE_MS)
?: DEFAULT_ROUTE_TOKEN_CACHE_MS
cachedRouteTokens[reference] = CachedRouteToken(
routeToken.token,
now + cacheDuration - ROUTE_TOKEN_EXPIRY_MARGIN_MS,
)
routeToken.token
}

data class ReaderPath(val path: String)

private class CachedSeed(val token: String, val expiresAt: Long)

private data class ChapterReference(val mangaId: String, val chapterId: String)

private class CachedRouteToken(val token: String, val expiresAt: Long)

companion object {
private const val SIGNATURE_HEADER = "x-toon-signature"
private const val ROUTE_TOKEN_HEADER = "x-toon-route-token"
private const val SEED_CACHE_MS = 25 * 60 * 1000L
private const val DEFAULT_ROUTE_TOKEN_CACHE_MS = 5 * 60 * 1000L
private const val MIN_ROUTE_TOKEN_CACHE_MS = 60 * 1000L
private const val MAX_ROUTE_TOKEN_CACHE_MS = 10 * 60 * 1000L
private const val ROUTE_TOKEN_EXPIRY_MARGIN_MS = 30 * 1000L
private const val NON_JSON_MESSAGE = "Não foi possível decifrar a resposta."
private const val INVALID_ROUTE_TOKEN_MESSAGE = "O site retornou um token de capítulo inválido."
}
}

@Serializable
private class SeedDto(val token: String)

@Serializable
private class RouteTokenDto(val token: String, val expiresMs: Long? = null)