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 <base> 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).
This commit is contained in:
schroda
2025-09-25 00:01:13 +02:00
committed by GitHub
parent 6e2be271c3
commit d95f4fe1e1
4 changed files with 109 additions and 98 deletions

View File

@@ -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 <T> future(block: suspend CoroutineScope.() -> T): CompletableFuture<T> = 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 =
"""
<script>
window.__SUWAYOMI_CONFIG__ = {
webUISubpath: "$subpath"
};
</script>
""".trimIndent()
val modifiedIndexHtml =
originalIndexHtml.replace(
"</head>",
"$configScript</head>",
)
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) {

View File

@@ -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() {

View File

@@ -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()}/"
}
}

View File

@@ -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 =
"""
<script>
"// <<suwayomi-subpath-injection>>"
const baseTag = document.createElement('base');
baseTag.href = location.origin + "${ServerSubpath.normalized()}/";
document.head.appendChild(baseTag);
</script>
""".trimIndent()
val indexHtmlWithSubpathInjection =
originalIndexHtml.replace(
"<head>",
"<head>$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