diff --git a/.github/workflows/issue_moderator.yml b/.github/workflows/issue_moderator.yml
index 1caa5f61b..5b7ec3fbd 100644
--- a/.github/workflows/issue_moderator.yml
+++ b/.github/workflows/issue_moderator.yml
@@ -37,7 +37,7 @@ jobs:
},
{
"type": "both",
- "regex": ".*(mangago|mangafox|hq\\s*dragon|manga\\s*host|supermangas|superhentais|union\\s*mangas|yes\\s*mangas|manhuascan|manhwahot|leitor\\.?net|manga\\s*livre|tsuki\\s*mangas|manga\\s*yabu|mangas\\.in|mangas\\.pw|hentaikai|toptoon\\+?|cocomanga|hitomi\\.la|copymanga|neox|1manga\\.co|mangafox\\.fun|mangahere\\.onl|mangakakalot\\.fun|manganel(?!o)|mangaonline\\.fun|mangatoday|manga\\.town|onemanga\\.info|koushoku).*",
+ "regex": ".*(mangafox|hq\\s*dragon|manga\\s*host|supermangas|superhentais|union\\s*mangas|yes\\s*mangas|manhuascan|manhwahot|leitor\\.?net|manga\\s*livre|tsuki\\s*mangas|manga\\s*yabu|mangas\\.in|mangas\\.pw|hentaikai|toptoon\\+?|cocomanga|hitomi\\.la|copymanga|neox|1manga\\.co|mangafox\\.fun|mangahere\\.onl|mangakakalot\\.fun|manganel(?!o)|mangaonline\\.fun|mangatoday|manga\\.town|onemanga\\.info|koushoku).*",
"ignoreCase": true,
"message": "{match} will not be added back as it is too difficult to maintain. Read #3475 for more information."
},
diff --git a/REMOVED_SOURCES.md b/REMOVED_SOURCES.md
index 2b7c3c031..d75addb24 100644
--- a/REMOVED_SOURCES.md
+++ b/REMOVED_SOURCES.md
@@ -10,7 +10,6 @@
- Koushoku https://github.com/tachiyomiorg/tachiyomi-extensions/pull/13329
- Mangá Host https://github.com/tachiyomiorg/tachiyomi-extensions/pull/7065
- Mangá Livre and Leitor.net https://github.com/tachiyomiorg/tachiyomi-extensions/pull/8679
-- mangago.me https://github.com/tachiyomiorg/tachiyomi-extensions/issues/988
- MangaYabu! https://github.com/tachiyomiorg/tachiyomi-extensions/pull/9336
- ManhuaScan https://github.com/tachiyomiorg/tachiyomi-extensions/pull/7129
- ManhwaHot https://github.com/tachiyomiorg/tachiyomi-extensions/pull/7129
diff --git a/src/en/mangago/AndroidManifest.xml b/src/en/mangago/AndroidManifest.xml
new file mode 100644
index 000000000..30deb7f79
--- /dev/null
+++ b/src/en/mangago/AndroidManifest.xml
@@ -0,0 +1,2 @@
+
+
diff --git a/src/en/mangago/build.gradle b/src/en/mangago/build.gradle
new file mode 100644
index 000000000..e82b2dbf8
--- /dev/null
+++ b/src/en/mangago/build.gradle
@@ -0,0 +1,16 @@
+apply plugin: 'com.android.application'
+apply plugin: 'kotlin-android'
+
+ext {
+ extName = 'Mangago'
+ pkgNameSuffix = 'en.mangago'
+ extClass = '.Mangago'
+ extVersionCode = 8
+ isNsfw = true
+}
+
+apply from: "$rootDir/common.gradle"
+
+dependencies {
+ implementation(project(':lib-cryptoaes'))
+}
\ No newline at end of file
diff --git a/src/en/mangago/res/mipmap-hdpi/ic_launcher.png b/src/en/mangago/res/mipmap-hdpi/ic_launcher.png
new file mode 100644
index 000000000..b740bf686
Binary files /dev/null and b/src/en/mangago/res/mipmap-hdpi/ic_launcher.png differ
diff --git a/src/en/mangago/res/mipmap-mdpi/ic_launcher.png b/src/en/mangago/res/mipmap-mdpi/ic_launcher.png
new file mode 100644
index 000000000..6b567e830
Binary files /dev/null and b/src/en/mangago/res/mipmap-mdpi/ic_launcher.png differ
diff --git a/src/en/mangago/res/mipmap-xhdpi/ic_launcher.png b/src/en/mangago/res/mipmap-xhdpi/ic_launcher.png
new file mode 100644
index 000000000..5ba854ef3
Binary files /dev/null and b/src/en/mangago/res/mipmap-xhdpi/ic_launcher.png differ
diff --git a/src/en/mangago/res/mipmap-xxhdpi/ic_launcher.png b/src/en/mangago/res/mipmap-xxhdpi/ic_launcher.png
new file mode 100644
index 000000000..24f5e0995
Binary files /dev/null and b/src/en/mangago/res/mipmap-xxhdpi/ic_launcher.png differ
diff --git a/src/en/mangago/res/mipmap-xxxhdpi/ic_launcher.png b/src/en/mangago/res/mipmap-xxxhdpi/ic_launcher.png
new file mode 100644
index 000000000..28809c8bb
Binary files /dev/null and b/src/en/mangago/res/mipmap-xxxhdpi/ic_launcher.png differ
diff --git a/src/en/mangago/res/web_hi_res_512.png b/src/en/mangago/res/web_hi_res_512.png
new file mode 100644
index 000000000..cefea8c34
Binary files /dev/null and b/src/en/mangago/res/web_hi_res_512.png differ
diff --git a/src/en/mangago/src/eu/kanade/tachiyomi/extension/en/mangago/Mangago.kt b/src/en/mangago/src/eu/kanade/tachiyomi/extension/en/mangago/Mangago.kt
new file mode 100644
index 000000000..eb3c19b2d
--- /dev/null
+++ b/src/en/mangago/src/eu/kanade/tachiyomi/extension/en/mangago/Mangago.kt
@@ -0,0 +1,459 @@
+package eu.kanade.tachiyomi.extension.en.mangago
+
+import android.graphics.Bitmap
+import android.graphics.BitmapFactory
+import android.graphics.Canvas
+import android.graphics.Rect
+import android.util.Base64
+import app.cash.quickjs.QuickJs
+import eu.kanade.tachiyomi.network.GET
+import eu.kanade.tachiyomi.source.model.Filter
+import eu.kanade.tachiyomi.source.model.FilterList
+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.online.ParsedHttpSource
+import okhttp3.Headers
+import okhttp3.HttpUrl
+import okhttp3.HttpUrl.Companion.toHttpUrl
+import okhttp3.MediaType.Companion.toMediaTypeOrNull
+import okhttp3.Request
+import okhttp3.ResponseBody.Companion.toResponseBody
+import org.jsoup.nodes.Document
+import org.jsoup.nodes.Element
+import java.io.ByteArrayOutputStream
+import java.io.InputStream
+import java.net.URLEncoder
+import java.text.SimpleDateFormat
+import java.util.Locale
+import javax.crypto.Cipher
+import javax.crypto.spec.IvParameterSpec
+import javax.crypto.spec.SecretKeySpec
+
+class Mangago : ParsedHttpSource() {
+
+ override val name = "Mangago"
+
+ override val baseUrl = "https://www.mangago.me"
+
+ override val lang = "en"
+
+ override val supportsLatest = true
+
+ override val client = network.cloudflareClient.newBuilder().addInterceptor { chain ->
+ val response = chain.proceed(chain.request())
+
+ val key = response.request.url.queryParameter("desckey") ?: return@addInterceptor response
+ val cols = response.request.url.queryParameter("cols")?.toIntOrNull() ?: return@addInterceptor response
+
+ val image = unscrambleImage(response.body!!.byteStream(), key, cols)
+ val body = image.toResponseBody("image/jpeg".toMediaTypeOrNull())
+ return@addInterceptor response.newBuilder()
+ .body(body)
+ .build()
+ }.build()
+
+ override fun headersBuilder(): Headers.Builder = super.headersBuilder()
+ .add("Referer", "$baseUrl/")
+ .add("Cookie", cookiesHeader)
+
+ private val cookiesHeader by lazy {
+ val cookies = mutableMapOf()
+
+ // Needed for correct page ordering
+ cookies["_m_superu"] = "1"
+
+ buildCookies(cookies)
+ }
+
+ private val genreListingSelector = ".updatesli"
+
+ private val genreListingNextPageSelector = ".current+li > a"
+
+ private val dateFormat = SimpleDateFormat("MMM d, yyyy", Locale.ENGLISH)
+
+ private fun mangaFromElement(element: Element) = SManga.create().apply {
+ val linkElement = element.selectFirst(".thm-effect")
+
+ setUrlWithoutDomain(linkElement.attr("href"))
+ title = linkElement.attr("title")
+
+ val thumbnailElem = linkElement.selectFirst("img")
+ thumbnail_url = thumbnailElem.attr("abs:src").ifBlank { thumbnailElem.attr("abs:data-src") }
+ }
+
+ override fun popularMangaRequest(page: Int): Request =
+ GET("$baseUrl/genre/all/$page/?f=1&o=1&sortby=view&e=", headers)
+
+ override fun popularMangaSelector(): String = genreListingSelector
+
+ override fun popularMangaFromElement(element: Element) = mangaFromElement(element)
+
+ override fun popularMangaNextPageSelector() = genreListingNextPageSelector
+
+ override fun latestUpdatesRequest(page: Int) = GET("$baseUrl/genre/all/$page/?f=1&o=1&sortby=update_date&e=", headers)
+
+ override fun latestUpdatesSelector() = genreListingSelector
+
+ override fun latestUpdatesFromElement(element: Element) = mangaFromElement(element)
+
+ override fun latestUpdatesNextPageSelector() = genreListingNextPageSelector
+
+ override fun searchMangaRequest(page: Int, query: String, filters: FilterList): Request {
+ val url = if (query.isNotBlank()) {
+ "$baseUrl/r/l_search".toHttpUrl().newBuilder()
+ .addQueryParameter("name", query)
+ .addQueryParameter("page", page.toString())
+ .build().toString()
+ } else {
+ "$baseUrl/genre/".toHttpUrl().newBuilder().apply {
+ val genres = mutableListOf()
+ val genresEx = mutableListOf()
+
+ filters.ifEmpty { getFilterList() }.forEach {
+ when (it) {
+ is UriFilter -> it.addToUrl(this)
+ is GenreFilterGroup -> it.state.forEach { genre ->
+ when (genre.state) {
+ Filter.TriState.STATE_EXCLUDE -> genresEx.add(genre.name)
+ Filter.TriState.STATE_INCLUDE -> genres.add(genre.name)
+ else -> {}
+ }
+ }
+ else -> {}
+ }
+ }
+
+ if (genres.isEmpty()) {
+ addPathSegment("all")
+ } else {
+ addPathSegment(genres.joinToString(","))
+ }
+ addPathSegment(page.toString())
+
+ addQueryParameter("e", genresEx.joinToString(","))
+ }.build().toString()
+ }
+ return GET(url, headers)
+ }
+
+ override fun searchMangaSelector() = "$genreListingSelector, .pic_list .box"
+
+ override fun searchMangaFromElement(element: Element) = mangaFromElement(element)
+
+ override fun searchMangaNextPageSelector() = genreListingNextPageSelector
+
+ override fun mangaDetailsParse(document: Document) = SManga.create().apply {
+ val coverElement = document.select(".left.cover > img")
+
+ title = coverElement.attr("alt")
+ thumbnail_url = coverElement.attr("src")
+ document.select(".manga_right td").forEach {
+ when (it.getElementsByTag("label").text().trim().lowercase()) {
+ "status:" -> {
+ status = when (it.selectFirst("span").text().trim().lowercase()) {
+ "ongoing" -> SManga.ONGOING
+ "completed" -> SManga.COMPLETED
+ else -> SManga.UNKNOWN
+ }
+ }
+ "author:" -> {
+ author = it.selectFirst("a").text()
+ }
+ "genre(s):" -> {
+ genre = it.getElementsByTag("a").joinToString { it.text() }
+ }
+ }
+ }
+ description = document.selectFirst(".manga_summary").ownText().trim()
+ }
+
+ override fun chapterListSelector() = "#chapter_table > tbody > tr"
+
+ override fun chapterFromElement(element: Element) = SChapter.create().apply {
+ val link = element.getElementsByTag("a")
+
+ setUrlWithoutDomain(link.attr("href"))
+ name = link.text().trim()
+ date_upload = kotlin.runCatching {
+ dateFormat.parse(element.getElementsByClass("no").text().trim())?.time
+ }.getOrNull() ?: 0L
+ }
+
+ override fun pageListParse(document: Document): List {
+ val imgsrcsScript = document.selectFirst("script:containsData(imgsrcs)")?.html()
+ ?: throw Exception("Could not find imgsrcs")
+ val imgsrcRaw = imgSrcsRegex.find(imgsrcsScript)?.groupValues?.get(1)
+ ?: throw Exception("Could not extract imgsrcs")
+ val imgsrcs = Base64.decode(imgsrcRaw, Base64.DEFAULT)
+
+ val chapterJsUrl = document.getElementsByTag("script").first {
+ it.attr("src").contains("chapter.js", ignoreCase = true)
+ }.attr("abs:src")
+
+ val obfuscatedChapterJs = client.newCall(GET(chapterJsUrl, headers)).execute().body!!.string()
+ val deobfChapterJs = SoJsonV4Deobfuscator.decode(obfuscatedChapterJs)
+
+ val key = findHexEncodedVariable(deobfChapterJs, "key").decodeHex()
+ val iv = findHexEncodedVariable(deobfChapterJs, "iv").decodeHex()
+ val cipher = Cipher.getInstance(hashCipher)
+ val keyS = SecretKeySpec(key, aes)
+ cipher.init(Cipher.DECRYPT_MODE, keyS, IvParameterSpec(iv))
+
+ var imageList = cipher.doFinal(imgsrcs).toString(Charsets.UTF_8)
+
+ try {
+ val keyLocations = keyLocationRegex.findAll(deobfChapterJs).map {
+ it.groupValues[1].toInt()
+ }.distinct()
+
+ val unscrambleKey = keyLocations.map {
+ imageList[it].toString().toInt()
+ }.toList()
+
+ keyLocations.forEachIndexed { idx, it ->
+ imageList = imageList.removeRange(it - idx..it - idx)
+ }
+
+ imageList = imageList.unscramble(unscrambleKey)
+ } catch (e: NumberFormatException) {
+ // Only call where it should throw is imageList[it].toString().toInt().
+ // This usually means that the list is already unscrambled.
+ }
+
+ val cols = deobfChapterJs
+ .substringAfter("var widthnum=heightnum=")
+ .substringBefore(";")
+
+ return imageList
+ .split(",")
+ .mapIndexed { idx, it ->
+ val url = if (it.contains("cspiclink")) {
+ "$it?desckey=${getDescramblingKey(deobfChapterJs, it)}&cols=$cols"
+ } else {
+ it
+ }
+
+ Page(idx, imageUrl = url)
+ }
+ }
+
+ override fun imageUrlParse(document: Document): String = throw UnsupportedOperationException("Not used")
+
+ override fun getFilterList(): FilterList = FilterList(
+ Filter.Header("Ignored if using text search"),
+ StatusFilterGroup(),
+ SortFilter(),
+ GenreFilterGroup(),
+ )
+
+ private interface UriFilter {
+ fun addToUrl(builder: HttpUrl.Builder)
+ }
+
+ private class StatusFilter(name: String, val query: String) : UriFilter, Filter.CheckBox(name) {
+ override fun addToUrl(builder: HttpUrl.Builder) {
+ builder.addQueryParameter(query, if (state) "1" else "0")
+ }
+ }
+
+ private class StatusFilterGroup : UriFilter, Filter.Group(
+ "Status",
+ listOf(
+ StatusFilter("Completed", "f"),
+ StatusFilter("Ongoing", "o")
+ )
+ ) {
+ override fun addToUrl(builder: HttpUrl.Builder) {
+ state.forEach {
+ it.addToUrl(builder)
+ }
+ }
+ }
+
+ open class UriPartFilter(
+ name: String,
+ private val query: String,
+ private val vals: Array>,
+ private val firstIsUnspecified: Boolean = true,
+ state: Int = 0
+ ) : UriFilter, Filter.Select(name, vals.map { it.first }.toTypedArray(), state) {
+ override fun addToUrl(builder: HttpUrl.Builder) {
+ if (state != 0 || !firstIsUnspecified) {
+ builder.addQueryParameter(query, vals[state].second)
+ }
+ }
+ }
+
+ private class SortFilter : UriPartFilter(
+ "Sort",
+ "sortby",
+ arrayOf(
+ Pair("Random", "random"),
+ Pair("Views", "view"),
+ Pair("Comment Count", "comment_count"),
+ Pair("Creation Date", "create_date"),
+ Pair("Update Date", "update_date")
+ ),
+ state = 1,
+ )
+
+ private class GenreFilter(name: String) : Filter.TriState(name)
+
+ private class GenreFilterGroup : Filter.Group(
+ "Genres",
+ listOf(
+ GenreFilter("Yaoi"),
+ GenreFilter("Doujinshi"),
+ GenreFilter("Shounen Ai"),
+ GenreFilter("Shoujo"),
+ GenreFilter("Yuri"),
+ GenreFilter("Romance"),
+ GenreFilter("Fantasy"),
+ GenreFilter("Comedy"),
+ GenreFilter("Smut"),
+ GenreFilter("Adult"),
+ GenreFilter("School Life"),
+ GenreFilter("Mystery"),
+ GenreFilter("One Shot"),
+ GenreFilter("Ecchi"),
+ GenreFilter("Shounen"),
+ GenreFilter("Martial Arts"),
+ GenreFilter("Shoujo Ai"),
+ GenreFilter("Supernatural"),
+ GenreFilter("Drama"),
+ GenreFilter("Action"),
+ GenreFilter("Adventure"),
+ GenreFilter("Harem"),
+ GenreFilter("Historical"),
+ GenreFilter("Horror"),
+ GenreFilter("Josei"),
+ GenreFilter("Mature"),
+ GenreFilter("Mecha"),
+ GenreFilter("Psychological"),
+ GenreFilter("Sci-fi"),
+ GenreFilter("Seinen"),
+ GenreFilter("Slice Of Life"),
+ GenreFilter("Sports"),
+ GenreFilter("Gender Bender"),
+ GenreFilter("Tragedy"),
+ GenreFilter("Bara"),
+ GenreFilter("Shotacon"),
+ GenreFilter("Webtoons")
+ )
+ )
+
+ private fun findHexEncodedVariable(input: String, variable: String): String {
+ val regex = Regex("""var $variable\s*=\s*CryptoJS\.enc\.Hex\.parse\("([0-9a-zA-Z]+)"\)""")
+ return regex.find(input)?.groupValues?.get(1) ?: ""
+ }
+
+ private fun String.unscramble(keys: List): String {
+ var s = this
+ keys.reversed().forEach {
+ for (i in s.length - 1 downTo it) {
+ if (i % 2 != 0) {
+ val temp = s[i - it]
+ s = s.replaceRange(i - it..i - it, s[i].toString())
+ s = s.replaceRange(i..i, temp.toString())
+ }
+ }
+ }
+ return s
+ }
+
+ private fun unscrambleImage(image: InputStream, key: String, cols: Int): ByteArray {
+ val bitmap = BitmapFactory.decodeStream(image)
+
+ val result = Bitmap.createBitmap(bitmap.width, bitmap.height, Bitmap.Config.ARGB_8888)
+ val canvas = Canvas(result)
+
+ val unitWidth = bitmap.width / cols
+ val unitHeight = bitmap.height / cols
+
+ val keyArray = key.split("a")
+
+ for (idx in 0 until cols * cols) {
+ val keyval = keyArray[idx].ifEmpty { "0" }.toInt()
+
+ val heightY = keyval.floorDiv(cols)
+ val dy = heightY * unitHeight
+ val dx = (keyval - heightY * cols) * unitWidth
+
+ val widthY = idx.floorDiv(cols)
+ val sy = widthY * unitHeight
+ val sx = (idx - widthY * cols) * unitWidth
+
+ val srcRect = Rect(sx, sy, sx + unitWidth, sy + unitHeight)
+ val dstRect = Rect(dx, dy, dx + unitWidth, dy + unitHeight)
+
+ canvas.drawBitmap(bitmap, srcRect, dstRect, null)
+ }
+
+ val output = ByteArrayOutputStream()
+ result.compress(Bitmap.CompressFormat.JPEG, 100, output)
+
+ return output.toByteArray()
+ }
+
+ private fun buildCookies(cookies: Map) = cookies.entries.joinToString(separator = "; ", postfix = ";") {
+ "${URLEncoder.encode(it.key, "UTF-8")}=${URLEncoder.encode(it.value, "UTF-8")}"
+ }
+
+ private fun String.decodeHex(): ByteArray {
+ check(length % 2 == 0) { "Must have an even length" }
+
+ return chunked(2)
+ .map { it.toInt(16).toByte() }
+ .toByteArray()
+ }
+
+ private fun getDescramblingKey(deobfChapterJs: String, imageUrl: String): String {
+ val imgkeys = deobfChapterJs
+ .substringAfter("var renImg = function(img,width,height,id){")
+ .substringBefore("key = key.split(")
+ .split("\n")
+ .filter { jsFilters.all { filter -> !it.contains(filter) } }
+ .joinToString("\n")
+ .replace("img.src", "url")
+
+ val js = """
+ function getDescramblingKey(url) { $imgkeys; return key; }
+ getDescramblingKey("$imageUrl");
+ """.trimIndent()
+
+ return QuickJs.create().use {
+ it.execute(replacePosBytecode)
+
+ it.evaluate(js).toString()
+ }
+ }
+
+ private val jsFilters = listOf("jQuery", "document", "getContext", "toDataURL", "getImageData", "width", "height")
+
+ private val hashCipher = "AES/CBC/ZEROBYTEPADDING"
+
+ private val aes = "AES"
+
+ private val keyLocationRegex by lazy {
+ Regex("""str\.charAt\(\s*(\d+)\s*\)""")
+ }
+
+ private val imgSrcsRegex by lazy {
+ Regex("""var imgsrcs\s*=\s*['"]([a-zA-Z0-9+=/]+)['"]""")
+ }
+
+ private val replacePosBytecode by lazy {
+ QuickJs.create().use {
+ it.compile(
+ """
+ function replacePos(strObj, pos, replacetext) {
+ var str = strObj.substr(0, pos) + replacetext + strObj.substring(pos + 1, strObj.length);
+ return str;
+ }
+ """.trimIndent(),
+ "?"
+ )
+ }
+ }
+}
diff --git a/src/en/mangago/src/eu/kanade/tachiyomi/extension/en/mangago/SoJsonV4Deobfuscator.kt b/src/en/mangago/src/eu/kanade/tachiyomi/extension/en/mangago/SoJsonV4Deobfuscator.kt
new file mode 100644
index 000000000..b6aff8bd2
--- /dev/null
+++ b/src/en/mangago/src/eu/kanade/tachiyomi/extension/en/mangago/SoJsonV4Deobfuscator.kt
@@ -0,0 +1,23 @@
+package eu.kanade.tachiyomi.extension.en.mangago
+
+import kotlin.IllegalArgumentException
+
+/*
+ Ported from https://github.com/hax0r31337/JSDec/blob/master/js/dec.js
+
+ SPDX-License-Identifier: MIT
+ Copyright (c) 2020 liulihaocai
+ */
+object SoJsonV4Deobfuscator {
+ private val splitRegex: Regex = Regex("""[a-zA-Z]+""")
+
+ fun decode(jsf: String): String {
+ if (!jsf.startsWith("['sojson.v4']")) {
+ throw IllegalArgumentException("Obfuscated code is not sojson.v4")
+ }
+
+ val args = jsf.substring(240, jsf.length - 59).split(splitRegex)
+
+ return args.map { it.toInt().toChar() }.joinToString("")
+ }
+}