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
This commit is contained in:
bapeey 2024-03-25 03:21:14 -05:00 committed by Draff
parent 756f3c63ea
commit a176c34f73
5 changed files with 136 additions and 6 deletions

View File

@ -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_on=Paid chapters will appear.
pref_show_paid_chapter_summary_off=Only free chapters will be displayed. 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 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. paid_chapter_error=Paid chapter unavailable.
id_not_found_error=Failed to get the ID for slug: %s id_not_found_error=Failed to get the ID for slug: %s

View File

@ -2,7 +2,7 @@ plugins {
id("lib-multisrc") id("lib-multisrc")
} }
baseVersionCode = 21 baseVersionCode = 22
dependencies { dependencies {
api(project(":lib:i18n")) api(project(":lib:i18n"))

View File

@ -2,10 +2,12 @@ package eu.kanade.tachiyomi.multisrc.heancms
import android.app.Application import android.app.Application
import android.content.SharedPreferences import android.content.SharedPreferences
import androidx.preference.EditTextPreference
import androidx.preference.PreferenceScreen import androidx.preference.PreferenceScreen
import androidx.preference.SwitchPreferenceCompat import androidx.preference.SwitchPreferenceCompat
import eu.kanade.tachiyomi.lib.i18n.Intl import eu.kanade.tachiyomi.lib.i18n.Intl
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
@ -14,7 +16,9 @@ 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 kotlinx.serialization.decodeFromString import kotlinx.serialization.decodeFromString
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json import kotlinx.serialization.json.Json
import okhttp3.FormBody
import okhttp3.Headers import okhttp3.Headers
import okhttp3.HttpUrl.Companion.toHttpUrl import okhttp3.HttpUrl.Companion.toHttpUrl
import okhttp3.OkHttpClient import okhttp3.OkHttpClient
@ -43,6 +47,8 @@ abstract class HeanCms(
protected open val useNewChapterEndpoint = false protected open val useNewChapterEndpoint = false
protected open val enableLogin = false
/** /**
* Custom Json instance to make usage of `encodeDefaults`, * Custom Json instance to make usage of `encodeDefaults`,
* which is not enabled on the injected instance of the app. * which is not enabled on the injected instance of the app.
@ -70,6 +76,44 @@ abstract class HeanCms(
.add("Origin", baseUrl) .add("Origin", baseUrl)
.add("Referer", "$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<HeanCmsErrorsDto>()
val message = result.errors?.firstOrNull()?.message ?: intl["login_failed_unknown_error"]
throw Exception(message)
}
val result = response.parseAs<HeanCmsTokenPayloadDto>()
preferences.tokenData = result
return result.token
}
override fun popularMangaRequest(page: Int): Request { override fun popularMangaRequest(page: Int): Request {
val url = "$apiUrl/query".toHttpUrl().newBuilder() val url = "$apiUrl/query".toHttpUrl().newBuilder()
.addQueryParameter("query_string", "") .addQueryParameter("query_string", "")
@ -277,24 +321,30 @@ abstract class HeanCms(
override fun getChapterUrl(chapter: SChapter) = baseUrl + chapter.url.substringBeforeLast("#") override fun getChapterUrl(chapter: SChapter) = baseUrl + chapter.url.substringBeforeLast("#")
override fun pageListRequest(chapter: SChapter) = 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<Page> { override fun pageListParse(response: Response): List<Page> {
val result = response.parseAs<HeanCmsPagePayloadDto>() val result = response.parseAs<HeanCmsPagePayloadDto>()
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) { return if (useNewChapterEndpoint) {
result.chapter.chapterData?.images.orEmpty().mapIndexed { i, img -> result.chapter.chapterData?.images.orEmpty().mapIndexed { i, img ->
Page(i, imageUrl = img) Page(i, imageUrl = img.toAbsoluteUrl())
} }
} else { } else {
result.data.orEmpty().mapIndexed { i, img -> 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<String> = Observable.just(page.imageUrl!!) override fun fetchImageUrl(page: Page): Observable<String> = Observable.just(page.imageUrl!!)
override fun imageUrlParse(response: Response): String = "" override fun imageUrlParse(response: Response): String = ""
@ -343,6 +393,32 @@ abstract class HeanCms(
summaryOff = intl["pref_show_paid_chapter_summary_off"] summaryOff = intl["pref_show_paid_chapter_summary_off"]
setDefaultValue(SHOW_PAID_CHAPTERS_DEFAULT) setDefaultValue(SHOW_PAID_CHAPTERS_DEFAULT)
}.also(screen::addPreference) }.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 <reified T> Response.parseAs(): T = use { protected inline fun <reified T> Response.parseAs(): T = use {
@ -357,6 +433,21 @@ abstract class HeanCms(
private val SharedPreferences.showPaidChapters: Boolean private val SharedPreferences.showPaidChapters: Boolean
get() = getBoolean(SHOW_PAID_CHAPTERS_PREF, SHOW_PAID_CHAPTERS_DEFAULT) 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 { companion object {
private const val ACCEPT_IMAGE = "image/avif,image/webp,image/apng,image/svg+xml,image/*,*/*;q=0.8" 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, */*" 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_PREF = "pref_show_paid_chap"
private const val SHOW_PAID_CHAPTERS_DEFAULT = false 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)
} }
} }

View File

@ -7,6 +7,33 @@ import kotlinx.serialization.Serializable
import org.jsoup.Jsoup import org.jsoup.Jsoup
import java.text.SimpleDateFormat 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<HeanCmsErrorMessageDto>? = emptyList(),
)
@Serializable
class HeanCmsErrorMessageDto(
val message: String,
)
@Serializable @Serializable
class HeanCmsQuerySearchDto( class HeanCmsQuerySearchDto(
val data: List<HeanCmsSeriesDto> = emptyList(), val data: List<HeanCmsSeriesDto> = emptyList(),
@ -129,7 +156,7 @@ class HeanCmsPageDataDto(
) )
private fun String.toAbsoluteThumbnailUrl(apiUrl: String, coverPath: String): String { 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) { fun String.toStatus(): Int = when (this) {

View File

@ -15,6 +15,7 @@ class OmegaScans : HeanCms("Omega Scans", "https://omegascans.org", "en") {
override val versionId = 2 override val versionId = 2
override val useNewChapterEndpoint = true override val useNewChapterEndpoint = true
override val enableLogin = true
override fun getGenreList() = listOf( override fun getGenreList() = listOf(
Genre("Romance", 1), Genre("Romance", 1),