From 3f18f1d2412bc7bc27b26b04e610deeef042c3e0 Mon Sep 17 00:00:00 2001 From: Leo Le Bleis Date: Sun, 15 Mar 2026 13:00:41 +0000 Subject: [PATCH 1/2] Fix reverse proxy subpath support for External_Webserver_address When Plan runs behind a reverse proxy at a subpath (e.g. /minecraft/stats/), the External_Webserver_address config was only used as a fallback when the webserver was disabled. This meant HTML asset paths stayed root-relative (/static/...) and PLAN_BASE_ADDRESS was injected with the internal address, breaking both static asset loading and React Router navigation. Add Addresses.getExternalAddress() that returns the configured external address when valid, and prefer it in both BundleAddressCorrection (for HTML/JS/CSS path rewriting) and ResponseFactory (for PLAN_BASE_ADDRESS injection). This gives the JS the correct protocol and subpath for reverse proxy setups. --- .../rendering/BundleAddressCorrection.java | 12 +- .../delivery/rendering/html/Contributors.java | 3 +- .../plan/delivery/webserver/Addresses.java | 13 +++ .../delivery/webserver/ResponseFactory.java | 8 +- .../BundleAddressCorrectionTest.java | 109 ++++++++++++++++++ .../AddressesExternalAddressTest.java | 106 +++++++++++++++++ 6 files changed, 245 insertions(+), 6 deletions(-) create mode 100644 Plan/common/src/test/java/com/djrapitops/plan/delivery/rendering/BundleAddressCorrectionTest.java create mode 100644 Plan/common/src/test/java/com/djrapitops/plan/delivery/webserver/AddressesExternalAddressTest.java diff --git a/Plan/common/src/main/java/com/djrapitops/plan/delivery/rendering/BundleAddressCorrection.java b/Plan/common/src/main/java/com/djrapitops/plan/delivery/rendering/BundleAddressCorrection.java index 1fe3480b2f..9016a56204 100644 --- a/Plan/common/src/main/java/com/djrapitops/plan/delivery/rendering/BundleAddressCorrection.java +++ b/Plan/common/src/main/java/com/djrapitops/plan/delivery/rendering/BundleAddressCorrection.java @@ -56,9 +56,15 @@ private String getExportBasePath() { } private String getWebserverBasePath() { - String address = addresses.getMainAddress() - .orElseGet(addresses::getFallbackLocalhostAddress); - return addresses.getBasePath(address); + // Prefer External_Webserver_address base path for reverse proxy subpath support + return addresses.getExternalAddress() + .map(addresses::getBasePath) + .filter(basePath -> !basePath.isEmpty()) + .orElseGet(() -> { + String address = addresses.getMainAddress() + .orElseGet(addresses::getFallbackLocalhostAddress); + return addresses.getBasePath(address); + }); } public String correctAddressForWebserver(String content, String fileName) { diff --git a/Plan/common/src/main/java/com/djrapitops/plan/delivery/rendering/html/Contributors.java b/Plan/common/src/main/java/com/djrapitops/plan/delivery/rendering/html/Contributors.java index 657ca8a7ef..1da4fb76b5 100644 --- a/Plan/common/src/main/java/com/djrapitops/plan/delivery/rendering/html/Contributors.java +++ b/Plan/common/src/main/java/com/djrapitops/plan/delivery/rendering/html/Contributors.java @@ -120,7 +120,8 @@ public class Contributors { new Contributor("YannicHock", CODE), new Contributor("SaolGhra", CODE), new Contributor("Jsinco", CODE), - new Contributor("julianvdhogen", LANG) + new Contributor("julianvdhogen", LANG), + new Contributor("Leolebleis", CODE) }; private Contributors() { diff --git a/Plan/common/src/main/java/com/djrapitops/plan/delivery/webserver/Addresses.java b/Plan/common/src/main/java/com/djrapitops/plan/delivery/webserver/Addresses.java index a12068ae46..e8ccefee1c 100644 --- a/Plan/common/src/main/java/com/djrapitops/plan/delivery/webserver/Addresses.java +++ b/Plan/common/src/main/java/com/djrapitops/plan/delivery/webserver/Addresses.java @@ -120,6 +120,19 @@ public Optional getAnyValidServerAddress() { .findAny(); } + /** + * Get the configured External_Webserver_address if it is set to a non-default value. + *

+ * This is useful for reverse proxy setups where the external address includes + * the correct protocol and subpath that the internal webserver address lacks. + * + * @return External address if configured, empty otherwise. + */ + public Optional getExternalAddress() { + String externalLink = getFallbackExternalAddress(); + return isValidAddress(externalLink) ? Optional.of(externalLink) : Optional.empty(); + } + private boolean isValidAddress(String address) { return address != null && !address.isEmpty() diff --git a/Plan/common/src/main/java/com/djrapitops/plan/delivery/webserver/ResponseFactory.java b/Plan/common/src/main/java/com/djrapitops/plan/delivery/webserver/ResponseFactory.java index 5265740886..95cbf9ec55 100644 --- a/Plan/common/src/main/java/com/djrapitops/plan/delivery/webserver/ResponseFactory.java +++ b/Plan/common/src/main/java/com/djrapitops/plan/delivery/webserver/ResponseFactory.java @@ -234,8 +234,12 @@ public Response javaScriptResponse(@Untrusted String fileName) { } private String replaceMainAddressPlaceholder(String resource) { - String address = addresses.get().getAccessAddress() - .orElseGet(addresses.get()::getFallbackLocalhostAddress); + Addresses addr = addresses.get(); + // Prefer External_Webserver_address when configured — it includes + // the correct protocol and subpath for reverse proxy setups. + String address = addr.getExternalAddress() + .orElseGet(() -> addr.getAccessAddress() + .orElseGet(addr::getFallbackLocalhostAddress)); return Strings.CS.replace(resource, "PLAN_BASE_ADDRESS", address); } diff --git a/Plan/common/src/test/java/com/djrapitops/plan/delivery/rendering/BundleAddressCorrectionTest.java b/Plan/common/src/test/java/com/djrapitops/plan/delivery/rendering/BundleAddressCorrectionTest.java new file mode 100644 index 0000000000..931f744ca6 --- /dev/null +++ b/Plan/common/src/test/java/com/djrapitops/plan/delivery/rendering/BundleAddressCorrectionTest.java @@ -0,0 +1,109 @@ +/* + * This file is part of Player Analytics (Plan). + * + * Plan is free software: you can redistribute it and/or modify + * it under the terms of the GNU Lesser General Public License v3 as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Plan is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with Plan. If not, see . + */ +package com.djrapitops.plan.delivery.rendering; + +import com.djrapitops.plan.delivery.webserver.Addresses; +import com.djrapitops.plan.settings.config.PlanConfig; +import com.djrapitops.plan.settings.config.paths.WebserverSettings; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.util.Optional; + +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.Mockito.when; + +/** + * Tests for {@link BundleAddressCorrection} reverse proxy subpath support. + */ +@ExtendWith(MockitoExtension.class) +class BundleAddressCorrectionTest { + + @Mock + PlanConfig config; + @Mock + Addresses addresses; + @InjectMocks + BundleAddressCorrection bundleAddressCorrection; + + @Test + @DisplayName("HTML paths are corrected when External_Webserver_address has subpath") + void htmlPathsCorrectedWithExternalSubpath() { + when(addresses.getExternalAddress()).thenReturn(Optional.of("https://example.com/plan")); + when(addresses.getBasePath("https://example.com/plan")).thenReturn("/plan"); + + String html = "" + + ""; + + String result = bundleAddressCorrection.correctAddressForWebserver(html, "index.html"); + + assertTrue(result.contains("src=\"/plan/static/index-abc123.js\""), + "JS src should be corrected to /plan/static/..., got: " + result); + assertTrue(result.contains("href=\"/plan/static/index-abc123.css\""), + "CSS href should be corrected to /plan/static/..., got: " + result); + } + + @Test + @DisplayName("HTML paths unchanged when External_Webserver_address has no subpath") + void htmlPathsUnchangedWithoutSubpath() { + when(addresses.getExternalAddress()).thenReturn(Optional.of("https://example.com")); + when(addresses.getBasePath("https://example.com")).thenReturn(""); + when(addresses.getMainAddress()).thenReturn(Optional.of("http://localhost:8804")); + when(addresses.getBasePath("http://localhost:8804")).thenReturn(""); + + String html = ""; + + String result = bundleAddressCorrection.correctAddressForWebserver(html, "index.html"); + + assertTrue(result.contains("src=\"/static/index-abc123.js\""), + "JS src should stay at root when no subpath, got: " + result); + } + + @Test + @DisplayName("HTML paths corrected using main address when no external address") + void htmlPathsCorrectedFromMainAddress() { + when(addresses.getExternalAddress()).thenReturn(Optional.empty()); + when(addresses.getMainAddress()).thenReturn(Optional.of("http://example.com/plan")); + when(addresses.getBasePath("http://example.com/plan")).thenReturn("/plan"); + + String html = ""; + + String result = bundleAddressCorrection.correctAddressForWebserver(html, "index.html"); + + assertTrue(result.contains("src=\"/plan/static/index-abc123.js\""), + "Should fall back to main address base path, got: " + result); + } + + @Test + @DisplayName("HTML paths unchanged when no external address and main address has no subpath") + void htmlPathsUnchangedNoSubpathAnywhere() { + when(addresses.getExternalAddress()).thenReturn(Optional.empty()); + when(addresses.getMainAddress()).thenReturn(Optional.of("http://localhost:8804")); + when(addresses.getBasePath("http://localhost:8804")).thenReturn(""); + + String html = ""; + + String result = bundleAddressCorrection.correctAddressForWebserver(html, "index.html"); + + assertTrue(result.contains("src=\"/static/index-abc123.js\""), + "Should be unchanged when no subpath anywhere, got: " + result); + } +} diff --git a/Plan/common/src/test/java/com/djrapitops/plan/delivery/webserver/AddressesExternalAddressTest.java b/Plan/common/src/test/java/com/djrapitops/plan/delivery/webserver/AddressesExternalAddressTest.java new file mode 100644 index 0000000000..7f83fada71 --- /dev/null +++ b/Plan/common/src/test/java/com/djrapitops/plan/delivery/webserver/AddressesExternalAddressTest.java @@ -0,0 +1,106 @@ +/* + * This file is part of Player Analytics (Plan). + * + * Plan is free software: you can redistribute it and/or modify + * it under the terms of the GNU Lesser General Public License v3 as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Plan is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with Plan. If not, see . + */ +package com.djrapitops.plan.delivery.webserver; + +import com.djrapitops.plan.delivery.webserver.http.WebServer; +import com.djrapitops.plan.identification.properties.ServerProperties; +import com.djrapitops.plan.settings.config.PlanConfig; +import com.djrapitops.plan.settings.config.paths.WebserverSettings; +import com.djrapitops.plan.storage.database.DBSystem; +import dagger.Lazy; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.util.Optional; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.mockito.Mockito.when; + +/** + * Tests for {@link Addresses#getExternalAddress()} and {@link Addresses#getBasePath(String)}. + */ +@ExtendWith(MockitoExtension.class) +class AddressesExternalAddressTest { + + @Mock + PlanConfig config; + @Mock + DBSystem dbSystem; + @Mock + Lazy serverProperties; + @Mock + Lazy webserver; + @InjectMocks + Addresses addresses; + + @Test + @DisplayName("getExternalAddress returns configured address with subpath") + void externalAddressWithSubpath() { + when(config.get(WebserverSettings.EXTERNAL_LINK)).thenReturn("https://example.com/plan"); + assertEquals(Optional.of("https://example.com/plan"), addresses.getExternalAddress()); + } + + @Test + @DisplayName("getExternalAddress returns configured address without subpath") + void externalAddressWithoutSubpath() { + when(config.get(WebserverSettings.EXTERNAL_LINK)).thenReturn("https://example.com"); + assertEquals(Optional.of("https://example.com"), addresses.getExternalAddress()); + } + + @Test + @DisplayName("getExternalAddress returns empty for default placeholder") + void externalAddressDefaultPlaceholder() { + when(config.get(WebserverSettings.EXTERNAL_LINK)).thenReturn("https://www.example.address"); + assertEquals(Optional.empty(), addresses.getExternalAddress()); + } + + @Test + @DisplayName("getExternalAddress returns empty for http placeholder") + void externalAddressHttpPlaceholder() { + when(config.get(WebserverSettings.EXTERNAL_LINK)).thenReturn("http://www.example.address"); + assertEquals(Optional.empty(), addresses.getExternalAddress()); + } + + @Test + @DisplayName("getExternalAddress returns empty for empty string") + void externalAddressEmpty() { + when(config.get(WebserverSettings.EXTERNAL_LINK)).thenReturn(""); + assertEquals(Optional.empty(), addresses.getExternalAddress()); + } + + @Test + @DisplayName("getBasePath extracts subpath from address") + void basePathExtraction() { + assertEquals("/minecraft/stats", addresses.getBasePath("https://disqt.com/minecraft/stats")); + } + + @Test + @DisplayName("getBasePath returns empty for root address") + void basePathRoot() { + assertEquals("", addresses.getBasePath("https://example.com")); + } + + @Test + @DisplayName("getBasePath extracts subpath from http address") + void basePathHttp() { + assertEquals("/plan", addresses.getBasePath("http://example.com/plan")); + } +} From cbfc0281a97411dbdc84aa4869d3a0a8e72bfeee Mon Sep 17 00:00:00 2001 From: Leo Le Bleis Date: Sun, 15 Mar 2026 13:45:16 +0000 Subject: [PATCH 2/2] Fix Vite preload base URL for subpath deployments The Vite __vitePreload helper uses `return"/"+l` to construct asset URLs. When deployed at a subpath, this produces root-relative paths like `/static/...` instead of `/plan/static/...`, causing CSS preload failures. Replace the hardcoded "/" prefix with the base path when configured. --- .../rendering/BundleAddressCorrection.java | 7 +++++ .../BundleAddressCorrectionTest.java | 29 +++++++++++++++++++ 2 files changed, 36 insertions(+) diff --git a/Plan/common/src/main/java/com/djrapitops/plan/delivery/rendering/BundleAddressCorrection.java b/Plan/common/src/main/java/com/djrapitops/plan/delivery/rendering/BundleAddressCorrection.java index 9016a56204..b8d1aa2dc2 100644 --- a/Plan/common/src/main/java/com/djrapitops/plan/delivery/rendering/BundleAddressCorrection.java +++ b/Plan/common/src/main/java/com/djrapitops/plan/delivery/rendering/BundleAddressCorrection.java @@ -103,6 +103,13 @@ private String correctAddressInCss(String content, String basePath) { } private String correctAddressInJavascript(String content, String basePath) { + // Vite's __vitePreload base URL function: return"/"+l + // Needs to include the base path so preloaded assets resolve correctly. + if (!basePath.isEmpty()) { + String endingSlash = basePath.endsWith("/") ? "" : "/"; + content = Strings.CS.replace(content, "return\"/\"+", "return\"" + basePath + endingSlash + "\"+"); + } + int lastIndex = 0; StringBuilder output = new StringBuilder(); diff --git a/Plan/common/src/test/java/com/djrapitops/plan/delivery/rendering/BundleAddressCorrectionTest.java b/Plan/common/src/test/java/com/djrapitops/plan/delivery/rendering/BundleAddressCorrectionTest.java index 931f744ca6..7deab8c3c5 100644 --- a/Plan/common/src/test/java/com/djrapitops/plan/delivery/rendering/BundleAddressCorrectionTest.java +++ b/Plan/common/src/test/java/com/djrapitops/plan/delivery/rendering/BundleAddressCorrectionTest.java @@ -106,4 +106,33 @@ void htmlPathsUnchangedNoSubpathAnywhere() { assertTrue(result.contains("src=\"/static/index-abc123.js\""), "Should be unchanged when no subpath anywhere, got: " + result); } + + @Test + @DisplayName("Vite preload base URL is corrected with subpath") + void vitePreloadCorrectedWithSubpath() { + when(addresses.getExternalAddress()).thenReturn(Optional.of("https://example.com/plan")); + when(addresses.getBasePath("https://example.com/plan")).thenReturn("/plan"); + + String js = "GN=function(l){return\"/\"+l}"; + + String result = bundleAddressCorrection.correctAddressForWebserver(js, "index.js"); + + assertTrue(result.contains("return\"/plan/\"+l"), + "Vite preload should include base path, got: " + result); + } + + @Test + @DisplayName("Vite preload base URL unchanged at root") + void vitePreloadUnchangedAtRoot() { + when(addresses.getExternalAddress()).thenReturn(Optional.empty()); + when(addresses.getMainAddress()).thenReturn(Optional.of("http://localhost:8804")); + when(addresses.getBasePath("http://localhost:8804")).thenReturn(""); + + String js = "GN=function(l){return\"/\"+l}"; + + String result = bundleAddressCorrection.correctAddressForWebserver(js, "index.js"); + + assertTrue(result.contains("return\"/\"+l"), + "Vite preload should be unchanged at root, got: " + result); + } }