From 6901357ba119bc059f1470e7e942c95d9be908c8 Mon Sep 17 00:00:00 2001 From: bapeey <90949336+bapeey@users.noreply.github.com> Date: Mon, 25 Mar 2024 03:21:14 -0500 Subject: [PATCH] HeanCMS: Add login preference (#2071) * Add login * Bump * Remove authHeaders from imageRequest * Make token nullable * Use /login api endpoint * Review changes * Throw error * Throw api error message * Reduce one day to prevent timezone issues * Fix no scheme found * Double parenthesis --- .../assets/i18n/messages_en.properties | 4 + lib-multisrc/heancms/build.gradle.kts | 2 +- .../tachiyomi/multisrc/heancms/HeanCms.kt | 106 +++++++++++++++++- .../tachiyomi/multisrc/heancms/HeanCmsDto.kt | 29 ++++- .../extension/en/omegascans/OmegaScans.kt | 1 + 5 files changed, 136 insertions(+), 6 deletions(-) diff --git a/lib-multisrc/heancms/assets/i18n/messages_en.properties b/lib-multisrc/heancms/assets/i18n/messages_en.properties index 27bebd940..6d73b316b 100644 --- a/lib-multisrc/heancms/assets/i18n/messages_en.properties +++ b/lib-multisrc/heancms/assets/i18n/messages_en.properties @@ -13,5 +13,9 @@ pref_show_paid_chapter_title=Display paid chapters pref_show_paid_chapter_summary_on=Paid chapters will appear. pref_show_paid_chapter_summary_off=Only free chapters will be displayed. url_changed_error=The URL of the series has changed. Migrate from %s to %s to update the URL +pref_username_title=Username/Email +pref_password_title=Password +pref_credentials_summary=Ignored if empty. +login_failed_unknown_error=Unknown error occurred while logging in paid_chapter_error=Paid chapter unavailable. id_not_found_error=Failed to get the ID for slug: %s diff --git a/lib-multisrc/heancms/build.gradle.kts b/lib-multisrc/heancms/build.gradle.kts index 32ff56488..b93314227 100644 --- a/lib-multisrc/heancms/build.gradle.kts +++ b/lib-multisrc/heancms/build.gradle.kts @@ -2,7 +2,7 @@ plugins { id("lib-multisrc") } -baseVersionCode = 21 +baseVersionCode = 22 dependencies { api(project(":lib:i18n")) diff --git a/lib-multisrc/heancms/src/eu/kanade/tachiyomi/multisrc/heancms/HeanCms.kt b/lib-multisrc/heancms/src/eu/kanade/tachiyomi/multisrc/heancms/HeanCms.kt index cdb8a4e8e..b912b453d 100644 --- a/lib-multisrc/heancms/src/eu/kanade/tachiyomi/multisrc/heancms/HeanCms.kt +++ b/lib-multisrc/heancms/src/eu/kanade/tachiyomi/multisrc/heancms/HeanCms.kt @@ -2,10 +2,12 @@ package eu.kanade.tachiyomi.multisrc.heancms import android.app.Application import android.content.SharedPreferences +import androidx.preference.EditTextPreference import androidx.preference.PreferenceScreen import androidx.preference.SwitchPreferenceCompat import eu.kanade.tachiyomi.lib.i18n.Intl 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 @@ -14,7 +16,9 @@ import eu.kanade.tachiyomi.source.model.SChapter import eu.kanade.tachiyomi.source.model.SManga import eu.kanade.tachiyomi.source.online.HttpSource import kotlinx.serialization.decodeFromString +import kotlinx.serialization.encodeToString import kotlinx.serialization.json.Json +import okhttp3.FormBody import okhttp3.Headers import okhttp3.HttpUrl.Companion.toHttpUrl import okhttp3.OkHttpClient @@ -43,6 +47,8 @@ abstract class HeanCms( protected open val useNewChapterEndpoint = false + protected open val enableLogin = false + /** * Custom Json instance to make usage of `encodeDefaults`, * which is not enabled on the injected instance of the app. @@ -70,6 +76,44 @@ abstract class HeanCms( .add("Origin", baseUrl) .add("Referer", "$baseUrl/") + private fun authHeaders(): Headers { + val builder = headersBuilder() + if (enableLogin && preferences.user.isNotEmpty() && preferences.password.isNotEmpty()) { + val tokenData = preferences.tokenData + val token = if (tokenData.isExpired(tokenExpiredAtDateFormat)) { + getToken() + } else { + tokenData.token + } + if (token != null) { + builder.add("Authorization", "Bearer $token") + } + } + return builder.build() + } + + private fun getToken(): String? { + val body = FormBody.Builder() + .add("email", preferences.user) + .add("password", preferences.password) + .build() + + val response = client.newCall(POST("$apiUrl/login", headers, body)).execute() + + if (!response.isSuccessful) { + val result = response.parseAs() + val message = result.errors?.firstOrNull()?.message ?: intl["login_failed_unknown_error"] + + throw Exception(message) + } + + val result = response.parseAs() + + preferences.tokenData = result + + return result.token + } + override fun popularMangaRequest(page: Int): Request { val url = "$apiUrl/query".toHttpUrl().newBuilder() .addQueryParameter("query_string", "") @@ -277,24 +321,30 @@ abstract class HeanCms( override fun getChapterUrl(chapter: SChapter) = baseUrl + chapter.url.substringBeforeLast("#") override fun pageListRequest(chapter: SChapter) = - GET(apiUrl + chapter.url.replace("/$mangaSubDirectory/", "/chapter/"), headers) + GET(apiUrl + chapter.url.replace("/$mangaSubDirectory/", "/chapter/"), authHeaders()) override fun pageListParse(response: Response): List { val result = response.parseAs() - if (result.isPaywalled()) throw Exception(intl["paid_chapter_error"]) + if (result.isPaywalled() && result.chapter.chapterData == null) { + throw Exception(intl["paid_chapter_error"]) + } return if (useNewChapterEndpoint) { result.chapter.chapterData?.images.orEmpty().mapIndexed { i, img -> - Page(i, imageUrl = img) + Page(i, imageUrl = img.toAbsoluteUrl()) } } else { result.data.orEmpty().mapIndexed { i, img -> - Page(i, imageUrl = img) + Page(i, imageUrl = img.toAbsoluteUrl()) } } } + private fun String.toAbsoluteUrl(): String { + return if (startsWith("https://") || startsWith("http://")) this else "$apiUrl/$this" + } + override fun fetchImageUrl(page: Page): Observable = Observable.just(page.imageUrl!!) override fun imageUrlParse(response: Response): String = "" @@ -343,6 +393,32 @@ abstract class HeanCms( summaryOff = intl["pref_show_paid_chapter_summary_off"] setDefaultValue(SHOW_PAID_CHAPTERS_DEFAULT) }.also(screen::addPreference) + + if (enableLogin) { + EditTextPreference(screen.context).apply { + key = USER_PREF + title = intl["pref_username_title"] + summary = intl["pref_credentials_summary"] + setDefaultValue("") + + setOnPreferenceChangeListener { _, _ -> + preferences.tokenData = HeanCmsTokenPayloadDto() + true + } + }.also(screen::addPreference) + + EditTextPreference(screen.context).apply { + key = PASSWORD_PREF + title = intl["pref_password_title"] + summary = intl["pref_credentials_summary"] + setDefaultValue("") + + setOnPreferenceChangeListener { _, _ -> + preferences.tokenData = HeanCmsTokenPayloadDto() + true + } + }.also(screen::addPreference) + } } protected inline fun Response.parseAs(): T = use { @@ -357,6 +433,21 @@ abstract class HeanCms( private val SharedPreferences.showPaidChapters: Boolean get() = getBoolean(SHOW_PAID_CHAPTERS_PREF, SHOW_PAID_CHAPTERS_DEFAULT) + private val SharedPreferences.user: String + get() = getString(USER_PREF, "") ?: "" + + private val SharedPreferences.password: String + get() = getString(PASSWORD_PREF, "") ?: "" + + private var SharedPreferences.tokenData: HeanCmsTokenPayloadDto + get() { + val jsonString = getString(TOKEN_PREF, "{}")!! + return json.decodeFromString(jsonString) + } + set(data) { + edit().putString(TOKEN_PREF, json.encodeToString(data)).apply() + } + companion object { private const val ACCEPT_IMAGE = "image/avif,image/webp,image/apng,image/svg+xml,image/*,*/*;q=0.8" private const val ACCEPT_JSON = "application/json, text/plain, */*" @@ -367,5 +458,12 @@ abstract class HeanCms( private const val SHOW_PAID_CHAPTERS_PREF = "pref_show_paid_chap" private const val SHOW_PAID_CHAPTERS_DEFAULT = false + + private const val USER_PREF = "pref_user" + private const val PASSWORD_PREF = "pref_password" + + private const val TOKEN_PREF = "pref_token" + + private val tokenExpiredAtDateFormat = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSS'Z'", Locale.US) } } diff --git a/lib-multisrc/heancms/src/eu/kanade/tachiyomi/multisrc/heancms/HeanCmsDto.kt b/lib-multisrc/heancms/src/eu/kanade/tachiyomi/multisrc/heancms/HeanCmsDto.kt index 73870233e..7507820cd 100644 --- a/lib-multisrc/heancms/src/eu/kanade/tachiyomi/multisrc/heancms/HeanCmsDto.kt +++ b/lib-multisrc/heancms/src/eu/kanade/tachiyomi/multisrc/heancms/HeanCmsDto.kt @@ -7,6 +7,33 @@ import kotlinx.serialization.Serializable import org.jsoup.Jsoup import java.text.SimpleDateFormat +@Serializable +class HeanCmsTokenPayloadDto( + val token: String? = null, + private val expiresAt: String? = null, +) { + fun isExpired(dateFormat: SimpleDateFormat): Boolean { + val expiredTime = try { + // Reduce one day to prevent timezone issues + expiresAt?.let { dateFormat.parse(it)?.time?.minus(1000 * 60 * 60 * 24) } ?: 0L + } catch (_: Exception) { + 0L + } + + return System.currentTimeMillis() > expiredTime + } +} + +@Serializable +class HeanCmsErrorsDto( + val errors: List? = emptyList(), +) + +@Serializable +class HeanCmsErrorMessageDto( + val message: String, +) + @Serializable class HeanCmsQuerySearchDto( val data: List = emptyList(), @@ -129,7 +156,7 @@ class HeanCmsPageDataDto( ) private fun String.toAbsoluteThumbnailUrl(apiUrl: String, coverPath: String): String { - return if (startsWith("https://")) this else "$apiUrl/$coverPath$this" + return if (startsWith("https://") || startsWith("http://")) this else "$apiUrl/$coverPath$this" } fun String.toStatus(): Int = when (this) { diff --git a/src/en/omegascans/src/eu/kanade/tachiyomi/extension/en/omegascans/OmegaScans.kt b/src/en/omegascans/src/eu/kanade/tachiyomi/extension/en/omegascans/OmegaScans.kt index 05c807587..d2fa7404a 100644 --- a/src/en/omegascans/src/eu/kanade/tachiyomi/extension/en/omegascans/OmegaScans.kt +++ b/src/en/omegascans/src/eu/kanade/tachiyomi/extension/en/omegascans/OmegaScans.kt @@ -15,6 +15,7 @@ class OmegaScans : HeanCms("Omega Scans", "https://omegascans.org", "en") { override val versionId = 2 override val useNewChapterEndpoint = true + override val enableLogin = true override fun getGenreList() = listOf( Genre("Romance", 1),