GreenShit: Fix (#9357)

* Add token generator and add login

* Use T.toJsonString

* Use buildJsonObject

* Use buildJsonObject in login
This commit is contained in:
Chopper 2025-06-22 16:56:42 -03:00 committed by Draff
parent 074a0d7563
commit cc63389835
Signed by: Draff
GPG Key ID: E8A89F3211677653
3 changed files with 170 additions and 101 deletions

View File

@ -2,4 +2,4 @@ plugins {
id("lib-multisrc")
}
baseVersionCode = 4
baseVersionCode = 5

View File

@ -1,12 +1,13 @@
package eu.kanade.tachiyomi.multisrc.greenshit
import android.content.SharedPreferences
import android.util.Base64
import android.widget.Toast
import androidx.preference.EditTextPreference
import androidx.preference.PreferenceScreen
import androidx.preference.SwitchPreferenceCompat
import app.cash.quickjs.QuickJs
import eu.kanade.tachiyomi.network.GET
import eu.kanade.tachiyomi.network.POST
import eu.kanade.tachiyomi.source.ConfigurableSource
import eu.kanade.tachiyomi.source.model.FilterList
import eu.kanade.tachiyomi.source.model.MangasPage
@ -15,75 +16,41 @@ import eu.kanade.tachiyomi.source.model.SChapter
import eu.kanade.tachiyomi.source.model.SManga
import eu.kanade.tachiyomi.source.online.HttpSource
import eu.kanade.tachiyomi.util.asJsoup
import keiyoushi.utils.getPreferences
import keiyoushi.utils.getPreferencesLazy
import keiyoushi.utils.parseAs
import keiyoushi.utils.toJsonString
import kotlinx.serialization.json.buildJsonObject
import kotlinx.serialization.json.put
import okhttp3.HttpUrl
import okhttp3.HttpUrl.Companion.toHttpUrl
import okhttp3.Interceptor
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.Request
import okhttp3.RequestBody.Companion.toRequestBody
import okhttp3.Response
import org.jsoup.nodes.Document
import org.jsoup.nodes.Element
import java.io.IOException
import javax.crypto.Mac
import javax.crypto.spec.SecretKeySpec
abstract class GreenShit(
override val name: String,
val url: String,
override val baseUrl: String,
override val lang: String,
val scanId: Long = 1,
) : HttpSource(), ConfigurableSource {
override val supportsLatest = true
private val isCi = System.getenv("CI") == "true"
private val preferences: SharedPreferences by getPreferencesLazy()
private val preferences: SharedPreferences = getPreferences()
protected var apiUrl: String
get() = preferences.getString(API_BASE_URL_PREF, defaultApiUrl)!!
private set(value) = preferences.edit().putString(API_BASE_URL_PREF, value).apply()
private var restoreDefaultEnable: Boolean
get() = preferences.getBoolean(DEFAULT_PREF, false)
set(value) = preferences.edit().putBoolean(DEFAULT_PREF, value).apply()
override val baseUrl: String get() = when {
isCi -> defaultBaseUrl
else -> preferences.getString(BASE_URL_PREF, defaultBaseUrl)!!
}
private val defaultBaseUrl: String = url
private val defaultApiUrl: String = "https://api.sussytoons.wtf"
protected open val apiUrl = "https://api.sussytoons.wtf"
override val client = network.cloudflareClient.newBuilder()
.addInterceptor(::imageLocation)
.build()
init {
if (restoreDefaultEnable) {
restoreDefaultEnable = false
preferences.edit().putString(DEFAULT_BASE_URL_PREF, null).apply()
preferences.edit().putString(API_DEFAULT_BASE_URL_PREF, null).apply()
}
preferences.getString(DEFAULT_BASE_URL_PREF, null).let { domain ->
if (domain != defaultBaseUrl) {
preferences.edit()
.putString(BASE_URL_PREF, defaultBaseUrl)
.putString(DEFAULT_BASE_URL_PREF, defaultBaseUrl)
.apply()
}
}
preferences.getString(API_DEFAULT_BASE_URL_PREF, null).let { domain ->
if (domain != defaultApiUrl) {
preferences.edit()
.putString(API_BASE_URL_PREF, defaultApiUrl)
.putString(API_DEFAULT_BASE_URL_PREF, defaultApiUrl)
.apply()
}
}
}
open val targetAudience: TargetAudience = TargetAudience.All
open val contentOrigin: ContentOrigin = ContentOrigin.Web
@ -182,12 +149,13 @@ abstract class GreenShit(
private fun mangaDetailsParseWeb(response: Response): SManga {
val json = response.parseScriptToJson().let(DETAILS_CHAPTER_REGEX::find)
?.groups?.get(0)?.value
?.groups?.get(1)?.value
?: throw IOException("Details do mangá não foi encontrado")
return json.parseAs<ResultDto<MangaDto>>().results.toSManga()
}
// ============================= Chapters =================================
override fun getChapterUrl(chapter: SChapter) = when (contentOrigin) {
ContentOrigin.Mobile -> "$baseUrl${chapter.url}"
else -> super.getChapterUrl(chapter)
@ -210,7 +178,7 @@ abstract class GreenShit(
private fun chapterListParseWeb(response: Response): List<SChapter> {
val json = response.parseScriptToJson().let(DETAILS_CHAPTER_REGEX::find)
?.groups?.get(0)?.value
?.groups?.get(1)?.value
?: return emptyList()
return json.parseAs<ResultDto<WrapperChapterDto>>().toSChapterList()
}
@ -226,8 +194,31 @@ abstract class GreenShit(
}
private fun pageListRequestMobile(chapter: SChapter): Request {
val pathSegment = chapter.url.replace("capitulo", "capitulo-app")
return GET("$apiUrl$pathSegment", headers)
val pathSegment = chapter.url.replace("capitulo", "capitulo-app-token")
val newHeaders = headers.newBuilder()
.set("x-client-hash", generateToken(scanId, SECRET_KEY))
.set("authorization", "Bearer $token")
.build()
return GET("$apiUrl$pathSegment", newHeaders)
}
private fun generateToken(scanId: Long, secretKey: String): String {
val timestamp = System.currentTimeMillis() / 1000
val expiration = timestamp + 3600
val payload = buildJsonObject {
put("scan_id", scanId)
put("timestamp", timestamp)
put("exp", expiration)
}.toJsonString()
val hmac = Mac.getInstance("HmacSHA256")
val secretKeySpec = SecretKeySpec(secretKey.toByteArray(), "HmacSHA256")
hmac.init(secretKeySpec)
val signatureBytes = hmac.doFinal(payload.toByteArray())
val signature = signatureBytes.joinToString("") { "%02x".format(it) }
return Base64.encodeToString("$payload.$signature".toByteArray(), Base64.NO_WRAP)
}
override fun pageListParse(response: Response): List<Page> =
@ -284,6 +275,57 @@ abstract class GreenShit(
return GET(page.url, imageHeaders)
}
// ============================= Login ========================================
private val credential: Credential by lazy {
Credential(
email = preferences.getString(USERNAME_PREF, "") as String,
password = preferences.getString(PASSWORD_PREF, "") as String,
)
}
private fun Token.save(): Token {
return this.also {
preferences.edit()
.putString(TOKEN_PREF, it.toJsonString())
.apply()
}
}
private var _cache: Token? = null
private val token: Token
get() {
if (_cache != null && _cache!!.isValid()) {
return _cache!!
}
val tokenValue = preferences.getString(TOKEN_PREF, Token().toJsonString())?.parseAs<Token>()
if (tokenValue != null && tokenValue.isValid()) {
return tokenValue.also { _cache = it }
}
return credential.takeIf(Credential::isNotEmpty)?.let(::doLogin)?.let { response ->
if (response.isSuccessful.not()) {
Token.empty().save()
throw IOException("Falha ao realizar o login")
}
val tokenDto = response.parseAs<ResultDto<TokenDto>>().results
Token(tokenDto.value).also {
_cache = it.save()
}
} ?: throw IOException("Adicione suas credenciais em Extensões > $name > Configurações")
}
val loginClient = network.cloudflareClient
fun doLogin(credential: Credential): Response {
val payload = buildJsonObject {
put("usr_email", credential.email)
put("usr_senha", credential.password)
}.toJsonString().toRequestBody("application/json".toMediaType())
return loginClient.newCall(POST("$apiUrl/me/login", headers, payload)).execute()
}
// ============================= Interceptors =================================
private fun imageLocation(chain: Interceptor.Chain): Response {
@ -308,52 +350,45 @@ abstract class GreenShit(
// ============================= Settings ====================================
override fun setupPreferenceScreen(screen: PreferenceScreen) {
val fields = listOf(
EditTextPreference(screen.context).apply {
key = BASE_URL_PREF
title = BASE_URL_PREF_TITLE
summary = URL_PREF_SUMMARY
if (contentOrigin != ContentOrigin.Mobile) {
return
}
dialogTitle = BASE_URL_PREF_TITLE
dialogMessage = "URL padrão:\n$defaultBaseUrl"
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"
setDefaultValue(defaultBaseUrl)
},
EditTextPreference(screen.context).apply {
key = API_BASE_URL_PREF
title = API_BASE_URL_PREF_TITLE
summary = buildString {
append("Se não souber como verificar a URL da API, ")
append("busque suporte no Discord do repositório de extensões.")
appendLine(URL_PREF_SUMMARY)
append("\n⚠ A fonte não oferece suporte para essa extensão.")
}
EditTextPreference(screen.context).apply {
key = USERNAME_PREF
title = "📧 Email"
summary = "Email de acesso"
dialogMessage = buildString {
appendLine(message.format("seu email"))
append("\n$warning")
}
dialogTitle = BASE_URL_PREF_TITLE
dialogMessage = "URL da API padrão:\n$defaultApiUrl"
setDefaultValue("")
setDefaultValue(defaultApiUrl)
},
setOnPreferenceChangeListener { _, _ ->
Toast.makeText(screen.context, RESTART_APP_MESSAGE, Toast.LENGTH_LONG).show()
true
}
}.also(screen::addPreference)
SwitchPreferenceCompat(screen.context).apply {
key = DEFAULT_PREF
title = "Redefinir configurações"
summary = buildString {
append("Habilite para redefinir as configurações padrões no próximo reinicialização da aplicação.")
appendLine("Você pode limpar os dados da extensão em Configurações > Avançado:")
appendLine("\t - Limpar os cookies")
appendLine("\t - Limpar os dados da WebView")
appendLine("\t - Limpar o banco de dados (Procure a '$name' e remova os dados)")
}
setDefaultValue(false)
setOnPreferenceChangeListener { _, _ ->
Toast.makeText(screen.context, RESTART_APP_MESSAGE, Toast.LENGTH_LONG).show()
true
}
},
)
EditTextPreference(screen.context).apply {
key = PASSWORD_PREF
title = "🔑 Senha"
summary = "Senha de acesso"
dialogMessage = buildString {
appendLine(message.format("sua senha"))
append("\n$warning")
}
setDefaultValue("")
fields.forEach(screen::addPreference)
setOnPreferenceChangeListener { _, _ ->
Toast.makeText(screen.context, RESTART_APP_MESSAGE, Toast.LENGTH_LONG).show()
true
}
}.also(screen::addPreference)
}
// ============================= Utilities ====================================
@ -402,19 +437,14 @@ abstract class GreenShit(
val pageRegex = """capituloInicial.{3}(.*?)(\}\]\})""".toRegex()
val POPULAR_JSON_REGEX = """(?:"dataTop":)(\{.+totalPaginas":\d+\})(?:.+"dataF)""".toRegex()
val DETAILS_CHAPTER_REGEX = """(\{\"resultado.+"\}{3})""".toRegex()
val DETAILS_CHAPTER_REGEX = """\{"obra":(\{.+"\}{3})""".toRegex()
private const val URL_PREF_SUMMARY = "Para uso temporário, se a extensão for atualizada, a alteração será perdida."
private const val BASE_URL_PREF = "overrideBaseUrl"
private const val BASE_URL_PREF_TITLE = "Editar URL da fonte"
private const val DEFAULT_BASE_URL_PREF = "defaultBaseUrl"
private const val RESTART_APP_MESSAGE = "Reinicie o aplicativo para aplicar as alterações"
private const val API_BASE_URL_PREF = "overrideApiUrl"
private const val API_BASE_URL_PREF_TITLE = "Editar URL da API da fonte"
private const val API_DEFAULT_BASE_URL_PREF = "defaultApiUrl"
private const val TOKEN_PREF = "greenShitToken"
private const val USERNAME_PREF = "usernamePref"
private const val PASSWORD_PREF = "passwordPref"
private const val DEFAULT_PREF = "defaultPref"
private const val SECRET_KEY = "sua_chave_secreta_aqui_32_caracteres"
}
}

View File

@ -13,8 +13,41 @@ import okhttp3.HttpUrl.Companion.toHttpUrl
import org.jsoup.Jsoup
import java.text.Normalizer
import java.text.SimpleDateFormat
import java.util.Calendar
import java.util.Date
import java.util.Locale
@Serializable
class Token(
val value: String = "",
val updateAt: Long = Date().time,
) {
fun isValid() = value.isNotEmpty() && isExpired().not()
fun isExpired(): Boolean {
val updateAtDate = Date(updateAt)
val expiration = Calendar.getInstance().apply {
time = updateAtDate
add(Calendar.HOUR, 1)
}
return Date().after(expiration.time)
}
override fun toString() = value
companion object {
fun empty() = Token()
}
}
class Credential(
val email: String = "",
val password: String = "",
) {
fun isEmpty() = listOf(email, password).any(String::isBlank)
fun isNotEmpty() = isEmpty().not()
}
@Serializable
class ResultDto<T>(
@SerialName("pagina")
@ -84,6 +117,12 @@ class ResultDto<T>(
}
}
@Serializable
class TokenDto(
@SerialName("token")
val value: String,
)
@Serializable
class MangaDto(
@SerialName("obr_id")