Add server-side subpath support for WebUI (#1658)

* Add server-side subpath support for WebUI

- Add webUISubpath configuration setting with regex validation
- Create temporary WebUI directory for subpath serving
- Inject subpath config into index.html for client detection
- Support all WebUI flavors (WEBUI, VUI, CUSTOM) with subpath
- Update browser opening to use subpath URLs

Related to Suwayomi/Suwayomi-WebUI#174

* Fix review points

* Fix code formatting issues

* Fix import issue
This commit is contained in:
Soner Köksal
2025-09-23 22:53:53 +03:00
committed by GitHub
parent d142d3a25a
commit c7b4f226b3
4 changed files with 114 additions and 15 deletions

View File

@@ -759,6 +759,16 @@ class ServerConfig(
description = "Strategy to apply when remote progress is older than local.", description = "Strategy to apply when remote progress is older than local.",
) )
val webUISubpath: MutableStateFlow<String> by StringSetting(
protoNumber = 75,
group = SettingGroup.WEB_UI,
defaultValue = "",
pattern = "^(/[a-zA-Z0-9._-]+)*$".toRegex(),
description = "Serve WebUI under a subpath (e.g., /manga). Leave empty for root path. Must start with / if specified.",
requiresRestart = true,
)
/** ****************************************************************** **/ /** ****************************************************************** **/
/** **/ /** **/
/** Renamed settings **/ /** Renamed settings **/

View File

@@ -43,6 +43,7 @@ import suwayomi.tachidesk.server.user.getUserFromWsContext
import suwayomi.tachidesk.server.util.Browser import suwayomi.tachidesk.server.util.Browser
import suwayomi.tachidesk.server.util.WebInterfaceManager import suwayomi.tachidesk.server.util.WebInterfaceManager
import uy.kohesive.injekt.injectLazy 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
@@ -65,18 +66,78 @@ object JavalinSetup {
val templateEngine = TemplateEngine.createPrecompiled(ContentType.Html) val templateEngine = TemplateEngine.createPrecompiled(ContentType.Html)
config.fileRenderer(JavalinJte(templateEngine)) config.fileRenderer(JavalinJte(templateEngine))
if (serverConfig.webUIEnabled.value) { if (serverConfig.webUIEnabled.value) {
val serveWebUI = { val subpath = serverConfig.webUISubpath.value
config.spaRoot.addFile("/", applicationDirs.webUIRoot + "/index.html", Location.EXTERNAL) val rootPath = if (subpath.isNotBlank()) "$subpath/" else "/"
}
WebInterfaceManager.setServeWebUI(serveWebUI)
runBlocking { runBlocking {
WebInterfaceManager.setupWebUI() WebInterfaceManager.setupWebUI()
} }
logger.info { "Serving web static files for ${serverConfig.webUIFlavor.value}" } // Helper function to create a servable WebUI directory with subpath injection
config.staticFiles.add(applicationDirs.webUIRoot, Location.EXTERNAL) fun createServableWebUIRoot(): String =
serveWebUI() 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()))
} }
@@ -120,7 +181,10 @@ object JavalinSetup {
} }
config.router.apiBuilder { config.router.apiBuilder {
path("api/") { val subpath = serverConfig.webUISubpath.value
val apiPath = if (subpath.isNotBlank()) "$subpath/api/" else "api/"
path(apiPath) {
path("v1/") { path("v1/") {
GlobalAPI.defineEndpoints() GlobalAPI.defineEndpoints()
MangaAPI.defineEndpoints() MangaAPI.defineEndpoints()
@@ -140,7 +204,10 @@ object JavalinSetup {
} }
} }
app.get("/login.html") { ctx -> val subpath = serverConfig.webUISubpath.value
val loginPath = if (subpath.isNotBlank()) "$subpath/login.html" else "/login.html"
app.get(loginPath) { ctx ->
val locale: Locale = LocalizationHelper.ctxToLocale(ctx) val locale: Locale = LocalizationHelper.ctxToLocale(ctx)
ctx.header("content-type", "text/html") ctx.header("content-type", "text/html")
val httpCacheSeconds = 1.days.inWholeSeconds val httpCacheSeconds = 1.days.inWholeSeconds
@@ -154,7 +221,7 @@ object JavalinSetup {
) )
} }
app.post("/login.html") { ctx -> app.post(loginPath) { ctx ->
val username = ctx.formParam("user") val username = ctx.formParam("user")
val password = ctx.formParam("pass") val password = ctx.formParam("pass")
val isValid = val isValid =
@@ -162,7 +229,8 @@ object JavalinSetup {
password == serverConfig.authPassword.value password == serverConfig.authPassword.value
if (isValid) { if (isValid) {
val redirect = ctx.queryParam("redirect") ?: "/" val defaultRedirect = if (subpath.isNotBlank()) "$subpath/" else "/"
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
@@ -184,13 +252,15 @@ object JavalinSetup {
} }
app.beforeMatched { ctx -> app.beforeMatched { ctx ->
val isWebManifest = listOf("site.webmanifest", "manifest.json", "login.html").any { ctx.path().endsWith(it) } val isWebManifest =
listOf("site.webmanifest", "manifest.json", "login.html").any { ctx.path().endsWith(it) }
val isPageIcon = val isPageIcon =
ctx.path().startsWith('/') && ctx.path().startsWith('/') &&
!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 isApi = ctx.path().startsWith("/api/") val apiPath = if (subpath.isNotBlank()) "$subpath/api/" else "/api/"
val isApi = ctx.path().startsWith(apiPath)
val requiresAuthentication = !isPreFlight && !isPageIcon && !isWebManifest val requiresAuthentication = !isPreFlight && !isPageIcon && !isWebManifest
if (!requiresAuthentication) { if (!requiresAuthentication) {
@@ -212,7 +282,7 @@ object JavalinSetup {
} }
if (authMode == AuthMode.SIMPLE_LOGIN && !cookieValid() && !isApi) { if (authMode == AuthMode.SIMPLE_LOGIN && !cookieValid() && !isApi) {
val url = "/login.html?redirect=" + URLEncoder.encode(ctx.fullUrl(), Charsets.UTF_8) val url = "$loginPath?redirect=" + URLEncoder.encode(ctx.fullUrl(), Charsets.UTF_8)
ctx.header("Location", url) ctx.header("Location", url)
throw RedirectResponse(HttpStatus.SEE_OTHER) throw RedirectResponse(HttpStatus.SEE_OTHER)
} }

View File

@@ -18,7 +18,9 @@ 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
return "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
} }
fun openInBrowser() { fun openInBrowser() {

View File

@@ -155,6 +155,23 @@ object WebInterfaceManager {
this.serveWebUI = serveWebUI this.serveWebUI = serveWebUI
} }
fun createServableWebUIDirectory(): 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" }
// Return canonical path to avoid Jetty alias issues
return File(tempWebUIRoot).canonicalPath
}
private fun setServedWebUIFlavor(flavor: WebUIFlavor) { private fun setServedWebUIFlavor(flavor: WebUIFlavor) {
preferences.edit().putString(SERVED_WEBUI_FLAVOR_KEY, flavor.uiName).apply() preferences.edit().putString(SERVED_WEBUI_FLAVOR_KEY, flavor.uiName).apply()
} }