diff --git a/src/pt/mangalivre/build.gradle.kts b/src/pt/mangalivre/build.gradle.kts index 5faa5127a62..ae3cf70d4d5 100644 --- a/src/pt/mangalivre/build.gradle.kts +++ b/src/pt/mangalivre/build.gradle.kts @@ -6,7 +6,7 @@ plugins { keiyoushi { name = "Manga Livre" - versionCode = 79 + versionCode = 80 contentWarning = ContentWarning.SAFE libVersion = "1.4" diff --git a/src/pt/mangalivre/src/eu/kanade/tachiyomi/extension/pt/mangalivre/Dto.kt b/src/pt/mangalivre/src/eu/kanade/tachiyomi/extension/pt/mangalivre/Dto.kt index 6f9b04c3833..dbf709e1cba 100644 --- a/src/pt/mangalivre/src/eu/kanade/tachiyomi/extension/pt/mangalivre/Dto.kt +++ b/src/pt/mangalivre/src/eu/kanade/tachiyomi/extension/pt/mangalivre/Dto.kt @@ -3,29 +3,46 @@ package eu.kanade.tachiyomi.extension.pt.mangalivre import eu.kanade.tachiyomi.source.model.Page import eu.kanade.tachiyomi.source.model.SChapter import eu.kanade.tachiyomi.source.model.SManga -import keiyoushi.utils.toJsonString -import kotlinx.serialization.SerialName -import kotlinx.serialization.Serializable +import kotlinx.serialization.SerializationException +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonArray +import kotlinx.serialization.json.JsonNull +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.booleanOrNull +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.contentOrNull +import kotlinx.serialization.json.jsonArray +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive +import kotlinx.serialization.json.longOrNull +import kotlinx.serialization.json.put import java.text.Normalizer -@Serializable class WrapperDto( val mangas: List, val pagination: PaginationDto, ) { val hasNextPage get() = pagination.hasNextPage + + companion object { + fun fromJson(json: JsonObject) = WrapperDto( + mangas = json.requiredArray("mangas").mapObjects(MangaDto::fromJson), + pagination = PaginationDto.fromJson(json.requiredObject("pagination")), + ) + } } -@Serializable class PaginationDto( val hasNextPage: Boolean, -) +) { + companion object { + fun fromJson(json: JsonObject) = PaginationDto(json.requiredBoolean("hasNextPage")) + } +} -@Serializable class MangaDto( val id: String, val title: String, - @SerialName("coverUrl") val thumbnailUrl: String? = null, val authors: List? = null, val artists: List? = null, @@ -73,16 +90,40 @@ class MangaDto( companion object { private val MARKS_REGEX = "\\p{InCombiningDiacriticalMarks}+".toRegex() private val NON_ALPHA_REGEX = "[^a-z0-9]+".toRegex() + + fun fromJson(json: JsonObject) = MangaDto( + id = json.requiredString("id"), + title = json.requiredString("title"), + thumbnailUrl = json.optionalString("coverUrl"), + authors = json.optionalStringList("authors"), + artists = json.optionalStringList("artists"), + genres = json.optionalStringList("genres"), + description = json.optionalString("description"), + alternativeTitle = json.optionalString("alternativeTitle"), + chapters = json["chapters"]?.takeUnless { it === JsonNull } + ?.jsonArray + ?.mapObjects(ChapterDto::fromJson), + status = json.optionalString("status"), + ) } } -@Serializable class ChapterReferenceDto( val mangaId: String, val chapterId: String, -) +) { + fun toJson(): String = buildJsonObject { + put("mangaId", mangaId) + put("chapterId", chapterId) + }.toString() + + companion object { + fun fromJson(value: String) = Json.parseToJsonElement(value).jsonObject.let { + ChapterReferenceDto(it.requiredString("mangaId"), it.requiredString("chapterId")) + } + } +} -@Serializable class ChapterDto( val id: String, val number: String, @@ -91,15 +132,55 @@ class ChapterDto( fun toSChapter(slug: String, mangaId: String) = SChapter.create().apply { name = "Capítulo $number" date_upload = timestamp - url = "/$slug/$number#${ChapterReferenceDto(mangaId, id).toJsonString()}" + url = "/$slug/$number#${ChapterReferenceDto(mangaId, id).toJson()}" + } + + companion object { + fun fromJson(json: JsonObject) = ChapterDto( + id = json.requiredString("id"), + number = json.requiredString("number"), + timestamp = json.requiredLong("timestamp"), + ) } } -@Serializable class PageDto( val pages: List, ) { fun toPageList() = pages.mapIndexed { index, imageUrl -> Page(index, imageUrl = imageUrl) } + + companion object { + fun fromJson(json: JsonObject) = PageDto(json.requiredArray("pages").toStringList()) + } } + +private fun JsonObject.requiredElement(key: String) = this[key] + ?: throw SerializationException("Missing required field: $key") + +private fun JsonObject.requiredString(key: String): String = requiredElement(key).jsonPrimitive.content + +private fun JsonObject.requiredBoolean(key: String): Boolean = requiredElement(key).jsonPrimitive.booleanOrNull + ?: throw SerializationException("Field is not a boolean: $key") + +private fun JsonObject.requiredLong(key: String): Long = requiredElement(key).jsonPrimitive.longOrNull + ?: throw SerializationException("Field is not a long: $key") + +private fun JsonObject.requiredArray(key: String): JsonArray = requiredElement(key).jsonArray + +private fun JsonObject.requiredObject(key: String): JsonObject = requiredElement(key).jsonObject + +private fun JsonObject.optionalString(key: String): String? = this[key] + ?.jsonPrimitive + ?.contentOrNull + ?.takeIf { it.isNotBlank() } + +private fun JsonObject.optionalStringList(key: String): List? = this[key] + ?.takeUnless { it === JsonNull } + ?.jsonArray + ?.toStringList() + +private fun JsonArray.toStringList(): List = map { it.jsonPrimitive.content } + +private fun JsonArray.mapObjects(transform: (JsonObject) -> T): List = map { transform(it.jsonObject) } diff --git a/src/pt/mangalivre/src/eu/kanade/tachiyomi/extension/pt/mangalivre/MangaLivre.kt b/src/pt/mangalivre/src/eu/kanade/tachiyomi/extension/pt/mangalivre/MangaLivre.kt index 6c08f0d6ac2..d77ddbb0b79 100644 --- a/src/pt/mangalivre/src/eu/kanade/tachiyomi/extension/pt/mangalivre/MangaLivre.kt +++ b/src/pt/mangalivre/src/eu/kanade/tachiyomi/extension/pt/mangalivre/MangaLivre.kt @@ -1,5 +1,6 @@ package eu.kanade.tachiyomi.extension.pt.mangalivre +import android.webkit.WebSettings import androidx.preference.PreferenceScreen import androidx.preference.SwitchPreferenceCompat import eu.kanade.tachiyomi.network.GET @@ -13,8 +14,11 @@ import eu.kanade.tachiyomi.source.model.SManga import eu.kanade.tachiyomi.source.online.HttpSource import keiyoushi.annotation.Source import keiyoushi.network.rateLimit +import keiyoushi.utils.applicationContext import keiyoushi.utils.getPreferencesLazy -import keiyoushi.utils.parseAs +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.jsonObject import okhttp3.Headers import okhttp3.HttpUrl.Companion.toHttpUrl import okhttp3.OkHttpClient @@ -43,13 +47,20 @@ abstract class MangaLivre : private val preferences by getPreferencesLazy() - override fun headersBuilder(): Headers.Builder = super.headersBuilder() - .add("Accept", "*/*") - .add("Accept-Language", "pt-BR,en-US;q=0.9,en;q=0.8") - .add("Referer", "$baseUrl/") - .add("Sec-Fetch-Dest", "empty") - .add("Sec-Fetch-Mode", "cors") - .add("Sec-Fetch-Site", "same-origin") + private val webViewUserAgent: String? by lazy { + runCatching { WebSettings.getDefaultUserAgent(applicationContext) } + .getOrNull() + } + + override fun headersBuilder(): Headers.Builder = super.headersBuilder().apply { + webViewUserAgent?.takeIf { it.isNotBlank() }?.let { set("User-Agent", it) } + add("Accept", "*/*") + add("Accept-Language", "pt-BR,en-US;q=0.9,en;q=0.8") + add("Referer", "$baseUrl/") + add("Sec-Fetch-Dest", "empty") + add("Sec-Fetch-Mode", "cors") + add("Sec-Fetch-Site", "same-origin") + } // ============================== Popular ======================================= @@ -103,7 +114,7 @@ abstract class MangaLivre : } override fun searchMangaParse(response: Response): MangasPage { - val dto = response.parseJson() + val dto = WrapperDto.fromJson(response.parseJson()) val mangas = dto.mangas.map { it.toSManga(useAlternativeTitle) } return MangasPage(mangas, dto.hasNextPage) } @@ -114,26 +125,26 @@ abstract class MangaLivre : override fun mangaDetailsRequest(manga: SManga): Request = GET("$apiUrl/manga-by-slug/${manga.url}", headers) - override fun mangaDetailsParse(response: Response): SManga = response.parseJson().toSManga(useAlternativeTitle) + override fun mangaDetailsParse(response: Response): SManga = MangaDto.fromJson(response.parseJson()).toSManga(useAlternativeTitle) // ============================== Chapters ======================================= override fun chapterListRequest(manga: SManga): Request = mangaDetailsRequest(manga) - override fun chapterListParse(response: Response): List = response.parseJson().toSChapterList() + override fun chapterListParse(response: Response): List = MangaDto.fromJson(response.parseJson()).toSChapterList() // ============================== Pages ======================================= override fun pageListRequest(chapter: SChapter): Request { val readerPath = chapter.url.substringBeforeLast("#") - val ref = chapter.url.substringAfterLast("#").parseAs() + val ref = ChapterReferenceDto.fromJson(chapter.url.substringAfterLast("#")) return GET("$apiUrl/mangas/${ref.mangaId}/chapters/${ref.chapterId}", headers) .newBuilder() .tag(ReadingGateInterceptor.ReaderPath::class.java, ReadingGateInterceptor.ReaderPath(readerPath)) .build() } - override fun pageListParse(response: Response): List = response.parseJson().toPageList() + override fun pageListParse(response: Response): List = PageDto.fromJson(response.parseJson()).toPageList() override fun imageUrlParse(response: Response): String = throw UnsupportedOperationException() @@ -179,18 +190,18 @@ abstract class MangaLivre : // ============================== Utilities ======================================= - private inline fun Response.parseJson(): T { - val peek = peekBody(MAX_PEEK).string().trimStart() - if (peek.isEmpty() || peek.startsWith("<")) { + private fun Response.parseJson(): JsonObject { + val responseBody = body.string().trimStart() + if (responseBody.isEmpty() || responseBody.startsWith("<")) { close() throw IOException(NON_JSON_MESSAGE) } - return parseAs() + return runCatching { Json.parseToJsonElement(responseBody).jsonObject } + .getOrElse { throw IOException(NON_JSON_MESSAGE, it) } } companion object { private const val ALTERNATIVE_TITLE_PREF = "alternativeTitlePref" - private const val MAX_PEEK = 1024L private const val NON_JSON_MESSAGE = "Resposta não-JSON (Cloudflare ou header desatualizado). Abra a fonte na WebView do app e tente de novo." diff --git a/src/pt/mangalivre/src/eu/kanade/tachiyomi/extension/pt/mangalivre/MangaLivreDecryptor.kt b/src/pt/mangalivre/src/eu/kanade/tachiyomi/extension/pt/mangalivre/MangaLivreDecryptor.kt index 22f1397b619..4e6831acbb6 100644 --- a/src/pt/mangalivre/src/eu/kanade/tachiyomi/extension/pt/mangalivre/MangaLivreDecryptor.kt +++ b/src/pt/mangalivre/src/eu/kanade/tachiyomi/extension/pt/mangalivre/MangaLivreDecryptor.kt @@ -3,64 +3,169 @@ package eu.kanade.tachiyomi.extension.pt.mangalivre import android.util.Base64 import eu.kanade.tachiyomi.network.GET import eu.kanade.tachiyomi.util.asJsoup -import keiyoushi.utils.get -import keiyoushi.utils.parseAs import keiyoushi.utils.readIntBigEndian import keiyoushi.utils.readIntLittleEndian -import keiyoushi.utils.stringOrNull -import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonArray +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.contentOrNull +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive import okhttp3.Headers +import okhttp3.HttpUrl.Companion.toHttpUrl import okhttp3.OkHttpClient import java.security.MessageDigest import java.time.LocalDate import java.time.ZoneOffset /** - * Decrypts the {"": ""} bodies the API returns (Rabbit cipher, CryptoJS - * "Salted__" framing). Passphrase is ENC_KEY + MD5(dateUTC + HOST + ANTIBOT)[0..8]; the date - * rotates daily and the three constants rotate in the site's bundle, so they're re-extracted from - * the live bundle at runtime ([reloadConstants]) with the hardcoded values as fallback. + * Decrypts the {"": ""} bodies the API returns and signs API requests using + * the active reading-gate scheme. All rotating constants are re-extracted from the live bundle at + * runtime ([reloadConstants]) with hardcoded values only as a startup fallback. */ class MangaLivreDecryptor( private val baseUrl: String, - private val client: OkHttpClient, + client: OkHttpClient, private val headers: Headers, ) { - @Volatile private var constants = Constants(DEFAULT_HOSTNAME_PART, DEFAULT_ANTIBOT_PART, DEFAULT_ENC_KEY) - - @Volatile private var lastReloadAt = 0L - - private data class Constants(val hostPart: String, val antibotPart: String, val encKey: String) + private val baseUrlHost = baseUrl.toHttpUrl().host + + private val scrapeClient = client.newBuilder() + .followRedirects(false) + .build() + + @Volatile + private var constants = Constants( + DEFAULT_HOSTNAME_PART, + DEFAULT_ANTIBOT_PART, + DEFAULT_ENC_KEY, + DEFAULT_SIGNATURE_PI_LENGTH, + DEFAULT_SIGNATURE_SUFFIX, + DEFAULT_CHAPTER_SIGNATURE, + DEFAULT_OTHER_SIGNATURE, + ) + + @Volatile + private var lastReloadAt = 0L + + private data class Constants( + val hostPart: String, + val antibotPart: String, + val encKey: String, + val signaturePiLength: Int, + val signatureSuffix: String, + val fixedChapterSignature: String?, + val fixedOtherSignature: String?, + ) fun decrypt(cipherWrapperBody: String, dataKey: String): String? = runCatching { - val ciphertext = cipherWrapperBody.parseAs()[dataKey]?.stringOrNull + val ciphertext = Json.parseToJsonElement(cipherWrapperBody) + .jsonObject[dataKey] + ?.jsonPrimitive + ?.contentOrNull + ?.takeIf { it.isNotEmpty() } ?: return@runCatching null decryptRabbit(ciphertext, derivePassword()).takeIf { it.isValidJson() } }.getOrNull() - fun reloadConstants() { + fun currentSignatureWindow(): Long = System.currentTimeMillis() / SIGNATURE_WINDOW_MS + + fun signature(path: String, timestamp: Long = currentSignatureWindow()): String { + val c = constants + val isChapter = "/chapters" in path + val fixedSignature = if (isChapter) c.fixedChapterSignature else c.fixedOtherSignature + if (fixedSignature != null) return fixedSignature + + val scope = if (isChapter) SIGNATURE_SCOPE_CHAPTERS else SIGNATURE_SCOPE_DEFAULT + val pi = Math.PI.toString() + val piPart = pi.substring(0, c.signaturePiLength.coerceIn(1, pi.length)) + val marker = encodeBase64(piPart) + "_" + c.signatureSuffix + val digest = MessageDigest.getInstance("SHA-256") + .digest("$timestamp:$scope:$marker".toByteArray()) + .joinToString("") { "%02x".format(it) } + return encodeBase64("$timestamp:$digest") + } + + fun reloadConstants(force: Boolean = false) { val now = System.currentTimeMillis() synchronized(this) { - if (now - lastReloadAt < RELOAD_COOLDOWN_MS) return + if (!force && now - lastReloadAt < RELOAD_COOLDOWN_MS) return + val reloaded = runCatching { fetchBundle()?.extractConstants(constants) }.getOrNull() ?: return + constants = reloaded lastReloadAt = now } - runCatching { - val indexJsUrl = client.newCall(GET(baseUrl, headers)).execute() - .asJsoup() + } + + private fun fetchBundle(): String? { + val documentHeaders = headers.newBuilder() + .set("Accept", "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8") + .set("Sec-Fetch-Dest", "document") + .set("Sec-Fetch-Mode", "navigate") + .set("Sec-Fetch-Site", "none") + .set("Upgrade-Insecure-Requests", "1") + .build() + val scriptHeaders = headers.newBuilder() + .set("Accept", "*/*") + .set("Sec-Fetch-Dest", "script") + .set("Sec-Fetch-Mode", "no-cors") + .set("Sec-Fetch-Site", "same-origin") + .build() + val indexUrl = scrapeClient.newCall(GET(baseUrl, documentHeaders)).execute().use { response -> + if (!response.isSuccessful || response.request.url.host != baseUrlHost) return null + response.asJsoup() .selectFirst("script[src*=index]") ?.absUrl("src") - if (indexJsUrl != null) { - val js = client.newCall(GET(indexJsUrl, headers)).execute().body.string() - EV_CONSTANTS_REGEX.find(js)?.let { match -> - // Order matters: host, antibot, encKey. - constants = Constants(match.groupValues[1], match.groupValues[2], match.groupValues[3]) - } - } + } ?: return null + return scrapeClient.newCall(GET(indexUrl, scriptHeaders)).execute().use { response -> + response.body.string().takeIf { response.isSuccessful } } } - private fun String.isValidJson(): Boolean = runCatching { parseAs() }.isSuccess + private fun String.extractConstants(previous: Constants): Constants? { + val legacy = EV_CONSTANTS_REGEX.find(this) + val timedSignature = SIGNATURE_MARKER_REGEX.find(this) + val fixedSignature = FIXED_SIGNATURE_REGEX.find(this)?.let { match -> + val chapter = match.groupValues[2].decodeAsciiList() ?: return@let null + val other = match.groupValues[3].decodeAsciiList() ?: return@let null + chapter to other + } + val hostPart = ENV_HOST_REGEX.find(this)?.groupValues?.get(1) ?: legacy?.groupValues?.get(1) + val antibotPart = ENV_ANTIBOT_REGEX.find(this)?.groupValues?.get(1) ?: legacy?.groupValues?.get(2) + val encKey = ENV_ENCRYPTION_REGEX.find(this)?.groupValues?.get(1) ?: legacy?.groupValues?.get(3) + + if (hostPart == null && antibotPart == null && encKey == null && timedSignature == null && fixedSignature == null) return null + + return Constants( + hostPart = hostPart ?: previous.hostPart, + antibotPart = antibotPart ?: previous.antibotPart, + encKey = encKey ?: previous.encKey, + signaturePiLength = timedSignature?.groupValues?.get(1)?.toIntOrNull() ?: previous.signaturePiLength, + signatureSuffix = timedSignature?.groupValues?.get(2) ?: previous.signatureSuffix, + fixedChapterSignature = when { + fixedSignature != null -> fixedSignature.first + timedSignature != null -> null + else -> previous.fixedChapterSignature + }, + fixedOtherSignature = when { + fixedSignature != null -> fixedSignature.second + timedSignature != null -> null + else -> previous.fixedOtherSignature + }, + ) + } + + private fun String.decodeAsciiList(): String? = split(',') + .map { it.trim().toIntOrNull() ?: return null } + .takeIf { values -> values.all { it in 32..126 } } + ?.map { it.toChar() } + ?.joinToString("") + + private fun encodeBase64(value: String): String = Base64.encodeToString(value.toByteArray(), Base64.NO_WRAP) + + private fun String.isValidJson(): Boolean = runCatching { Json.parseToJsonElement(this) } + .getOrNull() + .let { it is JsonObject || it is JsonArray } private fun decryptRabbit(ciphertextB64: String, password: String): String { val encrypted = Base64.decode(ciphertextB64, Base64.DEFAULT) @@ -104,17 +209,32 @@ class MangaLivreDecryptor( companion object { // Fallback only (current at build time); [reloadConstants] refreshes from the live bundle. - private const val DEFAULT_HOSTNAME_PART = "toonlivre.net::v8" - private const val DEFAULT_ANTIBOT_PART = "t81_4v21_b1" - private const val DEFAULT_ENC_KEY = "Sprang-Unkind-Unframed0" + private const val DEFAULT_HOSTNAME_PART = "toonlivre.tv::v8" + private const val DEFAULT_ANTIBOT_PART = "t17_4v19_b2" + private const val DEFAULT_ENC_KEY = "Dealer-Critter-Catnip4" + private const val DEFAULT_SIGNATURE_PI_LENGTH = 5 + private const val DEFAULT_SIGNATURE_SUFFIX = "1388" + private const val DEFAULT_CHAPTER_SIGNATURE = "t8v_authX9" + private const val DEFAULT_OTHER_SIGNATURE = "t8v_decoy9" private const val RELOAD_COOLDOWN_MS = 30_000L + private const val SIGNATURE_WINDOW_MS = 30_000L + private const val SIGNATURE_SCOPE_CHAPTERS = "chapters" + private const val SIGNATURE_SCOPE_DEFAULT = "other" - // Matches the bundle's ev() password builder; minified var names vary, so match them as \w+ - // and capture the three literals in declaration order (host, antibot, encKey). + // Older bundle shape: constants declared in the password-builder function. private val EV_CONSTANTS_REGEX = Regex( """toISOString\(\)\.split\("T"\)\[0]\s*,\s*\w+\s*=\s*"([^"]+)"\s*,\s*\w+\s*=\s*"([^"]+)"\s*,\s*\w+\s*=\s*"([^"]+)""", ) + private val ENV_HOST_REGEX = Regex("""VITE_HOSTNAME_PART\s*:\s*"([^"]+)"""") + private val ENV_ANTIBOT_REGEX = Regex("""VITE_ANTIBOT\s*:\s*"([^"]+)"""") + private val ENV_ENCRYPTION_REGEX = Regex("""VITE_ENCRYPTION_KEY\s*:\s*"([^"]+)"""") + private val SIGNATURE_MARKER_REGEX = Regex( + """btoa\(Math\.PI\.toString\(\)\.substring\(0,\s*(\d+)\)\)\s*\+\s*["']_["']\s*\+\s*["']([^"']+)["']""", + ) + private val FIXED_SIGNATURE_REGEX = Regex( + """[\w$]+=([\w$]+)\(\[120,45,116,111,111,110,45,115,105,103,110,97,116,117,114,101]\),[\w$]+=\1\(\[([\d,\s]+)]\),[\w$]+=\1\(\[([\d,\s]+)]\),[\w$]+=[\w$]+\.includes\(["']/chapters["']\)""", + ) } } diff --git a/src/pt/mangalivre/src/eu/kanade/tachiyomi/extension/pt/mangalivre/ReadingGateInterceptor.kt b/src/pt/mangalivre/src/eu/kanade/tachiyomi/extension/pt/mangalivre/ReadingGateInterceptor.kt index 8c5cc779dd7..0ac816ddfb8 100644 --- a/src/pt/mangalivre/src/eu/kanade/tachiyomi/extension/pt/mangalivre/ReadingGateInterceptor.kt +++ b/src/pt/mangalivre/src/eu/kanade/tachiyomi/extension/pt/mangalivre/ReadingGateInterceptor.kt @@ -1,5 +1,6 @@ package eu.kanade.tachiyomi.extension.pt.mangalivre +import okhttp3.Cookie import okhttp3.HttpUrl.Companion.toHttpUrl import okhttp3.Interceptor import okhttp3.OkHttpClient @@ -7,13 +8,15 @@ import okhttp3.Request import okhttp3.Response import okhttp3.ResponseBody.Companion.toResponseBody import java.io.IOException +import java.time.ZonedDateTime +import java.time.format.DateTimeFormatter +import java.util.UUID /** - * Clears the site's reading gate for same-host requests. The gate is a double-submit check: the - * client-generated `toon_v` cookie must be echoed in the `x-toon-verify` header. On a 403 this - * primes the cookie via a hidden WebView ([TokenResolver]); on a decrypt failure it reloads the - * rotated constants ([decryptor]). The two retries are independent, so a request that is both gated - * and stale-keyed still recovers. + * Clears the site's reading gate for same-host requests. The gate combines a double-submit + * `toon_v` cookie with a request signature whose format rotates between fixed and time-windowed + * values. A 403 first refreshes the live bundle constants, then rotates the cookie, and finally + * uses a hidden WebView as a last resort. Encrypted responses are transparently decrypted. */ class ReadingGateInterceptor( private val baseUrl: String, @@ -32,22 +35,91 @@ class ReadingGateInterceptor( if (request.url.host != baseUrlHost) { return chain.proceed(request) } - return proceedDecrypted(chain, request, primed = false, reloaded = false) + ensureVerifyCookie() + return proceedDecrypted( + chain, + request, + signatureWindow = decryptor.currentSignatureWindow(), + serverClockRetried = false, + securityReloaded = false, + cookieRotated = false, + webViewPrimed = false, + decryptReloaded = false, + ) } private fun proceedDecrypted( chain: Interceptor.Chain, request: Request, - primed: Boolean, - reloaded: Boolean, + signatureWindow: Long, + serverClockRetried: Boolean, + securityReloaded: Boolean, + cookieRotated: Boolean, + webViewPrimed: Boolean, + decryptReloaded: Boolean, ): Response { - val response = chain.proceed(request.withVerifyHeader()) + val response = chain.proceed(request.withGateHeaders(signatureWindow)) if (response.code == 403) { - if (primed) return response - response.close() - primeCookie(request) - return proceedDecrypted(chain, request, primed = true, reloaded = reloaded) + val serverSignatureWindow = response.serverSignatureWindow() + return when { + !serverClockRetried && serverSignatureWindow != null && serverSignatureWindow != signatureWindow -> { + response.close() + proceedDecrypted( + chain, + request, + serverSignatureWindow, + true, + securityReloaded, + cookieRotated, + webViewPrimed, + decryptReloaded, + ) + } + !securityReloaded -> { + response.close() + decryptor.reloadConstants(force = true) + proceedDecrypted( + chain, + request, + decryptor.currentSignatureWindow(), + serverClockRetried, + true, + cookieRotated, + webViewPrimed, + decryptReloaded, + ) + } + !cookieRotated -> { + response.close() + ensureVerifyCookie(forceNew = true) + proceedDecrypted( + chain, + request, + decryptor.currentSignatureWindow(), + serverClockRetried, + securityReloaded, + true, + webViewPrimed, + decryptReloaded, + ) + } + !webViewPrimed -> { + response.close() + primeCookie(request) + proceedDecrypted( + chain, + request, + decryptor.currentSignatureWindow(), + serverClockRetried, + securityReloaded, + cookieRotated, + true, + decryptReloaded, + ) + } + else -> response + } } val dataKey = response.headers["x-toon-datakey"] ?: return response @@ -61,9 +133,18 @@ class ReadingGateInterceptor( } response.close() - if (reloaded) throw IOException(NON_JSON_MESSAGE) - decryptor.reloadConstants() - return proceedDecrypted(chain, request, primed = primed, reloaded = true) + if (decryptReloaded) throw IOException(NON_JSON_MESSAGE) + decryptor.reloadConstants(force = true) + return proceedDecrypted( + chain, + request, + decryptor.currentSignatureWindow(), + serverClockRetried, + securityReloaded, + cookieRotated, + webViewPrimed, + true, + ) } private fun primeCookie(request: Request) { @@ -75,21 +156,46 @@ class ReadingGateInterceptor( } } - private fun Request.withVerifyHeader(): Request { - val verify = cookieClient.getCookie(baseUrl, "toon_v") ?: return this - val pass = if (url.encodedPath.contains("/chapters")) PASS_CHAPTERS else PASS_DEFAULT + private fun Request.withGateHeaders(signatureWindow: Long): Request { + val verify = ensureVerifyCookie() return newBuilder() + // Public API requests only need the double-submit cookie. Dropping stale session and + // Cloudflare cookies prevents an old WebView session from poisoning reader requests. + .header("Cookie", "$VERIFY_COOKIE=$verify") .header("x-toon-verify", verify) - .header("toonlivre-pass", pass) + .header("x-toon-signature", decryptor.signature(url.encodedPath, signatureWindow)) .build() } + private fun Response.serverSignatureWindow(): Long? = headers["Date"] + ?.let { runCatching { ZonedDateTime.parse(it, DateTimeFormatter.RFC_1123_DATE_TIME) }.getOrNull() } + ?.toInstant() + ?.toEpochMilli() + ?.div(SIGNATURE_WINDOW_MS) + + private fun ensureVerifyCookie(forceNew: Boolean = false): String = synchronized(this) { + if (!forceNew) { + cookieClient.getCookie(baseUrl, VERIFY_COOKIE)?.let { return@synchronized it } + } + val value = UUID.randomUUID().toString().replace("-", "") + val cookie = Cookie.Builder() + .name(VERIFY_COOKIE) + .value(value) + .hostOnlyDomain(baseUrlHost) + .path("/") + .expiresAt(System.currentTimeMillis() + COOKIE_MAX_AGE_MS) + .build() + cookieClient.cookieJar.saveFromResponse(baseUrl.toHttpUrl(), listOf(cookie)) + cookieClient.getCookie(baseUrl, VERIFY_COOKIE) ?: value + } + data class ReaderPath(val path: String) companion object { private const val REFRESH_COOLDOWN_MS = 60_000L - private const val PASS_CHAPTERS = "auth2028xy" - private const val PASS_DEFAULT = "decoy99xz" + private const val VERIFY_COOKIE = "toon_v" + private const val COOKIE_MAX_AGE_MS = 365L * 24 * 60 * 60 * 1_000 + private const val SIGNATURE_WINDOW_MS = 30_000L private const val NON_JSON_MESSAGE = "Não foi possível decifrar a resposta. Abra a fonte na WebView do app e tente de novo." } diff --git a/src/pt/mangalivre/src/eu/kanade/tachiyomi/extension/pt/mangalivre/TokenResolver.kt b/src/pt/mangalivre/src/eu/kanade/tachiyomi/extension/pt/mangalivre/TokenResolver.kt index 22f7a5460bc..c840e4f6d1d 100644 --- a/src/pt/mangalivre/src/eu/kanade/tachiyomi/extension/pt/mangalivre/TokenResolver.kt +++ b/src/pt/mangalivre/src/eu/kanade/tachiyomi/extension/pt/mangalivre/TokenResolver.kt @@ -57,6 +57,7 @@ object TokenResolver { view.webViewClient = object : WebViewClient() { override fun onPageFinished(view: WebView, url: String) { + view.evaluateJavascript("window.dispatchEvent(new Event('click'))", null) pageFinishedLatch.countDown() } }