diff --git a/src/all/akuma/AndroidManifest.xml b/src/all/akuma/AndroidManifest.xml new file mode 100644 index 000000000..3d6f16b29 --- /dev/null +++ b/src/all/akuma/AndroidManifest.xml @@ -0,0 +1,24 @@ + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/src/all/akuma/build.gradle b/src/all/akuma/build.gradle new file mode 100644 index 000000000..17d7c8650 --- /dev/null +++ b/src/all/akuma/build.gradle @@ -0,0 +1,12 @@ +apply plugin: 'com.android.application' +apply plugin: 'kotlin-android' + +ext { + extName = 'Akuma' + pkgNameSuffix = 'all.akuma' + extClass = '.Akuma' + extVersionCode = 1 + isNsfw = true +} + +apply from: "$rootDir/common.gradle" \ No newline at end of file diff --git a/src/all/akuma/res/mipmap-hdpi/ic_launcher.png b/src/all/akuma/res/mipmap-hdpi/ic_launcher.png new file mode 100644 index 000000000..ea33ed801 Binary files /dev/null and b/src/all/akuma/res/mipmap-hdpi/ic_launcher.png differ diff --git a/src/all/akuma/res/mipmap-mdpi/ic_launcher.png b/src/all/akuma/res/mipmap-mdpi/ic_launcher.png new file mode 100644 index 000000000..04904fe7d Binary files /dev/null and b/src/all/akuma/res/mipmap-mdpi/ic_launcher.png differ diff --git a/src/all/akuma/res/mipmap-xhdpi/ic_launcher.png b/src/all/akuma/res/mipmap-xhdpi/ic_launcher.png new file mode 100644 index 000000000..3ae326ed4 Binary files /dev/null and b/src/all/akuma/res/mipmap-xhdpi/ic_launcher.png differ diff --git a/src/all/akuma/res/mipmap-xxhdpi/ic_launcher.png b/src/all/akuma/res/mipmap-xxhdpi/ic_launcher.png new file mode 100644 index 000000000..76f579687 Binary files /dev/null and b/src/all/akuma/res/mipmap-xxhdpi/ic_launcher.png differ diff --git a/src/all/akuma/res/mipmap-xxxhdpi/ic_launcher.png b/src/all/akuma/res/mipmap-xxxhdpi/ic_launcher.png new file mode 100644 index 000000000..4fe936b67 Binary files /dev/null and b/src/all/akuma/res/mipmap-xxxhdpi/ic_launcher.png differ diff --git a/src/all/akuma/res/web_hi_res_512.png b/src/all/akuma/res/web_hi_res_512.png new file mode 100644 index 000000000..74af0b3ce Binary files /dev/null and b/src/all/akuma/res/web_hi_res_512.png differ diff --git a/src/all/akuma/src/eu/kanade/tachiyomi/extension/all/akuma/Akuma.kt b/src/all/akuma/src/eu/kanade/tachiyomi/extension/all/akuma/Akuma.kt new file mode 100644 index 000000000..71ab2bf1f --- /dev/null +++ b/src/all/akuma/src/eu/kanade/tachiyomi/extension/all/akuma/Akuma.kt @@ -0,0 +1,221 @@ +package eu.kanade.tachiyomi.extension.all.akuma + +import eu.kanade.tachiyomi.network.GET +import eu.kanade.tachiyomi.network.POST +import eu.kanade.tachiyomi.network.interceptor.rateLimit +import eu.kanade.tachiyomi.source.model.FilterList +import eu.kanade.tachiyomi.source.model.MangasPage +import eu.kanade.tachiyomi.source.model.Page +import eu.kanade.tachiyomi.source.model.SChapter +import eu.kanade.tachiyomi.source.model.SManga +import eu.kanade.tachiyomi.source.model.UpdateStrategy +import eu.kanade.tachiyomi.source.online.ParsedHttpSource +import eu.kanade.tachiyomi.util.asJsoup +import okhttp3.FormBody +import okhttp3.HttpUrl.Companion.toHttpUrlOrNull +import okhttp3.Interceptor +import okhttp3.OkHttpClient +import okhttp3.Request +import okhttp3.Response +import org.jsoup.nodes.Document +import org.jsoup.nodes.Element +import rx.Observable +import java.io.IOException + +class Akuma : ParsedHttpSource() { + + override val name = "Akuma" + + override val baseUrl = "https://akuma.moe" + + override val lang = "all" + + override val supportsLatest = false + + private var nextHash: String? = null + + private var storedToken: String? = null + + private val ddosGuardIntercept = DDosGuardInterceptor(network.client) + + override val client: OkHttpClient = network.client.newBuilder() + .addInterceptor(ddosGuardIntercept) + .addInterceptor(::tokenInterceptor) + .rateLimit(2) + .build() + + override fun headersBuilder() = super.headersBuilder() + .add("Referer", "$baseUrl/") + + private fun tokenInterceptor(chain: Interceptor.Chain): Response { + val request = chain.request() + + if (request.method == "POST" && request.header("X-CSRF-TOKEN") == null) { + val modifiedRequest = request.newBuilder() + .addHeader("X-Requested-With", "XMLHttpRequest") + + val token = getToken() + val response = chain.proceed( + modifiedRequest + .addHeader("X-CSRF-TOKEN", token) + .build(), + ) + + if (!response.isSuccessful && response.code == 419) { + response.close() + storedToken = null // reset the token + val newToken = getToken() + return chain.proceed( + modifiedRequest + .addHeader("X-CSRF-TOKEN", newToken) + .build(), + ) + } + + return response + } + + return chain.proceed(request) + } + + private fun getToken(): String { + if (storedToken.isNullOrEmpty()) { + val request = GET(baseUrl, headers) + val response = client.newCall(request).execute() + + val document = response.asJsoup() + val token = document.select("head meta[name*=csrf-token]") + .attr("content") + + if (token.isEmpty()) { + throw IOException("Unable to find CSRF token") + } + + storedToken = token + } + + return storedToken!! + } + + override fun popularMangaRequest(page: Int): Request { + val payload = FormBody.Builder() + .add("view", "3") + .build() + + return if (page == 1) { + nextHash = null + POST(baseUrl, headers, payload) + } else { + POST("$baseUrl/?cursor=$nextHash", headers, payload) + } + } + + override fun popularMangaSelector() = ".post-loop li" + override fun popularMangaNextPageSelector() = ".page-item a[rel*=next]" + + override fun popularMangaParse(response: Response): MangasPage { + val document = response.asJsoup() + + val mangas = document.select(popularMangaSelector()).map { element -> + popularMangaFromElement(element) + } + + val nextUrl = document.select(popularMangaNextPageSelector()).first()?.attr("href") + + nextHash = nextUrl?.toHttpUrlOrNull()?.queryParameter("cursor") + + return MangasPage(mangas, !nextHash.isNullOrEmpty()) + } + + override fun popularMangaFromElement(element: Element): SManga { + return SManga.create().apply { + setUrlWithoutDomain(element.select("a").attr("href")) + title = element.select(".overlay-title").text() + thumbnail_url = element.select("img").attr("abs:src") + } + } + + override fun fetchSearchManga( + page: Int, + query: String, + filters: FilterList, + ): Observable { + return if (query.startsWith(PREFIX_ID)) { + val url = "/g/${query.substringAfter(PREFIX_ID)}" + val manga = SManga.create().apply { this.url = url } + fetchMangaDetails(manga).map { + MangasPage(listOf(it.apply { this.url = url }), false) + } + } else { + super.fetchSearchManga(page, query, filters) + } + } + + override fun searchMangaRequest(page: Int, query: String, filters: FilterList): Request { + val request = popularMangaRequest(page) + + val url = request.url.newBuilder() + .addQueryParameter("q", query.trim()) + .build() + + return request.newBuilder() + .url(url) + .build() + } + + override fun searchMangaSelector() = popularMangaSelector() + override fun searchMangaNextPageSelector() = popularMangaNextPageSelector() + override fun searchMangaParse(response: Response) = popularMangaParse(response) + override fun searchMangaFromElement(element: Element) = popularMangaFromElement(element) + + override fun mangaDetailsParse(document: Document) = SManga.create().apply { + title = document.select(".entry-title").text() + thumbnail_url = document.select(".img-thumbnail").attr("abs:src") + author = document.select("li.meta-data > span.artist + span.value").text() + genre = document.select(".info-list a").joinToString { it.text() } + description = document.select(".pages span.value").text() + " Pages" + update_strategy = UpdateStrategy.ONLY_FETCH_ONCE + status = SManga.COMPLETED + } + + override fun fetchChapterList(manga: SManga): Observable> { + return Observable.just( + listOf( + SChapter.create().apply { + url = "${manga.url}/1" + name = "Chapter" + }, + ), + ) + } + + override fun pageListParse(document: Document): List { + val totalPages = document.select(".nav-select option").last() + ?.attr("value")?.toIntOrNull() ?: return emptyList() + + val url = document.location().substringBeforeLast("/") + + val pageList = mutableListOf() + + for (i in 1..totalPages) { + pageList.add(Page(i, "$url/$i")) + } + + return pageList + } + + override fun imageUrlParse(document: Document): String { + return document.select(".entry-content img").attr("abs:src") + } + + companion object { + const val PREFIX_ID = "id:" + } + + override fun latestUpdatesRequest(page: Int) = throw UnsupportedOperationException() + override fun latestUpdatesSelector() = throw UnsupportedOperationException() + override fun latestUpdatesNextPageSelector() = throw UnsupportedOperationException() + override fun latestUpdatesFromElement(element: Element) = throw UnsupportedOperationException() + override fun chapterFromElement(element: Element) = throw UnsupportedOperationException() + override fun chapterListSelector() = throw UnsupportedOperationException() +} diff --git a/src/all/akuma/src/eu/kanade/tachiyomi/extension/all/akuma/AkumaUrlActivity.kt b/src/all/akuma/src/eu/kanade/tachiyomi/extension/all/akuma/AkumaUrlActivity.kt new file mode 100644 index 000000000..e58be72e8 --- /dev/null +++ b/src/all/akuma/src/eu/kanade/tachiyomi/extension/all/akuma/AkumaUrlActivity.kt @@ -0,0 +1,34 @@ +package eu.kanade.tachiyomi.extension.all.akuma + +import android.app.Activity +import android.content.ActivityNotFoundException +import android.content.Intent +import android.os.Bundle +import android.util.Log +import kotlin.system.exitProcess + +class AkumaUrlActivity : Activity() { + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + val pathSegments = intent?.data?.pathSegments + if (pathSegments != null && pathSegments.size > 1) { + val id = pathSegments[1] + val mainIntent = Intent().apply { + action = "eu.kanade.tachiyomi.SEARCH" + putExtra("query", "${Akuma.PREFIX_ID}$id") + putExtra("filter", packageName) + } + + try { + startActivity(mainIntent) + } catch (e: ActivityNotFoundException) { + Log.e("AkumaUrlActivity", e.toString()) + } + } else { + Log.e("AkumaUrlActivity", "could not parse uri from intent $intent") + } + + finish() + exitProcess(0) + } +} diff --git a/src/all/akuma/src/eu/kanade/tachiyomi/extension/all/akuma/DDosGuardInterceptor.kt b/src/all/akuma/src/eu/kanade/tachiyomi/extension/all/akuma/DDosGuardInterceptor.kt new file mode 100644 index 000000000..1731b08ef --- /dev/null +++ b/src/all/akuma/src/eu/kanade/tachiyomi/extension/all/akuma/DDosGuardInterceptor.kt @@ -0,0 +1,67 @@ +package eu.kanade.tachiyomi.extension.all.akuma + +import android.webkit.CookieManager +import eu.kanade.tachiyomi.network.GET +import okhttp3.Cookie +import okhttp3.HttpUrl +import okhttp3.Interceptor +import okhttp3.OkHttpClient +import okhttp3.Response + +class DDosGuardInterceptor(private val client: OkHttpClient) : Interceptor { + + private val cookieManager by lazy { CookieManager.getInstance() } + + override fun intercept(chain: Interceptor.Chain): Response { + val originalRequest = chain.request() + val response = chain.proceed(originalRequest) + + // Check if DDos-GUARD is on + if (response.code !in ERROR_CODES || response.header("Server") !in SERVER_CHECK) { + return response + } + + val cookies = cookieManager.getCookie(originalRequest.url.toString()) + val oldCookie = if (cookies != null && cookies.isNotEmpty()) { + cookies.split(";").mapNotNull { Cookie.parse(originalRequest.url, it) } + } else { + emptyList() + } + val ddg2Cookie = oldCookie.firstOrNull { it.name == "__ddg2_" } + if (!ddg2Cookie?.value.isNullOrEmpty()) { + return response + } + + response.close() + + val newCookie = getNewCookie(originalRequest.url) + ?: return chain.proceed(originalRequest) + + val newCookieHeader = (oldCookie + newCookie) + .joinToString("; ") { "${it.name}=${it.value}" } + + val modifiedRequest = originalRequest.newBuilder() + .header("cookie", newCookieHeader) + .build() + + return chain.proceed(modifiedRequest) + } + + private fun getNewCookie(url: HttpUrl): Cookie? { + val wellKnown = client.newCall(GET(wellKnownUrl)) + .execute().body.string() + .substringAfter("'", "") + .substringBefore("'", "") + val checkUrl = "${url.scheme}://${url.host + wellKnown}" + val response = client.newCall(GET(checkUrl)).execute() + return response.header("set-cookie")?.let { + Cookie.parse(url, it) + } + } + + companion object { + private const val wellKnownUrl = "https://check.ddos-guard.net/check.js" + private val ERROR_CODES = listOf(403) + private val SERVER_CHECK = listOf("ddos-guard") + } +}