Manhastro: Fix cookie lifetime (#9255)

* Fix cookie lifetime

* Move class to file

* Rename

* Use const
This commit is contained in:
Chopper 2025-06-15 03:14:22 -03:00 committed by Draff
parent a4347e9da1
commit 04a963a59a
Signed by: Draff
GPG Key ID: E8A89F3211677653
3 changed files with 146 additions and 92 deletions

View File

@ -3,7 +3,7 @@ ext {
extClass = '.Manhastro' extClass = '.Manhastro'
themePkg = 'madara' themePkg = 'madara'
baseUrl = 'https://manhastro.net' baseUrl = 'https://manhastro.net'
overrideVersionCode = 7 overrideVersionCode = 8
isNsfw = true isNsfw = true
} }

View File

@ -0,0 +1,92 @@
package eu.kanade.tachiyomi.extension.pt.manhastro
import kotlinx.serialization.Serializable
import java.text.SimpleDateFormat
import java.util.Calendar
import java.util.Date
import java.util.Locale
@Serializable
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 = Calendar.getInstance().apply {
time = Date()
add(Calendar.DATE, EXPIRE_AT)
}.let { dateFormat.format(it.time) }
}
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("dd/MM/yyyy", Locale.ROOT)
private const val EXPIRE_AT = 2
}
}
@Serializable
class Attempt(
private var attempts: Long = 0,
private var updateAt: Long = Date().time,
) {
fun takeIfUnlocked(): Long? {
if (hasNext()) {
return attempts.also {
register()
}
}
return attempts.takeIf { now.isAfter(lockPeriod) }?.let {
reset()
register()
}
}
private fun Date.isAfter(date: Date) = this.after(date)
private val now: Date get() = Date()
private val lockPeriod: Date get() = Calendar.getInstance().apply {
time = Date(updateAt)
add(Calendar.HOUR, MIN_PERIOD)
}.time
fun reset() {
attempts = 0
}
fun updateAt(): String = dateFormat.format(Date(updateAt))
fun hasNext(): Boolean = attempts <= MAX_ATTEMPT_WITHIN_PERIOD
fun register() = (attempts++).also { updateAt = Date().time }
override fun toString(): String = attempts.toString()
companion object {
const val MIN_PERIOD = 6
private const val MAX_ATTEMPT_WITHIN_PERIOD = 2
private val dateFormat = SimpleDateFormat("dd/MM/yyyy HH:mm:ss", Locale("pt", "BR"))
}
}
class Credential(
val email: String,
val password: String,
) {
val isEmpty: Boolean get() = email.isBlank() || password.isBlank()
val isNotEmpty: Boolean get() = isEmpty.not()
}

View File

