mirror of
https://github.com/Suwayomi/Tachidesk.git
synced 2025-12-10 14:52:05 +01:00
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:
@@ -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 **/
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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() {
|
||||||
|
|||||||
@@ -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()
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user