GreenShit: Fix (#9357)
* Add token generator and add login * Use T.toJsonString * Use buildJsonObject * Use buildJsonObject in login
This commit is contained in:
parent
074a0d7563
commit
cc63389835
@ -2,4 +2,4 @@ plugins {
|
|||||||
id("lib-multisrc")
|
id("lib-multisrc")
|
||||||
}
|
}
|
||||||
|
|
||||||
baseVersionCode = 4
|
baseVersionCode = 5
|
||||||
|
@ -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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -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")
|
||||||
|
Loading…
x
Reference in New Issue
Block a user