diff --git a/server/server-config/src/main/kotlin/suwayomi/tachidesk/server/ServerConfig.kt b/server/server-config/src/main/kotlin/suwayomi/tachidesk/server/ServerConfig.kt index 2b7a92bb..a56e53f4 100644 --- a/server/server-config/src/main/kotlin/suwayomi/tachidesk/server/ServerConfig.kt +++ b/server/server-config/src/main/kotlin/suwayomi/tachidesk/server/ServerConfig.kt @@ -759,6 +759,16 @@ class ServerConfig( description = "Strategy to apply when remote progress is older than local.", ) + val webUISubpath: MutableStateFlow 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 **/ diff --git a/server/src/main/kotlin/suwayomi/tachidesk/server/JavalinSetup.kt b/server/src/main/kotlin/suwayomi/tachidesk/server/JavalinSetup.kt index 22bce3e5..89e8cb7c 100644 --- a/server/src/main/kotlin/suwayomi/tachidesk/server/JavalinSetup.kt +++ b/server/src/main/kotlin/suwayomi/tachidesk/server/JavalinSetup.kt @@ -43,6 +43,7 @@ import suwayomi.tachidesk.server.user.getUserFromWsContext import suwayomi.tachidesk.server.util.Browser 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 @@ -65,18 +66,78 @@ object JavalinSetup { val templateEngine = TemplateEngine.createPrecompiled(ContentType.Html) config.fileRenderer(JavalinJte(templateEngine)) if (serverConfig.webUIEnabled.value) { - val serveWebUI = { - config.spaRoot.addFile("/", applicationDirs.webUIRoot + "/index.html", Location.EXTERNAL) - } - WebInterfaceManager.setServeWebUI(serveWebUI) + val subpath = serverConfig.webUISubpath.value + val rootPath = if (subpath.isNotBlank()) "$subpath/" else "/" runBlocking { WebInterfaceManager.setupWebUI() } - logger.info { "Serving web static files for ${serverConfig.webUIFlavor.value}" } - config.staticFiles.add(applicationDirs.webUIRoot, Location.EXTERNAL) - serveWebUI() + // 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())) } @@ -120,7 +181,10 @@ object JavalinSetup { } config.router.apiBuilder { - path("api/") { + val subpath = serverConfig.webUISubpath.value + val apiPath = if (subpath.isNotBlank()) "$subpath/api/" else "api/" + + path(apiPath) { path("v1/") { GlobalAPI.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) ctx.header("content-type", "text/html") 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 password = ctx.formParam("pass") val isValid = @@ -162,7 +229,8 @@ object JavalinSetup { password == serverConfig.authPassword.value 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. // Thus, all sessions are stored in memory and not persisted. // Furthermore, default session timeout appears to be 30m @@ -184,13 +252,15 @@ object JavalinSetup { } 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 = ctx.path().startsWith('/') && !ctx.path().substring(1).contains('/') && listOf(".png", ".jpg", ".ico").any { ctx.path().endsWith(it) } 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 if (!requiresAuthentication) { @@ -212,7 +282,7 @@ object JavalinSetup { } 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) throw RedirectResponse(HttpStatus.SEE_OTHER) } 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 0c1d1718..55170640 100644 --- a/server/src/main/kotlin/suwayomi/tachidesk/server/util/Browser.kt +++ b/server/src/main/kotlin/suwayomi/tachidesk/server/util/Browser.kt @@ -18,7 +18,9 @@ object Browser { private fun getAppBaseUrl(): String { 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() { 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 1d877600..f2ed247a 100644 --- a/server/src/main/kotlin/suwayomi/tachidesk/server/util/WebInterfaceManager.kt +++ b/server/src/main/kotlin/suwayomi/tachidesk/server/util/WebInterfaceManager.kt @@ -155,6 +155,23 @@ object WebInterfaceManager { 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) { preferences.edit().putString(SERVED_WEBUI_FLAVOR_KEY, flavor.uiName).apply() }