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") id("lib-multisrc")
} }
baseVersionCode = 4 baseVersionCode = 5

View File

@ -1,12 +1,13 @@
package eu.kanade.tachiyomi.multisrc.greenshit package eu.kanade.tachiyomi.multisrc.greenshit
import android.content.SharedPreferences import android.content.SharedPreferences
import android.util.Base64
import android.widget.Toast import android.widget.Toast
import androidx.preference.EditTextPreference import androidx.preference.EditTextPreference
import androidx.preference.PreferenceScreen import androidx.preference.PreferenceScreen
import androidx.preference.SwitchPreferenceCompat
import app.cash.quickjs.QuickJs import app.cash.quickjs.QuickJs
import eu.kanade.tachiyomi.network.GET import eu.kanade.tachiyomi.network.GET
import eu.kanade.tachiyomi.network.POST
import eu.kanade.tachiyomi.source.ConfigurableSource import eu.kanade.tachiyomi.source.ConfigurableSource
import eu.kanade.tachiyomi.source.model.FilterList import eu.kanade.tachiyomi.source.model.FilterList
import eu.kanade.tachiyomi.source.model.MangasPage 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.model.SManga
import eu.kanade.tachiyomi.source.online.HttpSource import eu.kanade.tachiyomi.source.online.HttpSource
import eu.kanade.tachiyomi.util.asJsoup import eu.kanade.tachiyomi.util.asJsoup
import keiyoushi.utils.getPreferences import keiyoushi.utils.getPreferencesLazy
import keiyoushi.utils.parseAs import keiyoushi.utils.parseAs
import keiyoushi.utils.toJsonString
import kotlinx.serialization.json.buildJsonObject
import kotlinx.serialization.json.put
import okhttp3.HttpUrl import okhttp3.HttpUrl
import okhttp3.HttpUrl.Companion.toHttpUrl import okhttp3.HttpUrl.Companion.toHttpUrl
import okhttp3.Interceptor import okhttp3.Interceptor
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.Request import okhttp3.Request
import okhttp3.RequestBody.Companion.toRequestBody
import okhttp3.Response import okhttp3.Response
import org.jsoup.nodes.Document import org.jsoup.nodes.Document
import org.jsoup.nodes.Element import org.jsoup.nodes.Element
import java.io.IOException import java.io.IOException
import javax.crypto.Mac
import javax.crypto.spec.SecretKeySpec
abstract class GreenShit( abstract class GreenShit(
override val name: String, override val name: String,
val url: String, override val baseUrl: String,
override val lang: String, override val lang: String,
val scanId: Long = 1, val scanId: Long = 1,
) : HttpSource(), ConfigurableSource { ) : HttpSource(), ConfigurableSource {
override val supportsLatest = true override val supportsLatest = true
private val isCi = System.getenv("CI") == "true" private val preferences: SharedPreferences by getPreferencesLazy()
private val preferences: SharedPreferences = getPreferences() protected open val apiUrl = "https://api.sussytoons.wtf"
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"
override val client = network.cloudflareClient.newBuilder() override val client = network.cloudflareClient.newBuilder()
.addInterceptor(::imageLocation) .addInterceptor(::imageLocation)
.build() .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 targetAudience: TargetAudience = TargetAudience.All
open val contentOrigin: ContentOrigin = ContentOrigin.Web open val contentOrigin: ContentOrigin = ContentOrigin.Web
@ -182,12 +149,13 @@ abstract class GreenShit(
private fun mangaDetailsParseWeb(response: Response): SManga { private fun mangaDetailsParseWeb(response: Response): SManga {
val json = response.parseScriptToJson().let(DETAILS_CHAPTER_REGEX::find) 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") ?: throw IOException("Details do mangá não foi encontrado")
return json.parseAs<ResultDto<MangaDto>>().results.toSManga() return json.parseAs<ResultDto<MangaDto>>().results.toSManga()
} }
// ============================= Chapters ================================= // ============================= Chapters =================================
override fun getChapterUrl(chapter: SChapter) = when (contentOrigin) { override fun getChapterUrl(chapter: SChapter) = when (contentOrigin) {
ContentOrigin.Mobile -> "$baseUrl${chapter.url}" ContentOrigin.Mobile -> "$baseUrl${chapter.url}"
else -> super.getChapterUrl(chapter) else -> super.getChapterUrl(chapter)
@ -210,7 +178,7 @@ abstract class GreenShit(
private fun chapterListParseWeb(response: Response): List<SChapter> { private fun chapterListParseWeb(response: Response): List<SChapter> {
val json = response.parseScriptToJson().let(DETAILS_CHAPTER_REGEX::find) val json = response.parseScriptToJson().let(DETAILS_CHAPTER_REGEX::find)
?.groups?.get(0)?.value ?.groups?.get(1)?.value
?: return emptyList() ?: return emptyList()
return json.parseAs<ResultDto<WrapperChapterDto>>().toSChapterList() return json.parseAs<ResultDto<WrapperChapterDto>>().toSChapterList()
} }
@ -226,8 +194,31 @@ abstract class GreenShit(
} }
private fun pageListRequestMobile(chapter: SChapter): Request { private fun pageListRequestMobile(chapter: SChapter): Request {
val pathSegment = chapter.url.replace("capitulo", "capitulo-app") val pathSegment = chapter.url.replace("capitulo", "capitulo-app-token")
return GET("$apiUrl$pathSegment", headers) 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> = override fun pageListParse(response: Response): List<Page> =
@ -284,6 +275,57 @@ abstract class GreenShit(
return GET(page.url, imageHeaders) 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 ================================= // ============================= Interceptors =================================
private fun imageLocation(chain: Interceptor.Chain): Response { private fun imageLocation(chain: Interceptor.Chain): Response {
@ -308,52 +350,45 @@ abstract class GreenShit(
// ============================= Settings ==================================== // ============================= Settings ====================================
override fun setupPreferenceScreen(screen: PreferenceScreen) { override fun setupPreferenceScreen(screen: PreferenceScreen) {
val fields = listOf( if (contentOrigin != ContentOrigin.Mobile) {
EditTextPreference(screen.context).apply { return
key = BASE_URL_PREF }
title = BASE_URL_PREF_TITLE
summary = URL_PREF_SUMMARY
dialogTitle = BASE_URL_PREF_TITLE val warning = "⚠️ Os dados inseridos nessa seção serão usados somente para realizar o login na fonte"
dialogMessage = "URL padrão:\n$defaultBaseUrl" val message = "Insira %s para prosseguir com o acesso aos recursos disponíveis na fonte"
setDefaultValue(defaultBaseUrl) EditTextPreference(screen.context).apply {
}, key = USERNAME_PREF
EditTextPreference(screen.context).apply { title = "📧 Email"
key = API_BASE_URL_PREF summary = "Email de acesso"
title = API_BASE_URL_PREF_TITLE dialogMessage = buildString {
summary = buildString { appendLine(message.format("seu email"))
append("Se não souber como verificar a URL da API, ") append("\n$warning")
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.")
}
dialogTitle = BASE_URL_PREF_TITLE setDefaultValue("")
dialogMessage = "URL da API padrão:\n$defaultApiUrl"
setDefaultValue(defaultApiUrl) setOnPreferenceChangeListener { _, _ ->
}, Toast.makeText(screen.context, RESTART_APP_MESSAGE, Toast.LENGTH_LONG).show()
true
}
}.also(screen::addPreference)
SwitchPreferenceCompat(screen.context).apply { EditTextPreference(screen.context).apply {
key = DEFAULT_PREF key = PASSWORD_PREF
title = "Redefinir configurações" title = "🔑 Senha"
summary = buildString { summary = "Senha de acesso"
append("Habilite para redefinir as configurações padrões no próximo reinicialização da aplicação.") dialogMessage = buildString {
appendLine("Você pode limpar os dados da extensão em Configurações > Avançado:") appendLine(message.format("sua senha"))
appendLine("\t - Limpar os cookies") append("\n$warning")
appendLine("\t - Limpar os dados da WebView") }
appendLine("\t - Limpar o banco de dados (Procure a '$name' e remova os dados)") setDefaultValue("")
}
setDefaultValue(false)
setOnPreferenceChangeListener { _, _ ->
Toast.makeText(screen.context, RESTART_APP_MESSAGE, Toast.LENGTH_LONG).show()
true
}
},
)
fields.forEach(screen::addPreference) setOnPreferenceChangeListener { _, _ ->
Toast.makeText(screen.context, RESTART_APP_MESSAGE, Toast.LENGTH_LONG).show()
true
}
}.also(screen::addPreference)
} }
// ============================= Utilities ==================================== // ============================= Utilities ====================================
@ -402,19 +437,14 @@ abstract class GreenShit(
val pageRegex = """capituloInicial.{3}(.*?)(\}\]\})""".toRegex() val pageRegex = """capituloInicial.{3}(.*?)(\}\]\})""".toRegex()
val POPULAR_JSON_REGEX = """(?:"dataTop":)(\{.+totalPaginas":\d+\})(?:.+"dataF)""".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 RESTART_APP_MESSAGE = "Reinicie o aplicativo para aplicar as alterações"
private const val API_BASE_URL_PREF = "overrideApiUrl" private const val TOKEN_PREF = "greenShitToken"
private const val API_BASE_URL_PREF_TITLE = "Editar URL da API da fonte" private const val USERNAME_PREF = "usernamePref"
private const val API_DEFAULT_BASE_URL_PREF = "defaultApiUrl" 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 org.jsoup.Jsoup
import java.text.Normalizer import java.text.Normalizer
import java.text.SimpleDateFormat import java.text.SimpleDateFormat
import java.util.Calendar
import java.util.Date
import java.util.Locale 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 @Serializable
class ResultDto<T>( class ResultDto<T>(
@SerialName("pagina") @SerialName("pagina")
@ -84,6 +117,12 @@ class ResultDto<T>(
} }
} }
@Serializable
class TokenDto(
@SerialName("token")
val value: String,
)
@Serializable @Serializable
class MangaDto( class MangaDto(
@SerialName("obr_id") @SerialName("obr_id")