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..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 @@ -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) { @@ -97,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/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..7deab8c3c5 --- /dev/null +++ b/Plan/common/src/test/java/com/djrapitops/plan/delivery/rendering/BundleAddressCorrectionTest.java @@ -0,0 +1,138 @@ +/* + * 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); + } + + @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); + } +} 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")); + } +}