mirror of
https://github.com/plan-player-analytics/Plan.git
synced 2024-06-26 06:34:56 +02:00
Implemented support for reverse-proxy subdirectory addresses
Reverse proxied version of React website now works when subdirectory address is used (eg. /plan/...) The functionality was unit tested to ensure things work
This commit is contained in:
parent
5082f80030
commit
aa897fe8de
|
@ -22,6 +22,7 @@ import com.djrapitops.plan.delivery.formatting.Formatters;
|
|||
import com.djrapitops.plan.delivery.rendering.html.icon.Icon;
|
||||
import com.djrapitops.plan.delivery.web.ResourceService;
|
||||
import com.djrapitops.plan.delivery.web.resolver.exception.NotFoundException;
|
||||
import com.djrapitops.plan.delivery.webserver.Addresses;
|
||||
import com.djrapitops.plan.delivery.webserver.cache.JSONStorage;
|
||||
import com.djrapitops.plan.extension.implementation.results.ExtensionData;
|
||||
import com.djrapitops.plan.extension.implementation.storage.queries.ExtensionPlayerDataQuery;
|
||||
|
@ -39,6 +40,7 @@ import com.djrapitops.plan.storage.database.queries.objects.ServerQueries;
|
|||
import com.djrapitops.plan.storage.file.PlanFiles;
|
||||
import com.djrapitops.plan.version.VersionChecker;
|
||||
import dagger.Lazy;
|
||||
import org.apache.commons.lang3.StringUtils;
|
||||
|
||||
import javax.inject.Inject;
|
||||
import javax.inject.Singleton;
|
||||
|
@ -64,6 +66,7 @@ public class PageFactory {
|
|||
private final Lazy<Formatters> formatters;
|
||||
private final Lazy<Locale> locale;
|
||||
private final Lazy<ComponentSvc> componentService;
|
||||
private final Lazy<Addresses> addresses;
|
||||
|
||||
@Inject
|
||||
public PageFactory(
|
||||
|
@ -76,7 +79,8 @@ public class PageFactory {
|
|||
Lazy<JSONStorage> jsonStorage,
|
||||
Lazy<Formatters> formatters,
|
||||
Lazy<Locale> locale,
|
||||
Lazy<ComponentSvc> componentService
|
||||
Lazy<ComponentSvc> componentService,
|
||||
Lazy<Addresses> addresses
|
||||
) {
|
||||
this.versionChecker = versionChecker;
|
||||
this.files = files;
|
||||
|
@ -88,18 +92,31 @@ public class PageFactory {
|
|||
this.formatters = formatters;
|
||||
this.locale = locale;
|
||||
this.componentService = componentService;
|
||||
this.addresses = addresses;
|
||||
}
|
||||
|
||||
public Page playersPage() throws IOException {
|
||||
if (config.get().isTrue(PluginSettings.FRONTEND_BETA)) {
|
||||
String reactHtml = getResource("index.html");
|
||||
return () -> reactHtml;
|
||||
return reactPage();
|
||||
}
|
||||
|
||||
return new PlayersPage(getResource("players.html"), versionChecker.get(),
|
||||
config.get(), theme.get(), serverInfo.get());
|
||||
}
|
||||
|
||||
public Page reactPage() throws IOException {
|
||||
String reactHtml = StringUtils.replace(
|
||||
getResource("index.html"),
|
||||
"/static", getBasePath() + "/static");
|
||||
return () -> reactHtml;
|
||||
}
|
||||
|
||||
private String getBasePath() {
|
||||
String address = addresses.get().getMainAddress()
|
||||
.orElseGet(addresses.get()::getFallbackLocalhostAddress);
|
||||
return addresses.get().getBasePath(address);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a server page.
|
||||
*
|
||||
|
@ -113,8 +130,7 @@ public class PageFactory {
|
|||
.orElseThrow(() -> new NotFoundException("Server not found in the database"));
|
||||
|
||||
if (config.get().isTrue(PluginSettings.FRONTEND_BETA)) {
|
||||
String reactHtml = getResource("index.html");
|
||||
return () -> reactHtml;
|
||||
return reactPage();
|
||||
}
|
||||
|
||||
return new ServerPage(
|
||||
|
@ -137,8 +153,7 @@ public class PageFactory {
|
|||
PlayerContainer player = db.query(ContainerFetchQueries.fetchPlayerContainer(playerUUID));
|
||||
|
||||
if (config.get().isTrue(PluginSettings.FRONTEND_BETA)) {
|
||||
String reactHtml = getResource("index.html");
|
||||
return () -> reactHtml;
|
||||
return reactPage();
|
||||
}
|
||||
|
||||
return new PlayerPage(
|
||||
|
@ -188,8 +203,7 @@ public class PageFactory {
|
|||
|
||||
public Page networkPage() throws IOException {
|
||||
if (config.get().isTrue(PluginSettings.FRONTEND_BETA)) {
|
||||
String reactHtml = getResource("index.html");
|
||||
return () -> reactHtml;
|
||||
return reactPage();
|
||||
}
|
||||
|
||||
return new NetworkPage(getResource("network.html"),
|
||||
|
@ -240,8 +254,7 @@ public class PageFactory {
|
|||
|
||||
public Page loginPage() throws IOException {
|
||||
if (config.get().isTrue(PluginSettings.FRONTEND_BETA)) {
|
||||
String reactHtml = getResource("index.html");
|
||||
return () -> reactHtml;
|
||||
return reactPage();
|
||||
}
|
||||
|
||||
return new LoginPage(getResource("login.html"), serverInfo.get(), locale.get(), theme.get(), versionChecker.get());
|
||||
|
@ -249,8 +262,7 @@ public class PageFactory {
|
|||
|
||||
public Page registerPage() throws IOException {
|
||||
if (config.get().isTrue(PluginSettings.FRONTEND_BETA)) {
|
||||
String reactHtml = getResource("index.html");
|
||||
return () -> reactHtml;
|
||||
return reactPage();
|
||||
}
|
||||
|
||||
return new LoginPage(getResource("register.html"), serverInfo.get(), locale.get(), theme.get(), versionChecker.get());
|
||||
|
@ -258,8 +270,7 @@ public class PageFactory {
|
|||
|
||||
public Page queryPage() throws IOException {
|
||||
if (config.get().isTrue(PluginSettings.FRONTEND_BETA)) {
|
||||
String reactHtml = getResource("index.html");
|
||||
return () -> reactHtml;
|
||||
return reactPage();
|
||||
}
|
||||
return new QueryPage(
|
||||
getResource("query.html"),
|
||||
|
@ -268,7 +279,6 @@ public class PageFactory {
|
|||
}
|
||||
|
||||
public Page errorsPage() throws IOException {
|
||||
String reactHtml = getResource("index.html");
|
||||
return () -> reactHtml;
|
||||
return reactPage();
|
||||
}
|
||||
}
|
|
@ -24,6 +24,7 @@ import com.djrapitops.plan.settings.config.paths.WebserverSettings;
|
|||
import com.djrapitops.plan.storage.database.DBSystem;
|
||||
import com.djrapitops.plan.storage.database.queries.objects.ServerQueries;
|
||||
import dagger.Lazy;
|
||||
import org.apache.commons.lang3.StringUtils;
|
||||
|
||||
import javax.inject.Inject;
|
||||
import javax.inject.Singleton;
|
||||
|
@ -111,4 +112,15 @@ public class Addresses {
|
|||
String ip = serverProperties.get().getIp();
|
||||
return isValidAddress(ip) ? Optional.of(ip) : Optional.empty();
|
||||
}
|
||||
|
||||
public String getBasePath(String address) {
|
||||
String basePath = address
|
||||
.replace("http://", "")
|
||||
.replace("https://", "");
|
||||
if (StringUtils.contains(basePath, '/')) {
|
||||
return basePath.substring(StringUtils.indexOf(basePath, '/'));
|
||||
} else {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -178,6 +178,8 @@ public class ResponseFactory {
|
|||
if (fileName.startsWith("vendor/") || fileName.startsWith("/vendor/")) {return resource;}
|
||||
return locale.replaceLanguageInJavascript(resource);
|
||||
})
|
||||
.chain(resource -> StringUtils.replace(resource, "n.p=\"/\"",
|
||||
"n.p=\"" + getBasePath() + "/\""))
|
||||
.apply();
|
||||
return Response.builder()
|
||||
.setMimeType(MimeType.JS)
|
||||
|
@ -189,6 +191,12 @@ public class ResponseFactory {
|
|||
}
|
||||
}
|
||||
|
||||
private String getBasePath() {
|
||||
String address = addresses.get().getMainAddress()
|
||||
.orElseGet(addresses.get()::getFallbackLocalhostAddress);
|
||||
return addresses.get().getBasePath(address);
|
||||
}
|
||||
|
||||
private String replaceMainAddressPlaceholder(String resource) {
|
||||
String address = addresses.get().getAccessAddress()
|
||||
.orElseGet(addresses.get()::getFallbackLocalhostAddress);
|
||||
|
@ -197,7 +205,10 @@ public class ResponseFactory {
|
|||
|
||||
public Response cssResponse(String fileName) {
|
||||
try {
|
||||
String content = theme.replaceThemeColors(getResource(fileName).asString());
|
||||
String content = UnaryChain.of(getResource(fileName).asString())
|
||||
.chain(theme::replaceThemeColors)
|
||||
.chain(resource -> StringUtils.replace(resource, "/static", getBasePath() + "/static"))
|
||||
.apply();
|
||||
return Response.builder()
|
||||
.setMimeType(MimeType.CSS)
|
||||
.setContent(content)
|
||||
|
@ -459,9 +470,9 @@ public class ResponseFactory {
|
|||
try {
|
||||
return Response.builder()
|
||||
.setMimeType(MimeType.HTML)
|
||||
.setContent(getResource("index.html"))
|
||||
.setContent(pageFactory.reactPage().toHtml())
|
||||
.build();
|
||||
} catch (UncheckedIOException e) {
|
||||
} catch (UncheckedIOException | IOException e) {
|
||||
return forInternalError(e, "Could not read index.html");
|
||||
}
|
||||
}
|
||||
|
|
|
@ -48,25 +48,26 @@ import java.util.Optional;
|
|||
import java.util.UUID;
|
||||
import java.util.stream.Stream;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
/**
|
||||
* @author AuroraLS3
|
||||
*/
|
||||
class ExportTestUtilities {
|
||||
public class ExportTestUtilities {
|
||||
|
||||
private ExportTestUtilities() {
|
||||
/* Static utility method class */
|
||||
}
|
||||
|
||||
static void assertNoLogs(List<LogEntry> logs) {
|
||||
public static void assertNoLogs(List<LogEntry> logs) {
|
||||
List<String> loggedLines = logs.stream()
|
||||
.map(log -> "\n" + log.getLevel().getName() + " " + log.getMessage())
|
||||
.toList();
|
||||
assertTrue(loggedLines.isEmpty(), () -> "Browser console included " + loggedLines.size() + " logs: " + loggedLines);
|
||||
}
|
||||
|
||||
static void assertNoLogsExceptFaviconError(List<LogEntry> logs) {
|
||||
public static void assertNoLogsExceptFaviconError(List<LogEntry> logs) {
|
||||
List<String> loggedLines = logs.stream()
|
||||
.map(log -> "\n" + log.getLevel().getName() + " " + log.getMessage())
|
||||
.filter(log -> !log.contains("favicon.ico") && !log.contains("manifest.json"))
|
||||
|
@ -82,12 +83,15 @@ class ExportTestUtilities {
|
|||
}
|
||||
}
|
||||
|
||||
static List<LogEntry> getLogsAfterRequestToAddress(ChromeDriver driver, String address) {
|
||||
public static List<LogEntry> getLogsAfterRequestToAddress(ChromeDriver driver, String address) {
|
||||
System.out.println("GET: " + address);
|
||||
driver.get(address);
|
||||
|
||||
new WebDriverWait(driver, Duration.of(10, ChronoUnit.SECONDS)).until(
|
||||
webDriver -> ((JavascriptExecutor) webDriver).executeScript("return document.readyState").equals("complete"));
|
||||
|
||||
assertFalse(driver.findElement(By.tagName("body")).getText().contains("Bad Gateway"), "502 Bad Gateway, nginx could not reach Plan");
|
||||
|
||||
Awaitility.await()
|
||||
.atMost(Duration.of(10, ChronoUnit.SECONDS))
|
||||
.until(() -> getElement(driver).map(WebElement::isDisplayed).orElse(false));
|
||||
|
@ -114,7 +118,7 @@ class ExportTestUtilities {
|
|||
database.executeTransaction(new StoreSessionTransaction(session));
|
||||
}
|
||||
|
||||
static List<String> getEndpointsToTest(ServerUUID serverUUID) {
|
||||
public static List<String> getEndpointsToTest(ServerUUID serverUUID) {
|
||||
return Lists.builder(String.class)
|
||||
.add("/")
|
||||
.addAll(ServerPageExporter.getRedirections(serverUUID))
|
||||
|
|
|
@ -0,0 +1,135 @@
|
|||
/*
|
||||
* 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 <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package com.djrapitops.plan.delivery.webserver;
|
||||
|
||||
import com.djrapitops.plan.PlanSystem;
|
||||
import com.djrapitops.plan.gathering.domain.DataMap;
|
||||
import com.djrapitops.plan.gathering.domain.FinishedSession;
|
||||
import com.djrapitops.plan.identification.ServerUUID;
|
||||
import com.djrapitops.plan.settings.config.PlanConfig;
|
||||
import com.djrapitops.plan.settings.config.paths.DisplaySettings;
|
||||
import com.djrapitops.plan.settings.config.paths.PluginSettings;
|
||||
import com.djrapitops.plan.settings.config.paths.WebserverSettings;
|
||||
import com.djrapitops.plan.storage.database.Database;
|
||||
import com.djrapitops.plan.storage.database.transactions.events.PlayerRegisterTransaction;
|
||||
import com.djrapitops.plan.storage.database.transactions.events.StoreSessionTransaction;
|
||||
import com.djrapitops.plan.storage.database.transactions.events.StoreWorldNameTransaction;
|
||||
import com.djrapitops.plan.storage.file.PlanFiles;
|
||||
import extension.FullSystemExtension;
|
||||
import extension.SeleniumExtension;
|
||||
import org.junit.jupiter.api.*;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.openqa.selenium.WebDriver;
|
||||
import org.openqa.selenium.chrome.ChromeDriver;
|
||||
import org.openqa.selenium.logging.LogEntry;
|
||||
import org.testcontainers.containers.GenericContainer;
|
||||
import org.testcontainers.containers.Network;
|
||||
import org.testcontainers.containers.wait.strategy.HttpWaitStrategy;
|
||||
import org.testcontainers.junit.jupiter.Testcontainers;
|
||||
import org.testcontainers.utility.DockerImageName;
|
||||
import utilities.RandomData;
|
||||
import utilities.TestConstants;
|
||||
import utilities.TestResources;
|
||||
|
||||
import java.io.FileInputStream;
|
||||
import java.io.FileNotFoundException;
|
||||
import java.net.URISyntaxException;
|
||||
import java.nio.file.Path;
|
||||
import java.util.Collection;
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
import static com.djrapitops.plan.delivery.export.ExportTestUtilities.*;
|
||||
|
||||
/**
|
||||
* Tests against reverse proxy regression issues when using subdirectory (eg. /plan/...).
|
||||
*
|
||||
* @author AuroraLS3
|
||||
*/
|
||||
@Testcontainers(disabledWithoutDocker = true)
|
||||
@ExtendWith(SeleniumExtension.class)
|
||||
@ExtendWith(FullSystemExtension.class)
|
||||
class ReverseProxyRegressionTest {
|
||||
|
||||
private static final int PLAN_PORT = 9001;
|
||||
|
||||
public static GenericContainer<?> webserver;
|
||||
private static ServerUUID serverUUID;
|
||||
|
||||
@BeforeAll
|
||||
static void setUp(PlanFiles files, PlanConfig config, PlanSystem system) throws URISyntaxException, FileNotFoundException {
|
||||
Network network = Network.newNetwork(); // Define a network so that host.docker.internal resolution works.
|
||||
|
||||
Path nginxConfig = files.getDataDirectory().resolve("nginx.conf");
|
||||
webserver = new GenericContainer<>(DockerImageName.parse("nginx:latest"))
|
||||
.withExposedPorts(80)
|
||||
.withNetwork(network)
|
||||
.withNetworkAliases("foo")
|
||||
.withExtraHost("host.docker.internal", "host-gateway")
|
||||
.withFileSystemBind(nginxConfig.toFile().getAbsolutePath(), "/etc/nginx/conf.d/default.conf")
|
||||
.waitingFor(new HttpWaitStrategy());
|
||||
TestResources.copyResourceToFile(nginxConfig.toFile(), new FileInputStream(TestResources.getTestResourceFile("nginx-reverse-proxy.conf", ReverseProxyRegressionTest.class)));
|
||||
webserver.start();
|
||||
|
||||
config.set(PluginSettings.FRONTEND_BETA, true);
|
||||
config.set(PluginSettings.SERVER_NAME, "TestServer");
|
||||
config.set(WebserverSettings.PORT, PLAN_PORT);
|
||||
config.set(WebserverSettings.LOG_ACCESS_TO_CONSOLE, true);
|
||||
config.set(WebserverSettings.SHOW_ALTERNATIVE_IP, true);
|
||||
config.set(WebserverSettings.ALTERNATIVE_IP, webserver.getHost() + ":" + webserver.getMappedPort(80) + "/plan");
|
||||
// Avoid accidentally DDoS:ing head image service during tests.
|
||||
config.set(DisplaySettings.PLAYER_HEAD_IMG_URL, "data:image/png;base64,AA==");
|
||||
|
||||
system.enable();
|
||||
serverUUID = system.getServerInfo().getServerUUID();
|
||||
savePlayerData(system.getDatabaseSystem().getDatabase(), serverUUID);
|
||||
}
|
||||
|
||||
private static void savePlayerData(Database database, ServerUUID serverUUID) {
|
||||
UUID uuid = TestConstants.PLAYER_ONE_UUID;
|
||||
database.executeTransaction(new PlayerRegisterTransaction(uuid, RandomData::randomTime, TestConstants.PLAYER_ONE_NAME));
|
||||
FinishedSession session = new FinishedSession(uuid, serverUUID, 1000L, 11000L, 500L, new DataMap());
|
||||
database.executeTransaction(new StoreWorldNameTransaction(serverUUID, "world"));
|
||||
database.executeTransaction(new StoreSessionTransaction(session));
|
||||
}
|
||||
|
||||
@AfterAll
|
||||
static void tearDown(PlanSystem system) {
|
||||
system.disable();
|
||||
}
|
||||
|
||||
@AfterEach
|
||||
void clearBrowserConsole(WebDriver driver) {
|
||||
SeleniumExtension.newTab(driver);
|
||||
}
|
||||
|
||||
@TestFactory
|
||||
Collection<DynamicTest> exportedWebpageDoesNotHaveErrors(ChromeDriver driver) {
|
||||
List<String> endpointsToTest = getEndpointsToTest(serverUUID);
|
||||
|
||||
return endpointsToTest.stream().map(
|
||||
endpoint -> DynamicTest.dynamicTest("Reverse-proxied page does not log errors to js console /plan" + endpoint, () -> {
|
||||
String address = "http://" + webserver.getHost() + ":" + webserver.getMappedPort(80) + "/plan"
|
||||
+ (endpoint.startsWith("/") ? endpoint : '/' + endpoint);
|
||||
|
||||
List<LogEntry> logs = getLogsAfterRequestToAddress(driver, address);
|
||||
assertNoLogsExceptFaviconError(logs);
|
||||
})
|
||||
).collect(Collectors.toList());
|
||||
}
|
||||
}
|
|
@ -47,7 +47,7 @@ public class TestResources {
|
|||
assertTrue(toFile.exists(), () -> "Failed to copy resource: '" + resourcePath + "', it was not written");
|
||||
}
|
||||
|
||||
private static void copyResourceToFile(File toFile, InputStream testResource) {
|
||||
public static void copyResourceToFile(File toFile, InputStream testResource) {
|
||||
try (
|
||||
InputStream in = testResource;
|
||||
OutputStream out = Files.newOutputStream(toFile.toPath())
|
||||
|
|
15
Plan/common/src/test/resources/nginx-reverse-proxy.conf
Normal file
15
Plan/common/src/test/resources/nginx-reverse-proxy.conf
Normal file
|
@ -0,0 +1,15 @@
|
|||
server {
|
||||
listen 80;
|
||||
|
||||
location = / {
|
||||
return 200;
|
||||
}
|
||||
|
||||
location ~ /plan/(.*) {
|
||||
resolver 127.0.0.11;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $remote_addr;
|
||||
proxy_pass http://host.docker.internal:9001/$1$is_args$args;
|
||||
}
|
||||
}
|
|
@ -82,7 +82,7 @@ const Lazy = ({children}) => (
|
|||
)
|
||||
|
||||
const getBasename = () => {
|
||||
if (staticSite && baseAddress) {
|
||||
if (baseAddress) {
|
||||
const addressWithoutProtocol = baseAddress
|
||||
.replace("http://", "")
|
||||
.replace("https://", "");
|
||||
|
|
Loading…
Reference in New Issue
Block a user