From 526b8ec979e51bc87e69d8aec55e0cdbd689a218 Mon Sep 17 00:00:00 2001 From: Chopper <156493704+choppeh@users.noreply.github.com> Date: Sun, 11 May 2025 05:10:22 -0300 Subject: [PATCH] Manhastro: Update domain and add custom settings (#8763) * Update domain and add custom settings * Ue network.clouflareClient --- src/pt/manhastro/build.gradle | 8 +- .../extension/pt/manhastro/Manhastro.kt | 358 ++++++++++++++++-- 2 files changed, 339 insertions(+), 27 deletions(-) diff --git a/src/pt/manhastro/build.gradle b/src/pt/manhastro/build.gradle index 1b33c2764..caaf15b5c 100644 --- a/src/pt/manhastro/build.gradle +++ b/src/pt/manhastro/build.gradle @@ -2,9 +2,13 @@ ext { extName = 'Manhastro' extClass = '.Manhastro' themePkg = 'madara' - baseUrl = 'https://manhastro.com' - overrideVersionCode = 6 + baseUrl = 'https://manhastro.net' + overrideVersionCode = 7 isNsfw = true } apply from: "$rootDir/common.gradle" + +dependencies { + implementation(project(":lib:cookieinterceptor")) +} diff --git a/src/pt/manhastro/src/eu/kanade/tachiyomi/extension/pt/manhastro/Manhastro.kt b/src/pt/manhastro/src/eu/kanade/tachiyomi/extension/pt/manhastro/Manhastro.kt index dba3bdf24..ae9c2f8da 100644 --- a/src/pt/manhastro/src/eu/kanade/tachiyomi/extension/pt/manhastro/Manhastro.kt +++ b/src/pt/manhastro/src/eu/kanade/tachiyomi/extension/pt/manhastro/Manhastro.kt @@ -1,47 +1,109 @@ package eu.kanade.tachiyomi.extension.pt.manhastro +import android.app.Application +import android.content.SharedPreferences +import android.os.Handler +import android.os.Looper import android.util.Base64 +import android.widget.Toast +import androidx.preference.EditTextPreference +import androidx.preference.PreferenceScreen +import eu.kanade.tachiyomi.lib.cookieinterceptor.CookieInterceptor import eu.kanade.tachiyomi.multisrc.madara.Madara +import eu.kanade.tachiyomi.network.GET +import eu.kanade.tachiyomi.network.POST import eu.kanade.tachiyomi.network.interceptor.rateLimit +import eu.kanade.tachiyomi.source.ConfigurableSource import eu.kanade.tachiyomi.source.model.Page +import eu.kanade.tachiyomi.util.asJsoup +import keiyoushi.utils.getPreferences +import keiyoushi.utils.parseAs +import kotlinx.serialization.Serializable import kotlinx.serialization.decodeFromString +import kotlinx.serialization.encodeToString +import okhttp3.CacheControl +import okhttp3.FormBody +import okhttp3.Interceptor import okhttp3.MediaType.Companion.toMediaType import okhttp3.OkHttpClient +import okhttp3.Request +import okhttp3.Response import okhttp3.ResponseBody.Companion.toResponseBody import org.jsoup.nodes.Document +import uy.kohesive.injekt.Injekt +import uy.kohesive.injekt.api.get import java.text.SimpleDateFormat +import java.util.Calendar +import java.util.Date import java.util.Locale import java.util.concurrent.TimeUnit -class Manhastro : Madara( - "Manhastro", - "https://manhastro.com", - "pt-BR", - SimpleDateFormat("dd/MM/yyyy", Locale("pt", "BR")), -) { +class Manhastro : + Madara( + "Manhastro", + "https://manhastro.net", + "pt-BR", + SimpleDateFormat("dd/MM/yyyy", Locale("pt", "BR")), + ), + ConfigurableSource { override val mangaSubString = "lermanga" - override val client: OkHttpClient = super.client.newBuilder() - .rateLimit(1) - .readTimeout(1, TimeUnit.MINUTES) - .connectTimeout(1, TimeUnit.MINUTES) - .addInterceptor { chain -> - val response = chain.proceed(chain.request()) - val mime = response.headers["Content-Type"] - if (response.isSuccessful) { - if (mime != "application/octet-stream") { - return@addInterceptor response - } - // Fix image content type - val type = IMG_CONTENT_TYPE.toMediaType() - val body = response.body.bytes().toResponseBody(type) - return@addInterceptor response.newBuilder().body(body) - .header("Content-Type", IMG_CONTENT_TYPE).build() - } - response + private val preferences: SharedPreferences = getPreferences() + + private val application: Application by lazy { Injekt.get() } + + private var showWarning: Boolean = true + + private val authCookie: Cookie by lazy { + val cookieJson = preferences.getString(COOKIE_STORAGE_PREF, "") as String + if (cookieJson.isBlank()) { + return@lazy doAuth().also(::upsetCookie) } - .build() + + val cookieSaved = cookieJson.parseAs() + + return@lazy cookieSaved.takeIf { it.isExpired().not() } + ?: doAuth().also(::upsetCookie) + } + + private val cookieInterceptor: Interceptor by lazy { + return@lazy when { + authCookie.isEmpty() -> bypassInterceptor + else -> CookieInterceptor(baseUrl.substringAfterLast("/"), listOf(authCookie.value)) + } + } + + override val client: OkHttpClient by lazy { + super.client.newBuilder() + .rateLimit(1) + .readTimeout(1, TimeUnit.MINUTES) + .connectTimeout(1, TimeUnit.MINUTES) + .addInterceptor { chain -> + if (credentials.isEmpty && showWarning) { + showWarning = false + showToast("Configure suas credências em Extensões > $name > Configuração") + } + return@addInterceptor chain.proceed(chain.request()) + } + .addNetworkInterceptorIf(credentials.isNotEmpty, cookieInterceptor) + .addInterceptor { chain -> + val response = chain.proceed(chain.request()) + val mime = response.headers["Content-Type"] + if (response.isSuccessful) { + if (mime != "application/octet-stream") { + return@addInterceptor response + } + // Fix image content type + val type = IMG_CONTENT_TYPE.toMediaType() + val body = response.body.bytes().toResponseBody(type) + return@addInterceptor response.newBuilder().body(body) + .header("Content-Type", IMG_CONTENT_TYPE).build() + } + response + } + .build() + } override val useNewChapterEndpoint = true @@ -51,6 +113,16 @@ class Manhastro : Madara( override val mangaDetailsSelectorStatus = "div.summary-heading:contains(Status) + div.summary-content" + override fun popularMangaRequest(page: Int): Request { + resetToastMessage(page) + return super.popularMangaRequest(page) + } + + override fun latestUpdatesRequest(page: Int): Request { + resetToastMessage(page) + return super.latestUpdatesRequest(page) + } + override fun pageListParse(document: Document): List { return document.selectFirst("script:containsData(imageLinks)")?.data() ?.let { imageLinksPattern.find(it)?.groups?.get(1)?.value } @@ -63,7 +135,243 @@ class Manhastro : Madara( private val imageLinksPattern = """var\s+?imageLinks\s*?=\s*?(\[.*]);""".toRegex() + override fun setupPreferenceScreen(screen: PreferenceScreen) { + val warning = "⚠️ Os dados inseridos nessa seção serão usados somente para realizar o login na fonte" + val message = "Insira %s para prosseguir com o acesso aos recursos disponíveis na fonte" + + val usernamePref = EditTextPreference(screen.context).apply { + title = "📧 Email" + key = USERNAME_PREF + summary = "Email de acesso" + dialogMessage = buildString { + appendLine(message.format("seu email")) + append("\n$warning") + } + setDefaultValue("") + + setOnPreferenceChangeListener { _, _ -> + Toast.makeText(screen.context, RESTART_APP_MESSAGE, Toast.LENGTH_LONG).show() + true + } + } + + val passwordPref = EditTextPreference(screen.context).apply { + title = "🔑 Senha" + key = PASSWORD_PREF + summary = "Senha de acesso" + dialogMessage = buildString { + appendLine(message.format("sua senha")) + append("\n$warning") + } + setDefaultValue("") + + setOnPreferenceChangeListener { _, _ -> + Toast.makeText(screen.context, RESTART_APP_MESSAGE, Toast.LENGTH_LONG).show() + true + } + } + + screen.addPreference(usernamePref) + screen.addPreference(passwordPref) + } + + // ============================ Auth ==================================== + + private val credentials: Credential get() = Credential( + email = preferences.getString(USERNAME_PREF, "") as String, + password = preferences.getString(PASSWORD_PREF, "") as String, + ) + + private val defaultClient = network.cloudflareClient + + var attempts: Int + get() = preferences.getInt(COOKIE_ATTEMPT, 0) + set(value) { + preferences.edit() + .putInt(COOKIE_ATTEMPT, value) + .apply() + } + var lastAttempt: Date? + get() { + val time = preferences.getLong(COOKIE_LAST_REQUEST, 0) + if (time == 0L) { + return null + } + return Date(time) + } + set(value) { + val time = value?.time ?: 0L + preferences.edit() + .putLong(COOKIE_LAST_REQUEST, time) + .apply() + } + + private fun doAuth(): Cookie { + if (credentials.isEmpty) { + return Cookie.empty() + } + + attempts++ + /** + * Avoids excessive invalid requests when credentials are invalid + */ + if (attempts >= MAX_ATTEMPT_WITHIN_PERIOD) { + if (lastAttempt == null) { + lastAttempt = Date() + return Cookie.empty() + } + + val lockPeriod = Calendar.getInstance().apply { + time = lastAttempt!! + add(Calendar.HOUR, 6) + } + + when { + Date().after(lockPeriod.time) -> { + attempts = 0 + lastAttempt = null + } + else -> { + showToast("Login permitido após 6h de ${toastDateFormat.format(lastAttempt!!)}") + return Cookie.empty() + } + } + } + + val document = defaultClient.newCall(GET(baseUrl, headers)).execute().asJsoup() + val nonce = document.selectFirst("script#wp-manga-login-ajax-js-extra") + ?.data() + ?.let { + NONCE_LOGIN_REGEX.find(it)?.groups?.get(1)?.value + } + ?: return Cookie.empty() + + val formHeaders = headers.newBuilder() + .set("Accept", "*/*") + .set("Accept-Encoding", "gzip, deflate, br") + .set("Accept-Language", "en-US,en;q=0.9") + .set("Connection", "keep-alive") + .set("Origin", baseUrl) + .set("Referer", "$baseUrl/") + .set("X-Requested-With", "XMLHttpRequest") + .build() + + val form = FormBody.Builder() + .add("action", "wp_manga_signin") + .add("login", credentials.email) + .add("pass", credentials.password) + .add("rememberme", "forever") + .add("nonce", nonce) + .build() + + val response = defaultClient.newCall( + POST( + "$baseUrl/wp-admin/admin-ajax.php", + formHeaders, + form, + CacheControl.FORCE_NETWORK, + ), + ).execute() + val message = """ + Falha ao acessar recurso: Usuário ou senha incorreto. + Altere suas credências em Extensões > $name > Configuração. + """.trimIndent() + response.use { + if (it.isSuccessful.not()) { + showToast(message) + } + } + + return response.headers("Set-Cookie") + .firstOrNull { it.contains("wordpress_logged_in_", ignoreCase = true) } + ?.let(::Cookie) + ?: Cookie.empty().also { + showToast(message) + } + } + + private class Credential( + val email: String, + val password: String, + ) { + val isEmpty: Boolean get() = email.isBlank() || password.isBlank() + val isNotEmpty: Boolean get() = isEmpty.not() + } + + @Serializable + private class Cookie { + val value: Pair + val expired: String + + private constructor() { + value = "" to "" + expired = "" + } + + constructor(setCookie: String) { + val slices = setCookie.split("; ") + value = slices.first().split("=").let { + it.first() to it.last() + } + expired = slices.last().substringAfter("=") + } + + fun isExpired(): Boolean = + try { dateFormat.parse(expired)!!.before(Date()) } catch (e: Exception) { true } + + fun isEmpty(): Boolean = expired.isEmpty() || value.toList().any(String::isBlank) + + companion object { + fun empty() = Cookie() + private val dateFormat = SimpleDateFormat("EEE, dd MMM yyyy HH:mm:ss zzz", Locale.ENGLISH) + } + } + + // ============================ Utilities ==================================== + + private fun OkHttpClient.Builder.addNetworkInterceptorIf( + condition: Boolean, + interceptor: Interceptor, + ): OkHttpClient.Builder { + if (condition) { + addNetworkInterceptor(interceptor) + } + return this + } + + private val handler by lazy { Handler(Looper.getMainLooper()) } + + private fun showToast(message: String) { + handler.post { + Toast.makeText(application, message, Toast.LENGTH_LONG).show() + } + } + + private fun resetToastMessage(page: Int) { + if (page != 1) return + showWarning = true + } + + val bypassInterceptor = object : Interceptor { + override fun intercept(chain: Interceptor.Chain): Response = chain.proceed(chain.request()) + } + + private fun upsetCookie(cookie: Cookie) { + preferences.edit() + .putString(COOKIE_STORAGE_PREF, json.encodeToString(cookie)) + .apply() + } + companion object { + private const val MAX_ATTEMPT_WITHIN_PERIOD = 2 + private const val RESTART_APP_MESSAGE = "Reinicie o aplicativo para aplicar as alterações" + private const val USERNAME_PREF = "MANHASTRO_USERNAME" + private const val PASSWORD_PREF = "MANHASTRO_PASSWORD" + private const val COOKIE_STORAGE_PREF = "MANHASTRO_COOKIE_STORAGE" + private const val COOKIE_LAST_REQUEST = "MANHASTRO_COOKIE_LAST_REQUEST" + private const val COOKIE_ATTEMPT = "MANHASTRO_COOKIE_ATTEMPT" private const val IMG_CONTENT_TYPE = "image/jpeg" + private val NONCE_LOGIN_REGEX = """"nonce":"([^"]+)""".toRegex() + val toastDateFormat = SimpleDateFormat("dd/MM/yyyy HH:mm:ss", Locale("pt", "BR")) } }