This commit is contained in:
achmad
2026-05-10 02:42:37 +07:00
26 changed files with 643 additions and 1983 deletions
@@ -71,7 +71,7 @@ fun createAppModule(app: Application): Module {
}
}
single {
single<ProtoBuf> {
ProtoBuf
}
}
@@ -140,17 +140,16 @@ fun OkHttpClient.newCachelessCallWithProgress(
return progressClient.newCall(request)
}
context(Json)
context(_: Json)
inline fun <reified T> Response.parseAs(): T = decodeFromJsonResponse(serializer(), this)
@OptIn(ExperimentalSerializationApi::class)
context(Json)
context(json: Json)
fun <T> decodeFromJsonResponse(
deserializer: DeserializationStrategy<T>,
response: Response,
): T =
response.body.source().use {
decodeFromBufferedSource(deserializer, it)
json.decodeFromBufferedSource(deserializer, it)
}
class HttpException(
@@ -6,7 +6,7 @@ import io.javalin.websocket.WsMessageContext
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json
import org.eclipse.jetty.websocket.api.CloseStatus
import org.eclipse.jetty.websocket.core.CloseStatus
import suwayomi.tachidesk.manga.impl.update.Websocket
object WebView : Websocket<String>() {
@@ -13,10 +13,14 @@ import com.expediagroup.graphql.server.types.GraphQLRequest
import com.expediagroup.graphql.server.types.GraphQLServerRequest
import io.javalin.http.Context
import io.javalin.http.UploadedFile
import io.javalin.json.JavalinJackson
import io.javalin.json.fromJsonStream
import io.javalin.json.fromJsonString
import java.io.IOException
class JavalinGraphQLRequestParser : GraphQLRequestParser<Context> {
val jsonMapper = JavalinJackson()
@Suppress("PARAMETER_NAME_CHANGED_ON_OVERRIDE")
override suspend fun parseRequest(context: Context): GraphQLServerRequest? {
return try {
@@ -29,17 +33,17 @@ class JavalinGraphQLRequestParser : GraphQLRequestParser<Context> {
context.formParam("operations")
?: throw IllegalArgumentException("Cannot find 'operations' body")
} else {
return context.bodyAsClass(GraphQLServerRequest::class.java)
return context.bodyInputStream().use { jsonMapper.fromJsonStream<GraphQLServerRequest>(it) }
}
val request =
context.jsonMapper().fromJsonString<GraphQLServerRequest>(formParam)
jsonMapper.fromJsonString<GraphQLServerRequest>(formParam)
val map =
context
.formParam("map")
?.let {
context.jsonMapper().fromJsonString<Map<String, List<String>>>(it)
jsonMapper.fromJsonString<Map<String, List<String>>>(it)
}.orEmpty()
val mapItems =
@@ -26,7 +26,7 @@ import kotlinx.coroutines.flow.onCompletion
import kotlinx.coroutines.flow.onStart
import kotlinx.coroutines.job
import kotlinx.coroutines.runBlocking
import org.eclipse.jetty.websocket.api.CloseStatus
import org.eclipse.jetty.websocket.core.CloseStatus
import suwayomi.tachidesk.graphql.server.TachideskGraphQLContextFactory
import suwayomi.tachidesk.graphql.server.subscriptions.SubscriptionOperationMessage.ClientMessages.GQL_CONNECTION_INIT
import suwayomi.tachidesk.graphql.server.subscriptions.SubscriptionOperationMessage.ClientMessages.GQL_SUBSCRIBE
@@ -13,7 +13,7 @@ import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.emptyFlow
import kotlinx.coroutines.flow.onCompletion
import org.eclipse.jetty.websocket.api.CloseStatus
import org.eclipse.jetty.websocket.core.CloseStatus
import suwayomi.tachidesk.graphql.server.toGraphQLContext
import java.util.concurrent.ConcurrentHashMap
import java.util.concurrent.CopyOnWriteArrayList
@@ -14,6 +14,8 @@ import eu.kanade.tachiyomi.source.CatalogueSource
import eu.kanade.tachiyomi.source.Source
import eu.kanade.tachiyomi.source.SourceFactory
import io.github.oshai.kotlinlogging.KotlinLogging
import net.dongliu.apk.parser.ApkFile
import net.dongliu.apk.parser.bean.Icon
import okhttp3.CacheControl
import okio.buffer
import okio.sink
@@ -37,7 +39,9 @@ import suwayomi.tachidesk.manga.impl.util.PackageTools.getPackageInfo
import suwayomi.tachidesk.manga.impl.util.PackageTools.loadExtensionSources
import suwayomi.tachidesk.manga.impl.util.network.await
import suwayomi.tachidesk.manga.impl.util.source.GetCatalogueSource
import suwayomi.tachidesk.manga.impl.util.storage.ImageResponse.clearCachedImage
import suwayomi.tachidesk.manga.impl.util.storage.ImageResponse.getImageResponse
import suwayomi.tachidesk.manga.impl.util.storage.ImageResponse.saveImage
import suwayomi.tachidesk.manga.model.table.ExtensionTable
import suwayomi.tachidesk.manga.model.table.SourceTable
import suwayomi.tachidesk.server.ApplicationDirs
@@ -115,7 +119,6 @@ object Extension {
val dirPathWithoutType = "${applicationDirs.extensionsRoot}/$fileNameWithoutType"
val jarFilePath = "$dirPathWithoutType.jar"
val dexFilePath = "$dirPathWithoutType.dex"
val packageInfo = getPackageInfo(apkFilePath)
val pkgName = packageInfo.packageName
@@ -155,79 +158,115 @@ object Extension {
dex2jar(apkFilePath, jarFilePath, fileNameWithoutType)
extractAssetsFromApk(apkFilePath, jarFilePath)
extractAndCacheApkIcon(apkFilePath, apkName)
// clean up
File(apkFilePath).delete()
File(dexFilePath).delete()
// collect sources from the extension
val extensionMainClassInstance = loadExtensionSources(jarFilePath, className)
val sources: List<CatalogueSource> =
when (extensionMainClassInstance) {
is Source -> listOf(extensionMainClassInstance)
is SourceFactory -> extensionMainClassInstance.createSources()
else -> throw RuntimeException("Unknown source class type! ${extensionMainClassInstance.javaClass}")
}.map { it as CatalogueSource }
try {
// collect sources from the extension
val extensionMainClassInstance = loadExtensionSources(jarFilePath, className)
val sources: List<CatalogueSource> =
when (extensionMainClassInstance) {
is Source -> listOf(extensionMainClassInstance)
is SourceFactory -> extensionMainClassInstance.createSources()
else -> throw RuntimeException("Unknown source class type! ${extensionMainClassInstance.javaClass}")
}.map { it as CatalogueSource }
val langs = sources.map { it.lang }.toSet()
val extensionLang =
when (langs.size) {
0 -> ""
1 -> langs.first()
else -> "all"
}
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: ")
val extensionName =
packageInfo.applicationInfo.nonLocalizedLabel
.toString()
.substringAfter("Tachiyomi: ")
// update extension info
transaction {
if (ExtensionTable.selectAll().where { ExtensionTable.pkgName eq pkgName }.firstOrNull() == null) {
ExtensionTable.insert {
// update extension info
transaction {
if (ExtensionTable.selectAll().where { ExtensionTable.pkgName eq pkgName }.firstOrNull() == null) {
ExtensionTable.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
}
}
ExtensionTable.update({ ExtensionTable.pkgName eq pkgName }) {
it[this.apkName] = apkName
it[name] = extensionName
it[this.pkgName] = packageInfo.packageName
it[this.isInstalled] = true
it[this.classFQName] = className
it[versionName] = packageInfo.versionName
it[versionCode] = packageInfo.versionCode
it[lang] = extensionLang
it[this.isNsfw] = isNsfw
}
val extensionId =
ExtensionTable
.selectAll()
.where { ExtensionTable.pkgName eq pkgName }
.first()[ExtensionTable.id]
.value
sources.forEach { httpSource ->
SourceTable.insert {
it[id] = httpSource.id
it[name] = httpSource.name
it[lang] = httpSource.lang
it[extension] = extensionId
it[SourceTable.isNsfw] = isNsfw
}
logger.debug { "Installed source ${httpSource.name} (${httpSource.lang}) with id:${httpSource.id}" }
}
}
return 201 // we installed successfully
} catch (e: Throwable) {
// free up the file descriptor if exists
PackageTools.jarLoaderMap.remove(jarFilePath)?.close()
File(jarFilePath).delete()
ExtensionTable.update({ ExtensionTable.pkgName eq pkgName }) {
it[this.apkName] = apkName
it[this.isInstalled] = true
it[this.classFQName] = className
it[versionName] = packageInfo.versionName
it[versionCode] = packageInfo.versionCode
}
val extensionId =
ExtensionTable
.selectAll()
.where { ExtensionTable.pkgName eq pkgName }
.first()[ExtensionTable.id]
.value
sources.forEach { httpSource ->
SourceTable.insert {
it[id] = httpSource.id
it[name] = httpSource.name
it[lang] = httpSource.lang
it[extension] = extensionId
it[SourceTable.isNsfw] = isNsfw
}
logger.debug { "Installed source ${httpSource.name} (${httpSource.lang}) with id:${httpSource.id}" }
}
uninstallExtension(pkgName)
throw e
}
return 201 // we installed successfully
} else {
return 302 // extension was already installed
}
}
private fun extractAndCacheApkIcon(
apkFilePath: String,
apkName: String,
) {
val iconCacheDir = "${applicationDirs.extensionsRoot}/icon"
try {
val iconData =
ApkFile(File(apkFilePath)).use { apk ->
apk.allIcons
.filterIsInstance<Icon>()
.mapNotNull { it.data?.let { data -> data to it.density } }
.maxByOrNull { (_, density) -> density }
?.first
}
if (iconData == null) {
logger.warn { "No icon found in APK $apkName" }
return
}
File(iconCacheDir).mkdirs()
clearCachedImage(iconCacheDir, apkName)
saveImage("$iconCacheDir/$apkName", iconData.inputStream(), null)
} catch (e: Exception) {
logger.warn(e) { "Failed to extract icon from APK $apkName" }
}
}
private fun extractAssetsFromApk(
apkPath: String,
jarPath: String,
@@ -71,6 +71,9 @@ object ImageUtil {
if (bytes.compareWith(charByteArrayOf(0xFF, 0x0A))) {
return JXL
}
if (bytes.compareWith(charByteArrayOf(0x00, 0x00, 0x00, 0x0C, 0x4A, 0x58, 0x4C, 0x20, 0x0D, 0x0A, 0x87, 0x0A))) {
return JXL
}
} catch (_: Exception) {
}
return null
@@ -13,12 +13,14 @@ import io.github.oshai.kotlinlogging.KotlinLogging
import io.javalin.Javalin
import io.javalin.apibuilder.ApiBuilder.after
import io.javalin.apibuilder.ApiBuilder.path
import io.javalin.config.RoutesConfig
import io.javalin.http.Context
import io.javalin.http.HandlerType
import io.javalin.http.HttpStatus
import io.javalin.http.NotFoundResponse
import io.javalin.http.RedirectResponse
import io.javalin.http.UnauthorizedResponse
import io.javalin.json.JavalinJackson3
import io.javalin.rendering.template.JavalinJte
import io.javalin.websocket.WsContext
import kotlinx.coroutines.CoroutineScope
@@ -47,6 +49,7 @@ import java.net.URLEncoder
import java.util.Locale
import java.util.concurrent.CompletableFuture
import kotlin.concurrent.thread
import kotlin.text.get
import kotlin.time.Duration.Companion.days
object JavalinSetup {
@@ -58,10 +61,12 @@ object JavalinSetup {
fun javalinSetup() {
val app =
Javalin.create { config ->
Javalin.start { config ->
val templateEngine = TemplateEngine.createPrecompiled(ContentType.Html)
config.fileRenderer(JavalinJte(templateEngine))
config.jsonMapper(JavalinJackson3())
WebInterfaceManager.setup(config)
// config.registerPlugin(OpenApiPlugin(getOpenApiOptions()))
@@ -104,7 +109,8 @@ object JavalinSetup {
}
}
config.router.apiBuilder {
config.routes.defineCore()
config.routes.apiBuilder {
path(ServerSubpath.maybeAddAsPrefix("api/")) {
path("v1/") {
GlobalAPI.defineEndpoints()
@@ -117,17 +123,32 @@ object JavalinSetup {
after { ctx ->
// If not matched, the request was for an invalid endpoint
// Return a 404 instead of redirecting to the UI for usability
if (ctx.endpointHandlerPath() == "*") {
if (ctx.endpoints().lastHttpEndpoint()?.path == "*") {
throw NotFoundResponse()
}
}
}
}
config.events.serverStarted {
if (serverConfig.initialOpenInBrowserEnabled.value) {
Browser.openInBrowser()
}
}
}
// when JVM is prompted to shutdown, stop javalin gracefully
Runtime.getRuntime().addShutdownHook(
thread(start = false) {
app.stop()
},
)
}
fun RoutesConfig.defineCore() {
val loginPath = ServerSubpath.maybeAddAsPrefix("/login.html")
app.get(loginPath) { ctx ->
get(loginPath) { ctx ->
val locale: Locale = LocalizationHelper.ctxToLocale(ctx)
ctx.header("content-type", "text/html")
val httpCacheSeconds = 1.days.inWholeSeconds
@@ -141,7 +162,7 @@ object JavalinSetup {
)
}
app.post(loginPath) { ctx ->
post(loginPath) { ctx ->
val username = ctx.formParam("user")
val password = ctx.formParam("pass")
val isValid =
@@ -174,7 +195,7 @@ object JavalinSetup {
)
}
app.beforeMatched { ctx ->
beforeMatched { ctx ->
val isWebManifest =
listOf("site.webmanifest", "manifest.json", "login.html").any { ctx.path().endsWith(it) }
val isPageIcon =
@@ -219,60 +240,43 @@ object JavalinSetup {
ctx.setAttribute(Attribute.TachideskBasic, credentialsValid())
}
app.events { event ->
event.serverStarted {
if (serverConfig.initialOpenInBrowserEnabled.value) {
Browser.openInBrowser()
}
}
}
app.wsBefore {
wsBefore {
it.onConnect { ctx ->
ctx.setAttribute(Attribute.TachideskUser, getUserFromWsContext(ctx))
}
}
// when JVM is prompted to shutdown, stop javalin gracefully
Runtime.getRuntime().addShutdownHook(
thread(start = false) {
app.stop()
},
)
app.exception(NullPointerException::class.java) { e, ctx ->
exception(NullPointerException::class.java) { e, ctx ->
logger.error(e) { "NullPointerException while handling the request" }
ctx.status(404)
}
app.exception(NoSuchElementException::class.java) { e, ctx ->
exception(NoSuchElementException::class.java) { e, ctx ->
logger.error(e) { "NoSuchElementException while handling the request" }
ctx.status(404)
}
app.exception(IOException::class.java) { e, ctx ->
exception(IOException::class.java) { e, ctx ->
logger.error(e) { "IOException while handling the request" }
ctx.status(500)
ctx.result(e.message ?: "Internal Server Error")
}
app.exception(IllegalArgumentException::class.java) { e, ctx ->
exception(IllegalArgumentException::class.java) { e, ctx ->
logger.error(e) { "IllegalArgumentException while handling the request" }
ctx.status(400)
ctx.result(e.message ?: "Bad Request")
}
app.exception(UnauthorizedException::class.java) { e, ctx ->
exception(UnauthorizedException::class.java) { e, ctx ->
logger.error(e) { "UnauthorizedException while handling the request" }
ctx.status(HttpStatus.UNAUTHORIZED)
ctx.result(e.message ?: "Unauthorized")
}
app.exception(ForbiddenException::class.java) { e, ctx ->
exception(ForbiddenException::class.java) { e, ctx ->
logger.error(e) { "ForbiddenException while handling the request" }
ctx.status(HttpStatus.FORBIDDEN)
ctx.result(e.message ?: "Forbidden")
}
app.start()
}
// private fun getOpenApiOptions(): OpenApiOptions {
@@ -71,15 +71,14 @@ fun <T> getParam(
is Param.FormParam -> ctx.formParamAsClass(param.key, clazz)
is Param.PathParam -> ctx.pathParamAsClass(param.key, clazz)
is Param.QueryParam -> ctx.queryParamAsClass(param.key, clazz)
else -> throw IllegalStateException("Invalid param")
}.let {
if (param.nullable) {
it.allowNullable().get() ?: param.defaultValue
it.getOrNull() ?: param.defaultValue
} else {
if (param.defaultValue != null) {
it.getOrDefault(param.defaultValue!!)
} else {
it.get()
it.required().get()
}
}
}
@@ -17,6 +17,7 @@ import io.github.oshai.kotlinlogging.KLogger
import io.github.oshai.kotlinlogging.KotlinLogging
import io.github.reactivecircus.cache4k.Cache
import io.javalin.config.JavalinConfig
import io.javalin.http.staticfiles.AliasCheck
import io.javalin.http.staticfiles.Location
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.DelicateCoroutinesApi
@@ -39,7 +40,6 @@ import kotlinx.serialization.json.jsonArray
import kotlinx.serialization.json.jsonObject
import kotlinx.serialization.json.jsonPrimitive
import net.lingala.zip4j.ZipFile
import org.eclipse.jetty.server.handler.ContextHandler
import suwayomi.tachidesk.graphql.types.AboutWebUI
import suwayomi.tachidesk.graphql.types.UpdateState
import suwayomi.tachidesk.graphql.types.UpdateState.DOWNLOADING
@@ -180,7 +180,7 @@ object WebInterfaceManager {
// Use canonical path to avoid Jetty alias issues
staticFiles.directory = File(applicationDirs.webUIServe).canonicalPath
staticFiles.location = Location.EXTERNAL
staticFiles.aliasCheck = ContextHandler.ApproveAliases()
staticFiles.aliasCheck = AliasCheck { _, _ -> true }
}
serveWebUI = {
@@ -206,20 +206,12 @@ object WebInterfaceManager {
if (ServerSubpath.isDefined() && orgIndexHtml.exists()) {
val originalIndexHtml = orgIndexHtml.readText()
val subpathInjectionScript =
"""
<script>
// <<suwayomi-subpath-injection>>
const baseTag = document.createElement('base');
baseTag.href = location.origin + "${ServerSubpath.asRootPath()}";
document.head.appendChild(baseTag);
</script>
""".trimIndent()
val subpathInjectionBaseTag = "<base href=\"${ServerSubpath.asRootPath()}\">"
val indexHtmlWithSubpathInjection =
originalIndexHtml.replace(
"<head>",
"<head>$subpathInjectionScript",
"<head>$subpathInjectionBaseTag",
)
orgIndexHtml.writeText(indexHtmlWithSubpathInjection)
@@ -312,11 +304,25 @@ object WebInterfaceManager {
return
}
val flavor = WebUIFlavor.current
val servedFlavor = getServedWebUIFlavor()
val log =
KotlinLogging.logger("${logger.name} setupWebUI(flavor= ${flavor.uiName}, servedFlavor= ${servedFlavor.uiName})")
KotlinLogging.logger(
"${logger.name} setupWebUI(flavor= ${WebUIFlavor.current.uiName}, servedFlavor= ${servedFlavor.uiName}, channel= ${serverConfig.webUIChannel})",
)
val flavor =
if (serverConfig.webUIChannel.value == WebUIChannel.BUNDLED) {
if (serverConfig.webUIFlavor.value != WebUIFlavor.default) {
log.warn {
"Changed flavor to ${WebUIFlavor.default.uiName}. Channel \"${WebUIChannel.BUNDLED}\" only works with the default flavor"
}
}
WebUIFlavor.default
} else {
WebUIFlavor.current
}
if (doesLocalWebUIExist(applicationDirs.webUIRoot)) {
val currentVersion = getLocalVersion()