From d95f4fe1e1cca6fe046bb45f94c2bcb9450eb0b4 Mon Sep 17 00:00:00 2001
From: schroda <50052685+schroda@users.noreply.github.com>
Date: Thu, 25 Sep 2025 00:01:13 +0200
Subject: [PATCH] Fix/webui subpath injection (#1666)
* Cleanup subpath handling
* Move webUI serve setup logic to WebInterfaceManager
* Fix webUI subpath injection
Dynamic subpath support on the client requires using relative paths for everything.
Without a tag this only works when opening the client on the root path.
Any subpath will result in a blank page because the used url to request e.g., an asset will be invalid and cause an error (type mismatch, since the index.html will be returned for any unmatch route).
---
.../suwayomi/tachidesk/server/JavalinSetup.kt | 97 ++-----------------
.../suwayomi/tachidesk/server/util/Browser.kt | 4 +-
.../tachidesk/server/util/ServerSubpath.kt | 35 +++++++
.../server/util/WebInterfaceManager.kt | 71 ++++++++++++--
4 files changed, 109 insertions(+), 98 deletions(-)
create mode 100644 server/src/main/kotlin/suwayomi/tachidesk/server/util/ServerSubpath.kt
diff --git a/server/src/main/kotlin/suwayomi/tachidesk/server/JavalinSetup.kt b/server/src/main/kotlin/suwayomi/tachidesk/server/JavalinSetup.kt
index 89e8cb7c..73f2cf95 100644
--- a/server/src/main/kotlin/suwayomi/tachidesk/server/JavalinSetup.kt
+++ b/server/src/main/kotlin/suwayomi/tachidesk/server/JavalinSetup.kt
@@ -19,7 +19,6 @@ import io.javalin.http.HttpStatus
import io.javalin.http.NotFoundResponse
import io.javalin.http.RedirectResponse
import io.javalin.http.UnauthorizedResponse
-import io.javalin.http.staticfiles.Location
import io.javalin.rendering.template.JavalinJte
import io.javalin.websocket.WsContext
import kotlinx.coroutines.CoroutineScope
@@ -27,7 +26,6 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.future.future
-import kotlinx.coroutines.runBlocking
import org.eclipse.jetty.server.ServerConnector
import suwayomi.tachidesk.global.GlobalAPI
import suwayomi.tachidesk.graphql.GraphQL
@@ -41,9 +39,8 @@ import suwayomi.tachidesk.server.user.UserType
import suwayomi.tachidesk.server.user.getUserFromContext
import suwayomi.tachidesk.server.user.getUserFromWsContext
import suwayomi.tachidesk.server.util.Browser
+import suwayomi.tachidesk.server.util.ServerSubpath
import suwayomi.tachidesk.server.util.WebInterfaceManager
-import uy.kohesive.injekt.injectLazy
-import java.io.File
import java.io.IOException
import java.net.URLEncoder
import java.util.Locale
@@ -54,8 +51,6 @@ import kotlin.time.Duration.Companion.days
object JavalinSetup {
private val logger = KotlinLogging.logger {}
- private val applicationDirs: ApplicationDirs by injectLazy()
-
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
fun future(block: suspend CoroutineScope.() -> T): CompletableFuture = scope.future(block = block)
@@ -65,82 +60,10 @@ object JavalinSetup {
Javalin.create { config ->
val templateEngine = TemplateEngine.createPrecompiled(ContentType.Html)
config.fileRenderer(JavalinJte(templateEngine))
- if (serverConfig.webUIEnabled.value) {
- val subpath = serverConfig.webUISubpath.value
- val rootPath = if (subpath.isNotBlank()) "$subpath/" else "/"
- runBlocking {
- WebInterfaceManager.setupWebUI()
- }
+ WebInterfaceManager.setup(config)
- // Helper function to create a servable WebUI directory with subpath injection
- fun createServableWebUIRoot(): String =
- if (subpath.isNotBlank()) {
- val tempWebUIRoot = WebInterfaceManager.createServableWebUIDirectory()
-
- // Inject subpath configuration
- val indexHtmlFile = File("$tempWebUIRoot/index.html")
-
- if (indexHtmlFile.exists()) {
- val originalIndexHtml = indexHtmlFile.readText()
-
- // Only inject if not already injected
- if (!originalIndexHtml.contains("window.__SUWAYOMI_CONFIG__")) {
- val configScript =
- """
-
- """.trimIndent()
-
- val modifiedIndexHtml =
- originalIndexHtml.replace(
- "",
- "$configScript",
- )
-
- indexHtmlFile.writeText(modifiedIndexHtml)
- }
- }
-
- tempWebUIRoot
- } else {
- // Use the original webUI root when no subpath
- applicationDirs.webUIRoot
- }
-
- // Initial setup of a servable WebUI directory
- val servableWebUIRoot = createServableWebUIRoot()
-
- // Configure static files once during initialization
- config.spaRoot.addFile(rootPath, "$servableWebUIRoot/index.html", Location.EXTERNAL)
-
- if (subpath.isNotBlank()) {
- config.staticFiles.add { staticFiles ->
- staticFiles.hostedPath = subpath
- staticFiles.directory = servableWebUIRoot
- staticFiles.location = Location.EXTERNAL
- }
- } else {
- config.staticFiles.add(servableWebUIRoot, Location.EXTERNAL)
- }
-
- // Set up callback for WebUI updates (only updates the SPA root, not static files)
- val serveWebUI = {
- val updatedServableRoot = createServableWebUIRoot()
- config.spaRoot.addFile(rootPath, "$updatedServableRoot/index.html", Location.EXTERNAL)
- }
- WebInterfaceManager.setServeWebUI(serveWebUI)
-
- logger.info {
- "Serving web static files for ${serverConfig.webUIFlavor.value}" +
- if (subpath.isNotBlank()) " under subpath '$subpath'" else ""
- }
-
- // config.registerPlugin(OpenApiPlugin(getOpenApiOptions()))
- }
+ // config.registerPlugin(OpenApiPlugin(getOpenApiOptions()))
var connectorAdded = false
config.jetty.modifyServer { server ->
@@ -181,10 +104,7 @@ object JavalinSetup {
}
config.router.apiBuilder {
- val subpath = serverConfig.webUISubpath.value
- val apiPath = if (subpath.isNotBlank()) "$subpath/api/" else "api/"
-
- path(apiPath) {
+ path(ServerSubpath.maybeAddAsPrefix("api/")) {
path("v1/") {
GlobalAPI.defineEndpoints()
MangaAPI.defineEndpoints()
@@ -204,8 +124,7 @@ object JavalinSetup {
}
}
- val subpath = serverConfig.webUISubpath.value
- val loginPath = if (subpath.isNotBlank()) "$subpath/login.html" else "/login.html"
+ val loginPath = ServerSubpath.maybeAddAsPrefix("/login.html")
app.get(loginPath) { ctx ->
val locale: Locale = LocalizationHelper.ctxToLocale(ctx)
@@ -229,8 +148,7 @@ object JavalinSetup {
password == serverConfig.authPassword.value
if (isValid) {
- val defaultRedirect = if (subpath.isNotBlank()) "$subpath/" else "/"
- val redirect = ctx.queryParam("redirect") ?: defaultRedirect
+ val redirect = ctx.queryParam("redirect") ?: ServerSubpath.maybeAddAsPrefix("/")
// NOTE: We currently have no session handler attached.
// Thus, all sessions are stored in memory and not persisted.
// Furthermore, default session timeout appears to be 30m
@@ -259,8 +177,7 @@ object JavalinSetup {
!ctx.path().substring(1).contains('/') &&
listOf(".png", ".jpg", ".ico").any { ctx.path().endsWith(it) }
val isPreFlight = ctx.method() == HandlerType.OPTIONS
- val apiPath = if (subpath.isNotBlank()) "$subpath/api/" else "/api/"
- val isApi = ctx.path().startsWith(apiPath)
+ val isApi = ctx.path().startsWith(ServerSubpath.maybeAddAsPrefix("/api/"))
val requiresAuthentication = !isPreFlight && !isPageIcon && !isWebManifest
if (!requiresAuthentication) {
diff --git a/server/src/main/kotlin/suwayomi/tachidesk/server/util/Browser.kt b/server/src/main/kotlin/suwayomi/tachidesk/server/util/Browser.kt
index 55170640..aef9e61f 100644
--- a/server/src/main/kotlin/suwayomi/tachidesk/server/util/Browser.kt
+++ b/server/src/main/kotlin/suwayomi/tachidesk/server/util/Browser.kt
@@ -19,8 +19,8 @@ object Browser {
private fun getAppBaseUrl(): String {
val appIP = if (serverConfig.ip.value == "0.0.0.0") "127.0.0.1" else serverConfig.ip.value
val baseUrl = "http://$appIP:${serverConfig.port.value}"
- val subpath = serverConfig.webUISubpath.value
- return if (subpath.isNotBlank()) "$baseUrl$subpath/" else baseUrl
+
+ return ServerSubpath.maybeAddAsSuffix(baseUrl)
}
fun openInBrowser() {
diff --git a/server/src/main/kotlin/suwayomi/tachidesk/server/util/ServerSubpath.kt b/server/src/main/kotlin/suwayomi/tachidesk/server/util/ServerSubpath.kt
new file mode 100644
index 00000000..e81e80f2
--- /dev/null
+++ b/server/src/main/kotlin/suwayomi/tachidesk/server/util/ServerSubpath.kt
@@ -0,0 +1,35 @@
+package suwayomi.tachidesk.server.util
+
+import suwayomi.tachidesk.server.serverConfig
+
+object ServerSubpath {
+ fun isDefined(): Boolean = raw().isNotBlank()
+
+ private fun raw(): String = serverConfig.webUISubpath.value.trim('/')
+
+ fun normalized(): String = "/${raw()}"
+
+ fun maybeAddAsPrefix(path: String): String {
+ if (!isDefined()) {
+ return path
+ }
+
+ return "${normalized()}/${path.removePrefix("/")}"
+ }
+
+ fun maybeAddAsSuffix(path: String): String {
+ if (!isDefined()) {
+ return path
+ }
+
+ return "${path.removeSuffix("/")}/${raw()}/"
+ }
+
+ fun asRootPath(): String {
+ if (!isDefined()) {
+ return "/"
+ }
+
+ return "${normalized()}/"
+ }
+}
diff --git a/server/src/main/kotlin/suwayomi/tachidesk/server/util/WebInterfaceManager.kt b/server/src/main/kotlin/suwayomi/tachidesk/server/util/WebInterfaceManager.kt
index f2ed247a..50dc0ee2 100644
--- a/server/src/main/kotlin/suwayomi/tachidesk/server/util/WebInterfaceManager.kt
+++ b/server/src/main/kotlin/suwayomi/tachidesk/server/util/WebInterfaceManager.kt
@@ -14,6 +14,8 @@ import eu.kanade.tachiyomi.network.NetworkHelper
import eu.kanade.tachiyomi.network.awaitSuccess
import io.github.oshai.kotlinlogging.KLogger
import io.github.oshai.kotlinlogging.KotlinLogging
+import io.javalin.config.JavalinConfig
+import io.javalin.http.staticfiles.Location
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.DelicateCoroutinesApi
import kotlinx.coroutines.Dispatchers
@@ -151,22 +153,79 @@ object WebInterfaceManager {
private var serveWebUI: () -> Unit = {}
- fun setServeWebUI(serveWebUI: () -> Unit) {
- this.serveWebUI = serveWebUI
+ fun setup(config: JavalinConfig) {
+ if (!serverConfig.webUIEnabled.value) {
+ return
+ }
+
+ runBlocking {
+ setupWebUI()
+ }
+
+ val rootPath = ServerSubpath.asRootPath()
+ val servableWebUIRoot = createServableRoot()
+
+ config.spaRoot.addFile(rootPath, "$servableWebUIRoot/index.html", Location.EXTERNAL)
+
+ if (ServerSubpath.isDefined()) {
+ config.staticFiles.add { staticFiles ->
+ staticFiles.hostedPath = ServerSubpath.normalized()
+ staticFiles.directory = servableWebUIRoot
+ staticFiles.location = Location.EXTERNAL
+ }
+ } else {
+ config.staticFiles.add(servableWebUIRoot, Location.EXTERNAL)
+ }
+
+ serveWebUI = {
+ val updatedServableRoot = createServableRoot()
+ config.spaRoot.addFile(rootPath, "$updatedServableRoot/index.html", Location.EXTERNAL)
+ }
+
+ logger.info {
+ "Serving web static files for ${serverConfig.webUIFlavor.value}" +
+ if (ServerSubpath.isDefined()) " under subpath '${ServerSubpath.normalized()}'" else ""
+ }
}
- fun createServableWebUIDirectory(): String {
+ private fun createServableRoot(): String {
+ val tempWebUIRoot = createServableDirectory()
+ val orgIndexHtml = File("$tempWebUIRoot/index.html")
+
+ if (orgIndexHtml.exists()) {
+ val originalIndexHtml = orgIndexHtml.readText()
+ val subpathInjectionScript =
+ """
+
+ """.trimIndent()
+
+ val indexHtmlWithSubpathInjection =
+ originalIndexHtml.replace(
+ "",
+ "$subpathInjectionScript",
+ )
+
+ orgIndexHtml.writeText(indexHtmlWithSubpathInjection)
+ }
+
+ return tempWebUIRoot
+ }
+
+ private fun createServableDirectory(): String {
val originalWebUIRoot = applicationDirs.webUIRoot
val tempWebUIRoot = "${applicationDirs.tempRoot}/webui-serve"
- // Clean and create temp directory
File(tempWebUIRoot).deleteRecursively()
File(tempWebUIRoot).mkdirs()
- // Copy entire WebUI directory to temp location
File(originalWebUIRoot).copyRecursively(File(tempWebUIRoot))
- logger.info { "Created servable WebUI directory at: $tempWebUIRoot" }
+ logger.debug { "Created servable WebUI directory at: $tempWebUIRoot" }
// Return canonical path to avoid Jetty alias issues
return File(tempWebUIRoot).canonicalPath