@ -5,6 +5,7 @@ import android.content.SharedPreferences
import android.os.Handler import android.os.Handler
import android.os.Looper import android.os.Looper
import android.util.Base64 import android.util.Base64
import android.util.Log
import android.widget.Toast import android.widget.Toast
import androidx.preference.EditTextPreference import androidx.preference.EditTextPreference
import androidx.preference.PreferenceScreen import androidx.preference.PreferenceScreen
@ -18,7 +19,6 @@ import eu.kanade.tachiyomi.source.model.Page
import eu.kanade.tachiyomi.util.asJsoup import eu.kanade.tachiyomi.util.asJsoup
import keiyoushi.utils.getPreferences import keiyoushi.utils.getPreferences
import keiyoushi.utils.parseAs import keiyoushi.utils.parseAs
import kotlinx.serialization.Serializable
import kotlinx.serialization.decodeFromString import kotlinx.serialization.decodeFromString
import kotlinx.serialization.encodeToString import kotlinx.serialization.encodeToString
import okhttp3.CacheControl import okhttp3.CacheControl
@ -33,10 +33,9 @@ import org.jsoup.nodes.Document
import uy.kohesive.injekt.Injekt import uy.kohesive.injekt.Injekt
import uy.kohesive.injekt.api.get import uy.kohesive.injekt.api.get
import java.text.SimpleDateFormat import java.text.SimpleDateFormat
import java.util.Calendar
import java.util.Date
import java.util.Locale import java.util.Locale
import java.util.concurrent.TimeUnit import java.util.concurrent.TimeUnit
import java.util.concurrent.atomic.AtomicBoolean
class Manhastro : class Manhastro :
Madara( Madara(
@ -67,10 +66,17 @@ class Manhastro :
?: doAuth().also(::upsetCookie) ?: doAuth().also(::upsetCookie)
} }
// Prevent multiple login when rateLimit is greater than 1
private var initializedCookie = AtomicBoolean(false)
private val cookieInterceptor: Interceptor by lazy { private val cookieInterceptor: Interceptor by lazy {
return@lazy when { return@lazy when {
authCookie.isEmpty() -> bypassInterceptor authCookie.isEmpty() -> bypassInterceptor
else -> CookieInterceptor(baseUrl.substringAfterLast("/"), listOf(authCookie.value)) else -> {
if (initializedCookie.compareAndSet(false, true).not()) {
return@lazy bypassInterceptor
}
CookieInterceptor(baseUrl.substringAfterLast("/"), listOf(authCookie.value))
}
} }
} }
@ -182,27 +188,36 @@ class Manhastro :
password = preferences.getString(PASSWORD_PREF, "") as String, password = preferences.getString(PASSWORD_PREF, "") as String,
) )
private val defaultClient = network.cloudflareClient private val defaultClient = network.cloudflareClient.newBuilder()
.rateLimit(1)
.readTimeout(1, TimeUnit.MINUTES)
.connectTimeout(1, TimeUnit.MINUTES)
.build()
var attempts: Int private var _cache: Attempt? = null
get() = preferences.getInt(COOKIE_ATTEMPT, 0) val attempt: Attempt
set(value) { get() {
preferences.edit() if (_cache != null) {
.putInt(COOKIE_ATTEMPT, value) return _cache!!
}
val stored = preferences.getString(COOKIE_ATTEMPT_REF, "")
if (stored.isNullOrBlank()) {
return Attempt().also {
_cache = it
preferences
.edit()
.putString(COOKIE_ATTEMPT_REF, json.encodeToString(it))
.apply() .apply()
} }
var lastAttempt: Date?
get() {
val time = preferences.getLong(COOKIE_LAST_REQUEST, 0)
if (time == 0L) {
return null
} }
return Date(time) return stored.parseAs<Attempt>().also { _cache = it }
} }
set(value) {
val time = value?.time ?: 0L private fun Attempt.save() {
preferences.edit() preferences
.putLong(COOKIE_LAST_REQUEST, time) .edit()
.putString(COOKIE_ATTEMPT_REF, json.encodeToString(this))
.apply() .apply()
} }
@ -211,32 +226,13 @@ class Manhastro :
return Cookie.empty() return Cookie.empty()
} }
attempts++ val attemptCount = attempt.takeIfUnlocked() ?: return Cookie.empty().also {
/** showToast("Login permitido após ${Attempt.MIN_PERIOD}h de ${attempt.updateAt()}")
* 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 { Log.i(Manhastro::class.simpleName, "trying: ${attemptCount}x")
time = lastAttempt!!
add(Calendar.HOUR, 6)
}
when { attempt.save()
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 document = defaultClient.newCall(GET(baseUrl, headers)).execute().asJsoup()
val nonce = document.selectFirst("script#wp-manga-login-ajax-js-extra") val nonce = document.selectFirst("script#wp-manga-login-ajax-js-extra")
@ -249,17 +245,22 @@ class Manhastro :
val formHeaders = headers.newBuilder() val formHeaders = headers.newBuilder()
.set("Accept", "*/*") .set("Accept", "*/*")
.set("Accept-Encoding", "gzip, deflate, br") .set("Accept-Encoding", "gzip, deflate, br")
.set("Accept-Language", "en-US,en;q=0.9") .set("Accept-Language", "pt-BR,en-US;q=0.7,en;q=0.3")
.set("Connection", "keep-alive") .set("Connection", "keep-alive")
.set("Origin", baseUrl) .set("Origin", baseUrl)
.set("Referer", "$baseUrl/") .set("Referer", "$baseUrl/")
.set("Sec-Fetch-Site", "same-origin")
.set("Sec-Fetch-Mode", "cors")
.set("Sec-Fetch-Dest", "empty")
.set("Priority", "u=0")
.set("TE", "trailers")
.set("X-Requested-With", "XMLHttpRequest") .set("X-Requested-With", "XMLHttpRequest")
.build() .build()
val form = FormBody.Builder() val form = FormBody.Builder()
.add("action", "wp_manga_signin") .add("action", "wp_manga_signin")
.add("login", credentials.email) .add("login", credentials.email)
.add("pass", credentials.password) .addEncoded("pass", credentials.password)
.add("rememberme", "forever") .add("rememberme", "forever")
.add("nonce", nonce) .add("nonce", nonce)
.build() .build()
@ -276,6 +277,7 @@ class Manhastro :
Falha ao acessar recurso: Usuário ou senha incorreto. Falha ao acessar recurso: Usuário ou senha incorreto.
Altere suas credências em Extensões > $name > Configuração. Altere suas credências em Extensões > $name > Configuração.
""".trimIndent() """.trimIndent()
response.use { response.use {
if (it.isSuccessful.not()) { if (it.isSuccessful.not()) {
showToast(message) showToast(message)
@ -290,43 +292,6 @@ class Manhastro :
} }
} }
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 ==================================== // ============================ Utilities ====================================
private fun OkHttpClient.Builder.addNetworkInterceptorIf( private fun OkHttpClient.Builder.addNetworkInterceptorIf(
@ -363,15 +328,12 @@ class Manhastro :
} }
companion object { 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 RESTART_APP_MESSAGE = "Reinicie o aplicativo para aplicar as alterações"
private const val USERNAME_PREF = "MANHASTRO_USERNAME" private const val USERNAME_PREF = "MANHASTRO_USERNAME"
private const val PASSWORD_PREF = "MANHASTRO_PASSWORD" private const val PASSWORD_PREF = "MANHASTRO_PASSWORD"
private const val COOKIE_STORAGE_PREF = "MANHASTRO_COOKIE_STORAGE" private const val COOKIE_STORAGE_PREF = "MANHASTRO_COOKIE_STORAGE"
private const val COOKIE_LAST_REQUEST = "MANHASTRO_COOKIE_LAST_REQUEST" private const val COOKIE_ATTEMPT_REF = "MANHASTRO_COOKIE_ATTEMPT_REF"
private const val COOKIE_ATTEMPT = "MANHASTRO_COOKIE_ATTEMPT"
private const val IMG_CONTENT_TYPE = "image/jpeg" private const val IMG_CONTENT_TYPE = "image/jpeg"
private val NONCE_LOGIN_REGEX = """"nonce":"([^"]+)""".toRegex() private val NONCE_LOGIN_REGEX = """"nonce":"([^"]+)""".toRegex()
val toastDateFormat = SimpleDateFormat("dd/MM/yyyy HH:mm:ss", Locale("pt", "BR"))
} }
} }