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.NotFoundResponse
import io.javalin.http.RedirectResponse import io.javalin.http.RedirectResponse
import io.javalin.http.UnauthorizedResponse import io.javalin.http.UnauthorizedResponse
import io.javalin.http.staticfiles.Location
import io.javalin.rendering.template.JavalinJte import io.javalin.rendering.template.JavalinJte
import io.javalin.websocket.WsContext import io.javalin.websocket.WsContext
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
@@ -27,7 +26,6 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.future.future import kotlinx.coroutines.future.future
import kotlinx.coroutines.runBlocking
import org.eclipse.jetty.server.ServerConnector import org.eclipse.jetty.server.ServerConnector
import suwayomi.tachidesk.global.GlobalAPI import suwayomi.tachidesk.global.GlobalAPI
import suwayomi.tachidesk.graphql.GraphQL 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.getUserFromContext
import suwayomi.tachidesk.server.user.getUserFromWsContext import suwayomi.tachidesk.server.user.getUserFromWsContext
import suwayomi.tachidesk.server.util.Browser import suwayomi.tachidesk.server.util.Browser
import suwayomi.tachidesk.server.util.ServerSubpath
import suwayomi.tachidesk.server.util.WebInterfaceManager import suwayomi.tachidesk.server.util.WebInterfaceManager
import uy.kohesive.injekt.injectLazy
import java.io.File
import java.io.IOException import java.io.IOException
import java.net.URLEncoder import java.net.URLEncoder
import java.util.Locale import java.util.Locale
@@ -54,8 +51,6 @@ import kotlin.time.Duration.Companion.days
object JavalinSetup { object JavalinSetup {
private val logger = KotlinLogging.logger {} private val logger = KotlinLogging.logger {}
private val applicationDirs: ApplicationDirs by injectLazy()
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO) private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
fun <T> future(block: suspend CoroutineScope.() -> T): CompletableFuture<T> = scope.future(block = block) fun <T> future(block: suspend CoroutineScope.() -> T): CompletableFuture<T> = scope.future(block = block)
@@ -65,82 +60,10 @@ object JavalinSetup {
Javalin.create { config -> Javalin.create { config ->
val templateEngine = TemplateEngine.createPrecompiled(ContentType.Html) val templateEngine = TemplateEngine.createPrecompiled(ContentType.Html)
config.fileRenderer(JavalinJte(templateEngine)) config.fileRenderer(JavalinJte(templateEngine))
if (serverConfig.webUIEnabled.value) {
val subpath = serverConfig.webUISubpath.value
val rootPath = if (subpath.isNotBlank()) "$subpath/" else "/"
runBlocking { WebInterfaceManager.setup(config)
WebInterfaceManager.setupWebUI()
}
// Helper function to create a servable WebUI directory with subpath injection // config.registerPlugin(OpenApiPlugin(getOpenApiOptions()))
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()))
}
var connectorAdded = false var connectorAdded = false
config.jetty.modifyServer { server -> config.jetty.modifyServer { server ->
@@ -181,10 +104,7 @@ object JavalinSetup {
} }
config.router.apiBuilder { config.router.apiBuilder {
val subpath = serverConfig.webUISubpath.value path(ServerSubpath.maybeAddAsPrefix("api/")) {
val apiPath = if (subpath.isNotBlank()) "$subpath/api/" else "api/"
path(apiPath) {
path("v1/") { path("v1/") {
GlobalAPI.defineEndpoints() GlobalAPI.defineEndpoints()
MangaAPI.defineEndpoints() MangaAPI.defineEndpoints()
@@ -204,8 +124,7 @@ object JavalinSetup {
} }
} }
val subpath = serverConfig.webUISubpath.value val loginPath = ServerSubpath.maybeAddAsPrefix("/login.html")
val loginPath = if (subpath.isNotBlank()) "$subpath/login.html" else "/login.html"
app.get(loginPath) { ctx -> app.get(loginPath) { ctx ->
val locale: Locale = LocalizationHelper.ctxToLocale(ctx) val locale: Locale = LocalizationHelper.ctxToLocale(ctx)
@@ -229,8 +148,7 @@ object JavalinSetup {
password == serverConfig.authPassword.value password == serverConfig.authPassword.value
if (isValid) { if (isValid) {
val defaultRedirect = if (subpath.isNotBlank()) "$subpath/" else "/" val redirect = ctx.queryParam("redirect") ?: ServerSubpath.maybeAddAsPrefix("/")
val redirect = ctx.queryParam("redirect") ?: defaultRedirect
// NOTE: We currently have no session handler attached. // NOTE: We currently have no session handler attached.
// Thus, all sessions are stored in memory and not persisted. // Thus, all sessions are stored in memory and not persisted.
// Furthermore, default session timeout appears to be 30m // Furthermore, default session timeout appears to be 30m
@@ -259,8 +177,7 @@ object JavalinSetup {
!ctx.path().substring(1).contains('/') && !ctx.path().substring(1).contains('/') &&
listOf(".png", ".jpg", ".ico").any { ctx.path().endsWith(it) } listOf(".png", ".jpg", ".ico").any { ctx.path().endsWith(it) }
val isPreFlight = ctx.method() == HandlerType.OPTIONS val isPreFlight = ctx.method() == HandlerType.OPTIONS
val apiPath = if (subpath.isNotBlank()) "$subpath/api/" else "/api/" val isApi = ctx.path().startsWith(ServerSubpath.maybeAddAsPrefix("/api/"))
val isApi = ctx.path().startsWith(apiPath)
val requiresAuthentication = !isPreFlight && !isPageIcon && !isWebManifest val requiresAuthentication = !isPreFlight && !isPageIcon && !isWebManifest
if (!requiresAuthentication) { if (!requiresAuthentication) {

View File

@@ -19,8 +19,8 @@ object Browser {
private fun getAppBaseUrl(): String { private fun getAppBaseUrl(): String {
val appIP = if (serverConfig.ip.value == "0.0.0.0") "127.0.0.1" else serverConfig.ip.value 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 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() { 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 eu.kanade.tachiyomi.network.awaitSuccess
import io.github.oshai.kotlinlogging.KLogger import io.github.oshai.kotlinlogging.KLogger
import io.github.oshai.kotlinlogging.KotlinLogging import io.github.oshai.kotlinlogging.KotlinLogging
import io.javalin.config.JavalinConfig
import io.javalin.http.staticfiles.Location
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.DelicateCoroutinesApi import kotlinx.coroutines.DelicateCoroutinesApi
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
@@ -151,22 +153,79 @@ object WebInterfaceManager {
private var serveWebUI: () -> Unit = {} private var serveWebUI: () -> Unit = {}
fun setServeWebUI(serveWebUI: () -> Unit) { fun setup(config: JavalinConfig) {
this.serveWebUI = serveWebUI 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 originalWebUIRoot = applicationDirs.webUIRoot
val tempWebUIRoot = "${applicationDirs.tempRoot}/webui-serve" val tempWebUIRoot = "${applicationDirs.tempRoot}/webui-serve"
// Clean and create temp directory
File(tempWebUIRoot).deleteRecursively() File(tempWebUIRoot).deleteRecursively()
File(tempWebUIRoot).mkdirs() File(tempWebUIRoot).mkdirs()
// Copy entire WebUI directory to temp location
File(originalWebUIRoot).copyRecursively(File(tempWebUIRoot)) 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 canonical path to avoid Jetty alias issues
return File(tempWebUIRoot).canonicalPath return File(tempWebUIRoot).canonicalPath