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 GitHub
parent c6763f5f62
commit 6901357ba1
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
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_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

View File

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

View File

@ -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<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 {
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<Page> {
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) {
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<String> = 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 <reified T> 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)
}
}

View File

@ -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<HeanCmsErrorMessageDto>? = emptyList(),
)
@Serializable
class HeanCmsErrorMessageDto(
val message: String,
)
@Serializable
class HeanCmsQuerySearchDto(
val data: List<HeanCmsSeriesDto> = 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) {

View File

@ -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),