diff --git a/server/src/main/kotlin/suwayomi/anime/AnimeAPI.kt b/server/src/main/kotlin/suwayomi/anime/AnimeAPI.kt new file mode 100644 index 00000000..7bef99a4 --- /dev/null +++ b/server/src/main/kotlin/suwayomi/anime/AnimeAPI.kt @@ -0,0 +1,367 @@ +package suwayomi.anime + +/* + * Copyright (C) Contributors to the Suwayomi project + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. */ + +import io.javalin.Javalin +import suwayomi.server.JavalinSetup +import suwayomi.anime.impl.extension.ExtensionsList.getExtensionList + +object AnimeAPI { + fun defineEndpoints(app: Javalin) { + // list all extensions + app.get("/api/v1/extension/list") { ctx -> + ctx.json( + JavalinSetup.future { + getExtensionList() + } + ) + } + +// // install extension identified with "pkgName" +// app.get("/api/v1/extension/install/:pkgName") { ctx -> +// val pkgName = ctx.pathParam("pkgName") +// +// ctx.json( +// JavalinSetup.future { +// installExtension(pkgName) +// } +// ) +// } +// +// // update extension identified with "pkgName" +// app.get("/api/v1/extension/update/:pkgName") { ctx -> +// val pkgName = ctx.pathParam("pkgName") +// +// ctx.json( +// JavalinSetup.future { +// updateExtension(pkgName) +// } +// ) +// } +// +// // uninstall extension identified with "pkgName" +// app.get("/api/v1/extension/uninstall/:pkgName") { ctx -> +// val pkgName = ctx.pathParam("pkgName") +// +// uninstallExtension(pkgName) +// ctx.status(200) +// } +// +// // icon for extension named `apkName` +// app.get("/api/v1/extension/icon/:apkName") { ctx -> // TODO: move to pkgName +// val apkName = ctx.pathParam("apkName") +// +// ctx.result( +// JavalinSetup.future { getExtensionIcon(apkName) } +// .thenApply { +// ctx.header("content-type", it.second) +// it.first +// } +// ) +// } + +// // list of sources +// app.get("/api/v1/source/list") { ctx -> +// ctx.json(getSourceList()) +// } +// +// // fetch source with id `sourceId` +// app.get("/api/v1/source/:sourceId") { ctx -> +// val sourceId = ctx.pathParam("sourceId").toLong() +// ctx.json(getSource(sourceId)) +// } +// +// // popular mangas from source with id `sourceId` +// app.get("/api/v1/source/:sourceId/popular/:pageNum") { ctx -> +// val sourceId = ctx.pathParam("sourceId").toLong() +// val pageNum = ctx.pathParam("pageNum").toInt() +// ctx.json( +// JavalinSetup.future { +// getMangaList(sourceId, pageNum, popular = true) +// } +// ) +// } +// +// // latest mangas from source with id `sourceId` +// app.get("/api/v1/source/:sourceId/latest/:pageNum") { ctx -> +// val sourceId = ctx.pathParam("sourceId").toLong() +// val pageNum = ctx.pathParam("pageNum").toInt() +// ctx.json( +// JavalinSetup.future { +// getMangaList(sourceId, pageNum, popular = false) +// } +// ) +// } +// +// // get manga info +// app.get("/api/v1/manga/:mangaId/") { ctx -> +// val mangaId = ctx.pathParam("mangaId").toInt() +// val onlineFetch = ctx.queryParam("onlineFetch", "false").toBoolean() +// +// ctx.json( +// JavalinSetup.future { +// getManga(mangaId, onlineFetch) +// } +// ) +// } +// +// // manga thumbnail +// app.get("api/v1/manga/:mangaId/thumbnail") { ctx -> +// val mangaId = ctx.pathParam("mangaId").toInt() +// +// ctx.result( +// JavalinSetup.future { getMangaThumbnail(mangaId) } +// .thenApply { +// ctx.header("content-type", it.second) +// it.first +// } +// ) +// } +// +// // list manga's categories +// app.get("api/v1/manga/:mangaId/category/") { ctx -> +// val mangaId = ctx.pathParam("mangaId").toInt() +// ctx.json(getMangaCategories(mangaId)) +// } +// +// // adds the manga to category +// app.get("api/v1/manga/:mangaId/category/:categoryId") { ctx -> +// val mangaId = ctx.pathParam("mangaId").toInt() +// val categoryId = ctx.pathParam("categoryId").toInt() +// addMangaToCategory(mangaId, categoryId) +// ctx.status(200) +// } +// +// // removes the manga from the category +// app.delete("api/v1/manga/:mangaId/category/:categoryId") { ctx -> +// val mangaId = ctx.pathParam("mangaId").toInt() +// val categoryId = ctx.pathParam("categoryId").toInt() +// removeMangaFromCategory(mangaId, categoryId) +// ctx.status(200) +// } +// +// // get chapter list when showing a manga +// app.get("/api/v1/manga/:mangaId/chapters") { ctx -> +// val mangaId = ctx.pathParam("mangaId").toInt() +// +// val onlineFetch = ctx.queryParam("onlineFetch")?.toBoolean() +// +// ctx.json(JavalinSetup.future { getChapterList(mangaId, onlineFetch) }) +// } +// +// // used to display a chapter, get a chapter in order to show it's pages +// app.get("/api/v1/manga/:mangaId/chapter/:chapterIndex") { ctx -> +// val chapterIndex = ctx.pathParam("chapterIndex").toInt() +// val mangaId = ctx.pathParam("mangaId").toInt() +// ctx.json(JavalinSetup.future { getChapter(chapterIndex, mangaId) }) +// } +// +// // used to modify a chapter's parameters +// app.patch("/api/v1/manga/:mangaId/chapter/:chapterIndex") { ctx -> +// val chapterIndex = ctx.pathParam("chapterIndex").toInt() +// val mangaId = ctx.pathParam("mangaId").toInt() +// +// val read = ctx.formParam("read")?.toBoolean() +// val bookmarked = ctx.formParam("bookmarked")?.toBoolean() +// val markPrevRead = ctx.formParam("markPrevRead")?.toBoolean() +// val lastPageRead = ctx.formParam("lastPageRead")?.toInt() +// +// modifyChapter(mangaId, chapterIndex, read, bookmarked, markPrevRead, lastPageRead) +// +// ctx.status(200) +// } +// +// // get page at index "index" +// app.get("/api/v1/manga/:mangaId/chapter/:chapterIndex/page/:index") { ctx -> +// val mangaId = ctx.pathParam("mangaId").toInt() +// val chapterIndex = ctx.pathParam("chapterIndex").toInt() +// val index = ctx.pathParam("index").toInt() +// +// ctx.result( +// JavalinSetup.future { getPageImage(mangaId, chapterIndex, index) } +// .thenApply { +// ctx.header("content-type", it.second) +// it.first +// } +// ) +// } +// +// // submit a chapter for download +// app.put("/api/v1/manga/:mangaId/chapter/:chapterIndex/download") { ctx -> +// // TODO +// } +// +// // cancel a chapter download +// app.delete("/api/v1/manga/:mangaId/chapter/:chapterIndex/download") { ctx -> +// // TODO +// } +// +// // global search, Not implemented yet +// app.get("/api/v1/search/:searchTerm") { ctx -> +// val searchTerm = ctx.pathParam("searchTerm") +// ctx.json(sourceGlobalSearch(searchTerm)) +// } +// +// // single source search +// app.get("/api/v1/source/:sourceId/search/:searchTerm/:pageNum") { ctx -> +// val sourceId = ctx.pathParam("sourceId").toLong() +// val searchTerm = ctx.pathParam("searchTerm") +// val pageNum = ctx.pathParam("pageNum").toInt() +// ctx.json(JavalinSetup.future { sourceSearch(sourceId, searchTerm, pageNum) }) +// } +// +// // source filter list +// app.get("/api/v1/source/:sourceId/filters/") { ctx -> +// val sourceId = ctx.pathParam("sourceId").toLong() +// ctx.json(sourceFilters(sourceId)) +// } +// +// // adds the manga to library +// app.get("api/v1/manga/:mangaId/library") { ctx -> +// val mangaId = ctx.pathParam("mangaId").toInt() +// +// ctx.result( +// JavalinSetup.future { addMangaToLibrary(mangaId) } +// ) +// } +// +// // removes the manga from the library +// app.delete("api/v1/manga/:mangaId/library") { ctx -> +// val mangaId = ctx.pathParam("mangaId").toInt() +// +// ctx.result( +// JavalinSetup.future { removeMangaFromLibrary(mangaId) } +// ) +// } +// +// // lists mangas that have no category assigned +// app.get("/api/v1/library/") { ctx -> +// ctx.json(getLibraryMangas()) +// } +// +// // category list +// app.get("/api/v1/category/") { ctx -> +// ctx.json(Category.getCategoryList()) +// } +// +// // category create +// app.post("/api/v1/category/") { ctx -> +// val name = ctx.formParam("name")!! +// Category.createCategory(name) +// ctx.status(200) +// } +// +// // returns some static info of the current app build +// app.get("/api/v1/about/") { ctx -> +// ctx.json(About.getAbout()) +// } +// +// // category modification +// app.patch("/api/v1/category/:categoryId") { ctx -> +// val categoryId = ctx.pathParam("categoryId").toInt() +// val name = ctx.formParam("name") +// val isDefault = ctx.formParam("default")?.toBoolean() +// Category.updateCategory(categoryId, name, isDefault) +// ctx.status(200) +// } +// +// // category re-ordering +// app.patch("/api/v1/category/:categoryId/reorder") { ctx -> +// val categoryId = ctx.pathParam("categoryId").toInt() +// val from = ctx.formParam("from")!!.toInt() +// val to = ctx.formParam("to")!!.toInt() +// Category.reorderCategory(categoryId, from, to) +// ctx.status(200) +// } +// +// // category delete +// app.delete("/api/v1/category/:categoryId") { ctx -> +// val categoryId = ctx.pathParam("categoryId").toInt() +// Category.removeCategory(categoryId) +// ctx.status(200) +// } +// +// // returns the manga list associated with a category +// app.get("/api/v1/category/:categoryId") { ctx -> +// val categoryId = ctx.pathParam("categoryId").toInt() +// ctx.json(getCategoryMangaList(categoryId)) +// } +// +// // expects a Tachiyomi legacy backup json in the body +// app.post("/api/v1/backup/legacy/import") { ctx -> +// ctx.result( +// future { +// restoreLegacyBackup(ctx.bodyAsInputStream()) +// } +// ) +// } +// +// // expects a Tachiyomi legacy backup json as a file upload, the file must be named "backup.json" +// app.post("/api/v1/backup/legacy/import/file") { ctx -> +// ctx.result( +// JavalinSetup.future { +// restoreLegacyBackup(ctx.uploadedFile("backup.json")!!.content) +// } +// ) +// } +// +// // returns a Tachiyomi legacy backup json created from the current database as a json body +// app.get("/api/v1/backup/legacy/export") { ctx -> +// ctx.contentType("application/json") +// ctx.result( +// JavalinSetup.future { +// createLegacyBackup( +// BackupFlags( +// includeManga = true, +// includeCategories = true, +// includeChapters = true, +// includeTracking = true, +// includeHistory = true, +// ) +// ) +// } +// ) +// } +// +// // returns a Tachiyomi legacy backup json created from the current database as a file +// app.get("/api/v1/backup/legacy/export/file") { ctx -> +// ctx.contentType("application/json") +// val sdf = SimpleDateFormat("yyyy-MM-dd_HH-mm") +// val currentDate = sdf.format(Date()) +// +// ctx.header("Content-Disposition", "attachment; filename=\"tachidesk_$currentDate.json\"") +// ctx.result( +// JavalinSetup.future { +// createLegacyBackup( +// BackupFlags( +// includeManga = true, +// includeCategories = true, +// includeChapters = true, +// includeTracking = true, +// includeHistory = true, +// ) +// ) +// } +// ) +// } +// +// // Download queue stats +// app.ws("/api/v1/downloads") { ws -> +// ws.onConnect { ctx -> +// // TODO: send current stat +// // TODO: add to downlad subscribers +// } +// ws.onMessage { +// // TODO: send current stat +// } +// ws.onClose { ctx -> +// // TODO: remove from subscribers +// } +// } + } +} diff --git a/server/src/main/kotlin/suwayomi/anime/impl/extension/Extension.kt b/server/src/main/kotlin/suwayomi/anime/impl/extension/Extension.kt new file mode 100644 index 00000000..207b40d6 --- /dev/null +++ b/server/src/main/kotlin/suwayomi/anime/impl/extension/Extension.kt @@ -0,0 +1,251 @@ +package suwayomi.anime.impl.extension + +/* + * Copyright (C) Contributors to the Suwayomi project + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. */ + +import android.net.Uri +import eu.kanade.tachiyomi.network.GET +import eu.kanade.tachiyomi.network.NetworkHelper +import eu.kanade.tachiyomi.source.CatalogueSource +import eu.kanade.tachiyomi.source.Source +import eu.kanade.tachiyomi.source.SourceFactory +import mu.KotlinLogging +import okhttp3.Request +import okio.buffer +import okio.sink +import org.jetbrains.exposed.sql.deleteWhere +import org.jetbrains.exposed.sql.insert +import org.jetbrains.exposed.sql.select +import org.jetbrains.exposed.sql.transactions.transaction +import org.jetbrains.exposed.sql.update +import org.kodein.di.DI +import org.kodein.di.conf.global +import org.kodein.di.instance +import suwayomi.anime.impl.extension.ExtensionsList.extensionTableAsDataClass +import suwayomi.anime.impl.extension.github.ExtensionGithubApi +import suwayomi.anime.impl.util.PackageTools.EXTENSION_FEATURE +import suwayomi.anime.impl.util.PackageTools.LIB_VERSION_MAX +import suwayomi.anime.impl.util.PackageTools.LIB_VERSION_MIN +import suwayomi.anime.impl.util.PackageTools.METADATA_NSFW +import suwayomi.anime.impl.util.PackageTools.METADATA_SOURCE_CLASS +import suwayomi.anime.impl.util.PackageTools.dex2jar +import suwayomi.anime.impl.util.PackageTools.getPackageInfo +import suwayomi.anime.impl.util.PackageTools.getSignatureHash +import suwayomi.anime.impl.util.PackageTools.loadExtensionSources +import suwayomi.anime.impl.util.PackageTools.trustedSignatures +import suwayomi.anime.model.table.AnimeExtensionTable +import suwayomi.server.ApplicationDirs +import suwayomi.tachidesk.impl.util.network.await +import suwayomi.tachidesk.impl.util.storage.CachedImageResponse.getCachedImageResponse +import suwayomi.tachidesk.model.table.SourceTable +import uy.kohesive.injekt.injectLazy +import java.io.File +import java.io.InputStream + +object Extension { + private val logger = KotlinLogging.logger {} + private val applicationDirs by DI.global.instance() + + data class InstallableAPK( + val apkFilePath: String, + val pkgName: String + ) + + suspend fun installExtension(pkgName: String): Int { + logger.debug("Installing $pkgName") + val extensionRecord = extensionTableAsDataClass().first { it.pkgName == pkgName } + + return installAPK { + val apkURL = ExtensionGithubApi.getApkUrl(extensionRecord) + val apkName = Uri.parse(apkURL).lastPathSegment!! + val apkSavePath = "${applicationDirs.extensionsRoot}/$apkName" + // download apk file + downloadAPKFile(apkURL, apkSavePath) + + apkSavePath + } + } + + suspend fun installAPK(fetcher: suspend () -> String): Int { + val apkFilePath = fetcher() + val apkName = File(apkFilePath).name + + // check if we don't have the extension already installed + // if it's installed and we want to update, it first has to be uninstalled + val isInstalled = transaction { + AnimeExtensionTable.select { AnimeExtensionTable.apkName eq apkName }.firstOrNull() + }?.get(AnimeExtensionTable.isInstalled) ?: false + + if (!isInstalled) { + val fileNameWithoutType = apkName.substringBefore(".apk") + + val dirPathWithoutType = "${applicationDirs.extensionsRoot}/$fileNameWithoutType" + val jarFilePath = "$dirPathWithoutType.jar" + val dexFilePath = "$dirPathWithoutType.dex" + + val packageInfo = getPackageInfo(apkFilePath) + val pkgName = packageInfo.packageName + + if (!packageInfo.reqFeatures.orEmpty().any { it.name == EXTENSION_FEATURE }) { + throw Exception("This apk is not a Tachiyomi extension") + } + + // Validate lib version + val libVersion = packageInfo.versionName.substringBeforeLast('.').toDouble() + if (libVersion < LIB_VERSION_MIN || libVersion > LIB_VERSION_MAX) { + throw Exception( + "Lib version is $libVersion, while only versions " + + "$LIB_VERSION_MIN to $LIB_VERSION_MAX are allowed" + ) + } + + val signatureHash = getSignatureHash(packageInfo) + + if (signatureHash == null) { + throw Exception("Package $pkgName isn't signed") + } else if (signatureHash !in trustedSignatures) { + // TODO: allow trusting keys + throw Exception("This apk is not a signed with the official tachiyomi signature") + } + + val isNsfw = packageInfo.applicationInfo.metaData.getString(METADATA_NSFW) == "1" + + val className = packageInfo.packageName + packageInfo.applicationInfo.metaData.getString(METADATA_SOURCE_CLASS) + + logger.debug("Main class for extension is $className") + + dex2jar(apkFilePath, jarFilePath, fileNameWithoutType) + + // clean up +// File(apkFilePath).delete() + File(dexFilePath).delete() + + // collect sources from the extension + val sources: List = when (val instance = loadExtensionSources(jarFilePath, className)) { + is Source -> listOf(instance) + is SourceFactory -> instance.createSources() + else -> throw RuntimeException("Unknown source class type! ${instance.javaClass}") + }.map { it as CatalogueSource } + + val langs = sources.map { it.lang }.toSet() + val extensionLang = when (langs.size) { + 0 -> "" + 1 -> langs.first() + else -> "all" + } + + val extensionName = packageInfo.applicationInfo.nonLocalizedLabel.toString().substringAfter("Tachiyomi: ") + + // update extension info + transaction { + if (AnimeExtensionTable.select { AnimeExtensionTable.pkgName eq pkgName }.firstOrNull() == null) { + AnimeExtensionTable.insert { + it[this.apkName] = apkName + it[name] = extensionName + it[this.pkgName] = packageInfo.packageName + it[versionName] = packageInfo.versionName + it[versionCode] = packageInfo.versionCode + it[lang] = extensionLang + it[this.isNsfw] = isNsfw + } + } + + AnimeExtensionTable.update({ AnimeExtensionTable.pkgName eq pkgName }) { + it[this.isInstalled] = true + it[this.classFQName] = className + } + + val extensionId = AnimeExtensionTable.select { AnimeExtensionTable.pkgName eq pkgName }.first()[AnimeExtensionTable.id].value + + sources.forEach { httpSource -> + SourceTable.insert { + it[id] = httpSource.id + it[name] = httpSource.name + it[lang] = httpSource.lang + it[extension] = extensionId + } + logger.debug("Installed source ${httpSource.name} (${httpSource.lang}) with id:${httpSource.id}") + } + } + return 201 // we installed successfully + } else { + return 302 // extension was already installed + } + } + + private val network: NetworkHelper by injectLazy() + + private suspend fun downloadAPKFile(url: String, savePath: String) { + val request = Request.Builder().url(url).build() + val response = network.client.newCall(request).await() + + val downloadedFile = File(savePath) + downloadedFile.sink().buffer().use { sink -> + response.body!!.source().use { source -> + sink.writeAll(source) + sink.flush() + } + } + } + + fun uninstallExtension(pkgName: String) { + logger.debug("Uninstalling $pkgName") + + val extensionRecord = transaction { AnimeExtensionTable.select { AnimeExtensionTable.pkgName eq pkgName }.first() } + val fileNameWithoutType = extensionRecord[AnimeExtensionTable.apkName].substringBefore(".apk") + val jarPath = "${applicationDirs.extensionsRoot}/$fileNameWithoutType.jar" + transaction { + val extensionId = extensionRecord[AnimeExtensionTable.id].value + + SourceTable.deleteWhere { SourceTable.extension eq extensionId } + if (extensionRecord[AnimeExtensionTable.isObsolete]) + AnimeExtensionTable.deleteWhere { AnimeExtensionTable.pkgName eq pkgName } + else + AnimeExtensionTable.update({ AnimeExtensionTable.pkgName eq pkgName }) { + it[isInstalled] = false + } + } + + if (File(jarPath).exists()) { + File(jarPath).delete() + } + } + + suspend fun updateExtension(pkgName: String): Int { + val targetExtension = ExtensionsList.updateMap.remove(pkgName)!! + uninstallExtension(pkgName) + transaction { + AnimeExtensionTable.update({ AnimeExtensionTable.pkgName eq pkgName }) { + it[name] = targetExtension.name + it[versionName] = targetExtension.versionName + it[versionCode] = targetExtension.versionCode + it[lang] = targetExtension.lang + it[isNsfw] = targetExtension.isNsfw + it[apkName] = targetExtension.apkName + it[iconUrl] = targetExtension.iconUrl + it[hasUpdate] = false + } + } + return installExtension(pkgName) + } + + suspend fun getExtensionIcon(apkName: String): Pair { + val iconUrl = transaction { AnimeExtensionTable.select { AnimeExtensionTable.apkName eq apkName }.first() }[AnimeExtensionTable.iconUrl] + + val saveDir = "${applicationDirs.extensionsRoot}/icon" + + return getCachedImageResponse(saveDir, apkName) { + network.client.newCall( + GET(iconUrl) + ).await() + } + } + + fun getExtensionIconUrl(apkName: String): String { + return "/api/v1/extension/icon/$apkName" + } +} diff --git a/server/src/main/kotlin/suwayomi/anime/impl/extension/ExtensionsList.kt b/server/src/main/kotlin/suwayomi/anime/impl/extension/ExtensionsList.kt new file mode 100644 index 00000000..bc6a46d4 --- /dev/null +++ b/server/src/main/kotlin/suwayomi/anime/impl/extension/ExtensionsList.kt @@ -0,0 +1,132 @@ +package suwayomi.anime.impl.extension + +/* + * Copyright (C) Contributors to the Suwayomi project + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. */ + +import mu.KotlinLogging +import org.jetbrains.exposed.sql.deleteWhere +import org.jetbrains.exposed.sql.insert +import org.jetbrains.exposed.sql.select +import org.jetbrains.exposed.sql.selectAll +import org.jetbrains.exposed.sql.transactions.transaction +import org.jetbrains.exposed.sql.update +import suwayomi.anime.impl.extension.Extension.getExtensionIconUrl +import suwayomi.anime.impl.extension.github.ExtensionGithubApi +import suwayomi.anime.impl.extension.github.OnlineExtension +import suwayomi.anime.model.dataclass.AnimeExtensionDataClass +import suwayomi.anime.model.table.AnimeExtensionTable +import java.util.concurrent.ConcurrentHashMap + +object ExtensionsList { + private val logger = KotlinLogging.logger {} + + var lastUpdateCheck: Long = 0 + var updateMap = ConcurrentHashMap() + + /** 60,000 milliseconds = 60 seconds */ + private const val ExtensionUpdateDelayTime = 60 * 1000 + + suspend fun getExtensionList(): List { + // update if {ExtensionUpdateDelayTime} seconds has passed or requested offline and database is empty + if (lastUpdateCheck + ExtensionUpdateDelayTime < System.currentTimeMillis()) { + logger.debug("Getting extensions list from the internet") + lastUpdateCheck = System.currentTimeMillis() + + val foundExtensions = ExtensionGithubApi.findExtensions() + updateExtensionDatabase(foundExtensions) + } else { + logger.debug("used cached extension list") + } + + return extensionTableAsDataClass() + } + + fun extensionTableAsDataClass() = transaction { + AnimeExtensionTable.selectAll().map { + AnimeExtensionDataClass( + it[AnimeExtensionTable.apkName], + getExtensionIconUrl(it[AnimeExtensionTable.apkName]), + it[AnimeExtensionTable.name], + it[AnimeExtensionTable.pkgName], + it[AnimeExtensionTable.versionName], + it[AnimeExtensionTable.versionCode], + it[AnimeExtensionTable.lang], + it[AnimeExtensionTable.isNsfw], + it[AnimeExtensionTable.isInstalled], + it[AnimeExtensionTable.hasUpdate], + it[AnimeExtensionTable.isObsolete], + ) + } + } + + private fun updateExtensionDatabase(foundExtensions: List) { + transaction { + foundExtensions.forEach { foundExtension -> + val extensionRecord = AnimeExtensionTable.select { AnimeExtensionTable.pkgName eq foundExtension.pkgName }.firstOrNull() + if (extensionRecord != null) { + if (extensionRecord[AnimeExtensionTable.isInstalled]) { + when { + foundExtension.versionCode > extensionRecord[AnimeExtensionTable.versionCode] -> { + // there is an update + AnimeExtensionTable.update({ AnimeExtensionTable.pkgName eq foundExtension.pkgName }) { + it[hasUpdate] = true + } + updateMap.putIfAbsent(foundExtension.pkgName, foundExtension) + } + foundExtension.versionCode < extensionRecord[AnimeExtensionTable.versionCode] -> { + // some how the user installed an invalid version + AnimeExtensionTable.update({ AnimeExtensionTable.pkgName eq foundExtension.pkgName }) { + it[isObsolete] = true + } + } + } + } else { + // extension is not installed so we can overwrite the data without a care + AnimeExtensionTable.update({ AnimeExtensionTable.pkgName eq foundExtension.pkgName }) { + it[name] = foundExtension.name + it[versionName] = foundExtension.versionName + it[versionCode] = foundExtension.versionCode + it[lang] = foundExtension.lang + it[isNsfw] = foundExtension.isNsfw + it[apkName] = foundExtension.apkName + it[iconUrl] = foundExtension.iconUrl + } + } + } else { + // insert new record + AnimeExtensionTable.insert { + it[name] = foundExtension.name + it[pkgName] = foundExtension.pkgName + it[versionName] = foundExtension.versionName + it[versionCode] = foundExtension.versionCode + it[lang] = foundExtension.lang + it[isNsfw] = foundExtension.isNsfw + it[apkName] = foundExtension.apkName + it[iconUrl] = foundExtension.iconUrl + } + } + } + + // deal with obsolete extensions + AnimeExtensionTable.selectAll().forEach { extensionRecord -> + val foundExtension = foundExtensions.find { it.pkgName == extensionRecord[AnimeExtensionTable.pkgName] } + if (foundExtension == null) { + // not in the repo, so this extensions is obsolete + if (extensionRecord[AnimeExtensionTable.isInstalled]) { + // is installed so we should mark it as obsolete + AnimeExtensionTable.update({ AnimeExtensionTable.pkgName eq extensionRecord[AnimeExtensionTable.pkgName] }) { + it[isObsolete] = true + } + } else { + // is not installed so we can remove the record without a care + AnimeExtensionTable.deleteWhere { AnimeExtensionTable.pkgName eq extensionRecord[AnimeExtensionTable.pkgName] } + } + } + } + } + } +} diff --git a/server/src/main/kotlin/suwayomi/tachidesk/impl/extension/github/AnimeExtensionGithubApi.kt b/server/src/main/kotlin/suwayomi/anime/impl/extension/github/ExtensionGithubApi.kt similarity index 93% rename from server/src/main/kotlin/suwayomi/tachidesk/impl/extension/github/AnimeExtensionGithubApi.kt rename to server/src/main/kotlin/suwayomi/anime/impl/extension/github/ExtensionGithubApi.kt index 50920d82..aeed1d3f 100644 --- a/server/src/main/kotlin/suwayomi/tachidesk/impl/extension/github/AnimeExtensionGithubApi.kt +++ b/server/src/main/kotlin/suwayomi/anime/impl/extension/github/ExtensionGithubApi.kt @@ -1,4 +1,4 @@ -package suwayomi.tachidesk.impl.extension.github +package suwayomi.anime.impl.extension.github /* * Copyright (C) Contributors to the Suwayomi project @@ -13,11 +13,11 @@ import com.google.gson.JsonArray import com.google.gson.JsonParser import eu.kanade.tachiyomi.network.NetworkHelper import okhttp3.Request +import suwayomi.anime.model.dataclass.AnimeExtensionDataClass import suwayomi.tachidesk.impl.util.network.UnzippingInterceptor -import suwayomi.tachidesk.model.dataclass.ExtensionDataClass import uy.kohesive.injekt.injectLazy -object AnimeExtensionGithubApi { +object ExtensionGithubApi { const val BASE_URL = "https://raw.githubusercontent.com" const val REPO_URL_PREFIX = "$BASE_URL/jmir1/tachiyomi-extensions/repo" @@ -51,7 +51,7 @@ object AnimeExtensionGithubApi { return parseResponse(response) } - fun getApkUrl(extension: ExtensionDataClass): String { + fun getApkUrl(extension: AnimeExtensionDataClass): String { return "$REPO_URL_PREFIX/apk/${extension.apkName}" } diff --git a/server/src/main/kotlin/suwayomi/anime/impl/extension/github/OnlineExtension.kt b/server/src/main/kotlin/suwayomi/anime/impl/extension/github/OnlineExtension.kt new file mode 100644 index 00000000..5dc7aa4e --- /dev/null +++ b/server/src/main/kotlin/suwayomi/anime/impl/extension/github/OnlineExtension.kt @@ -0,0 +1,12 @@ +package suwayomi.anime.impl.extension.github + +data class OnlineExtension( + val name: String, + val pkgName: String, + val versionName: String, + val versionCode: Int, + val lang: String, + val isNsfw: Boolean, + val apkName: String, + val iconUrl: String +) diff --git a/server/src/main/kotlin/suwayomi/anime/impl/util/PackageTools.kt b/server/src/main/kotlin/suwayomi/anime/impl/util/PackageTools.kt new file mode 100644 index 00000000..33c5c3a3 --- /dev/null +++ b/server/src/main/kotlin/suwayomi/anime/impl/util/PackageTools.kt @@ -0,0 +1,146 @@ +package suwayomi.anime.impl.util + +/* + * Copyright (C) Contributors to the Suwayomi project + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. */ + +import android.content.pm.PackageInfo +import android.content.pm.Signature +import android.os.Bundle +import com.googlecode.d2j.dex.Dex2jar +import com.googlecode.d2j.reader.MultiDexFileReader +import com.googlecode.dex2jar.tools.BaksmaliBaseDexExceptionHandler +import eu.kanade.tachiyomi.util.lang.Hash +import mu.KotlinLogging +import net.dongliu.apk.parser.ApkFile +import net.dongliu.apk.parser.ApkParsers +import org.kodein.di.DI +import org.kodein.di.conf.global +import org.kodein.di.instance +import org.w3c.dom.Element +import org.w3c.dom.Node +import suwayomi.server.ApplicationDirs +import xyz.nulldev.androidcompat.pm.InstalledPackage.Companion.toList +import xyz.nulldev.androidcompat.pm.toPackageInfo +import java.io.File +import java.net.URL +import java.net.URLClassLoader +import java.nio.file.Files +import java.nio.file.Path +import javax.xml.parsers.DocumentBuilderFactory + + +object PackageTools { + private val logger = KotlinLogging.logger {} + private val applicationDirs by DI.global.instance() + + const val EXTENSION_FEATURE = "tachiyomi.extension" + const val METADATA_SOURCE_CLASS = "tachiyomi.extension.class" + const val METADATA_SOURCE_FACTORY = "tachiyomi.extension.factory" + const val METADATA_NSFW = "tachiyomi.extension.nsfw" + const val LIB_VERSION_MIN = 1.3 + const val LIB_VERSION_MAX = 1.3 + + private const val officialSignature = "50ab1d1e3a20d204d0ad6d334c7691c632e41b98dfa132bf385695fdfa63839c" // jmir1's key + var trustedSignatures = mutableSetOf() + officialSignature + + /** + * Convert dex to jar, a wrapper for the dex2jar library + */ + fun dex2jar(dexFile: String, jarFile: String, fileNameWithoutType: String) { + // adopted from com.googlecode.dex2jar.tools.Dex2jarCmd.doCommandLine + // source at: https://github.com/DexPatcher/dex2jar/tree/v2.1-20190905-lanchon/dex-tools/src/main/java/com/googlecode/dex2jar/tools/Dex2jarCmd.java + + val jarFilePath = File(jarFile).toPath() + val reader = MultiDexFileReader.open(Files.readAllBytes(File(dexFile).toPath())) + val handler = BaksmaliBaseDexExceptionHandler() + Dex2jar + .from(reader) + .withExceptionHandler(handler) + .reUseReg(false) + .topoLogicalSort() + .skipDebug(true) + .optimizeSynchronized(false) + .printIR(false) + .noCode(false) + .skipExceptions(false) + .to(jarFilePath) + if (handler.hasException()) { + val errorFile: Path = File(applicationDirs.extensionsRoot).toPath().resolve("$fileNameWithoutType-error.txt") + logger.error( + """ + Detail Error Information in File $errorFile + Please report this file to one of following link if possible (any one). + https://sourceforge.net/p/dex2jar/tickets/ + https://bitbucket.org/pxb1988/dex2jar/issues + https://github.com/pxb1988/dex2jar/issues + dex2jar@googlegroups.com + """.trimIndent() + ) + handler.dump(errorFile, emptyArray()) + } + } + + /** A modified version of `xyz.nulldev.androidcompat.pm.InstalledPackage.info` */ + fun getPackageInfo(apkFilePath: String): PackageInfo { + val apk = File(apkFilePath) + return ApkParsers.getMetaInfo(apk).toPackageInfo(apk).apply { + val parsed = ApkFile(apk) + val dbFactory = DocumentBuilderFactory.newInstance() + val dBuilder = dbFactory.newDocumentBuilder() + val doc = parsed.manifestXml.byteInputStream().use { + dBuilder.parse(it) + } + + logger.debug(parsed.manifestXml) + + applicationInfo.metaData = Bundle().apply { + val appTag = doc.getElementsByTagName("application").item(0) + + appTag?.childNodes?.toList() + .orEmpty() + .asSequence() + .filter { + it.nodeType == Node.ELEMENT_NODE + }.map { + it as Element + }.filter { + it.tagName == "meta-data" + }.forEach { + putString( + it.attributes.getNamedItem("android:name").nodeValue, + it.attributes.getNamedItem("android:value").nodeValue + ) + } + } + + signatures = ( + parsed.apkSingers.flatMap { it.certificateMetas } + /*+ parsed.apkV2Singers.flatMap { it.certificateMetas }*/ + ) // Blocked by: https://github.com/hsiafan/apk-parser/issues/72 + .map { Signature(it.data) }.toTypedArray() + } + } + + fun getSignatureHash(pkgInfo: PackageInfo): String? { + val signatures = pkgInfo.signatures + return if (signatures != null && signatures.isNotEmpty()) { + Hash.sha256(signatures.first().toByteArray()) + } else { + null + } + } + + /** + * loads the extension main class called $className from the jar located at $jarPath + * It may return an instance of HttpSource or SourceFactory depending on the extension. + */ + fun loadExtensionSources(jarPath: String, className: String): Any { + val classLoader = URLClassLoader(arrayOf(URL("file:$jarPath"))) + val classToLoad = Class.forName(className, false, classLoader) + return classToLoad.getDeclaredConstructor().newInstance() + } +} diff --git a/server/src/main/kotlin/suwayomi/anime/model/dataclass/AnimeExtensionDataClass.kt b/server/src/main/kotlin/suwayomi/anime/model/dataclass/AnimeExtensionDataClass.kt new file mode 100644 index 00000000..ffbd8bb8 --- /dev/null +++ b/server/src/main/kotlin/suwayomi/anime/model/dataclass/AnimeExtensionDataClass.kt @@ -0,0 +1,24 @@ +package suwayomi.anime.model.dataclass + +/* + * Copyright (C) Contributors to the Suwayomi project + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. */ + +data class AnimeExtensionDataClass( + val apkName: String, + val iconUrl: String, + + val name: String, + val pkgName: String, + val versionName: String, + val versionCode: Int, + val lang: String, + val isNsfw: Boolean, + + val installed: Boolean, + val hasUpdate: Boolean, + val obsolete: Boolean, +) diff --git a/server/src/main/kotlin/suwayomi/anime/model/table/AnimeExtensionTable.kt b/server/src/main/kotlin/suwayomi/anime/model/table/AnimeExtensionTable.kt new file mode 100644 index 00000000..36983e68 --- /dev/null +++ b/server/src/main/kotlin/suwayomi/anime/model/table/AnimeExtensionTable.kt @@ -0,0 +1,31 @@ +package suwayomi.anime.model.table + +/* + * Copyright (C) Contributors to the Suwayomi project + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. */ + +import org.jetbrains.exposed.dao.id.IntIdTable + +object AnimeExtensionTable : IntIdTable() { + val apkName = varchar("apk_name", 1024) + + // default is the local source icon from tachiyomi + val iconUrl = varchar("icon_url", 2048) + .default("https://raw.githubusercontent.com/tachiyomiorg/tachiyomi/64ba127e7d43b1d7e6d58a6f5c9b2bd5fe0543f7/app/src/main/res/mipmap-xxxhdpi/ic_local_source.webp") + + val name = varchar("name", 128) + val pkgName = varchar("pkg_name", 128) + val versionName = varchar("version_name", 16) + val versionCode = integer("version_code") + val lang = varchar("lang", 10) + val isNsfw = bool("is_nsfw") + + val isInstalled = bool("is_installed").default(false) + val hasUpdate = bool("has_update").default(false) + val isObsolete = bool("is_obsolete").default(false) + + val classFQName = varchar("class_name", 1024).default("") // fully qualified name +} diff --git a/server/src/main/kotlin/suwayomi/tachidesk/impl/util/GetHttpSource.kt b/server/src/main/kotlin/suwayomi/tachidesk/impl/util/GetHttpSource.kt index a32cc0b1..b1bf0962 100644 --- a/server/src/main/kotlin/suwayomi/tachidesk/impl/util/GetHttpSource.kt +++ b/server/src/main/kotlin/suwayomi/tachidesk/impl/util/GetHttpSource.kt @@ -15,8 +15,8 @@ import org.jetbrains.exposed.sql.transactions.transaction import org.kodein.di.DI import org.kodein.di.conf.global import org.kodein.di.instance -import suwayomi.server.ApplicationDirs import suwayomi.tachidesk.impl.util.PackageTools.loadExtensionSources +import suwayomi.server.ApplicationDirs import suwayomi.tachidesk.model.table.ExtensionTable import suwayomi.tachidesk.model.table.SourceTable import java.util.concurrent.ConcurrentHashMap diff --git a/server/src/main/kotlin/suwayomi/tachidesk/impl/util/PackageTools.kt b/server/src/main/kotlin/suwayomi/tachidesk/impl/util/PackageTools.kt index 5e1648aa..e4e6648a 100644 --- a/server/src/main/kotlin/suwayomi/tachidesk/impl/util/PackageTools.kt +++ b/server/src/main/kotlin/suwayomi/tachidesk/impl/util/PackageTools.kt @@ -1,5 +1,12 @@ package suwayomi.tachidesk.impl.util +/* + * Copyright (C) Contributors to the Suwayomi project + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. */ + import android.content.pm.PackageInfo import android.content.pm.Signature import android.os.Bundle @@ -25,12 +32,6 @@ import java.nio.file.Files import java.nio.file.Path import javax.xml.parsers.DocumentBuilderFactory -/* - * Copyright (C) Contributors to the Suwayomi project - * - * This Source Code Form is subject to the terms of the Mozilla Public - * License, v. 2.0. If a copy of the MPL was not distributed with this - * file, You can obtain one at https://mozilla.org/MPL/2.0/. */ object PackageTools { private val logger = KotlinLogging.logger {} @@ -41,12 +42,11 @@ object PackageTools { const val METADATA_SOURCE_FACTORY = "tachiyomi.extension.factory" const val METADATA_NSFW = "tachiyomi.extension.nsfw" const val LIB_VERSION_MIN = 1.2 - const val LIB_VERSION_MAX = 1.3 + const val LIB_VERSION_MAX = 1.2 private const val officialSignature = "7ce04da7773d41b489f4693a366c36bcd0a11fc39b547168553c285bd7348e23" // inorichi's key - private const val animeSignature = "50ab1d1e3a20d204d0ad6d334c7691c632e41b98dfa132bf385695fdfa63839c" // jmir1's key private const val unofficialSignature = "64feb21075ba97ebc9cc981243645b331595c111cef1b0d084236a0403b00581" // ArMor's key - var trustedSignatures = mutableSetOf() + officialSignature + animeSignature + unofficialSignature + var trustedSignatures = mutableSetOf() + officialSignature + unofficialSignature /** * Convert dex to jar, a wrapper for the dex2jar library