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:
Aurora Lahtela 2023-01-06 14:24:18 +02:00
parent 5082f80030
commit aa897fe8de
8 changed files with 214 additions and 27 deletions

View File

@ -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();
}
}

View File

@ -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 "";
}
}
}

View File

@ -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");
}
}

View File

@ -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))

View File

@ -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());
}
}

View File

@ -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())

View 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;
}
}

View File

@ -82,7 +82,7 @@ const Lazy = ({children}) => (
)
const getBasename = () => {
if (staticSite && baseAddress) {
if (baseAddress) {
const addressWithoutProtocol = baseAddress
.replace("http://", "")
.replace("https://", "");