Manhastro: Update domain and add custom settings (#8763)

* Update domain and add custom settings

* Ue network.clouflareClient
This commit is contained in:
Chopper 2025-05-11 05:10:22 -03:00 committed by Draff
parent 062e3f84bd
commit 526b8ec979
No known key found for this signature in database
GPG Key ID: E8A89F3211677653
2 changed files with 339 additions and 27 deletions

View File

@ -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"))
}

View File

@ -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<Application>() }
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<Cookie>()
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<Page> {
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<String, String>
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"))
}
}