diff --git a/scm-webapp/src/main/java/sonia/scm/ForwardingPushStateDispatcher.java b/scm-webapp/src/main/java/sonia/scm/ForwardingPushStateDispatcher.java new file mode 100644 index 0000000000..0b80f158f3 --- /dev/null +++ b/scm-webapp/src/main/java/sonia/scm/ForwardingPushStateDispatcher.java @@ -0,0 +1,24 @@ +package sonia.scm; + +import javax.servlet.RequestDispatcher; +import javax.servlet.ServletException; +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; +import java.io.IOException; + +/** + * This dispatcher forwards every request to the index.html of the application. + * + * @since 2.0.0 + */ +public class ForwardingPushStateDispatcher implements PushStateDispatcher { + @Override + public void dispatch(HttpServletRequest request, HttpServletResponse response, String uri) throws IOException { + RequestDispatcher dispatcher = request.getRequestDispatcher("/index.html"); + try { + dispatcher.forward(request, response); + } catch (ServletException e) { + throw new IOException("failed to forward request", e); + } + } +} diff --git a/scm-webapp/src/main/java/sonia/scm/ProxyPushStateDispatcher.java b/scm-webapp/src/main/java/sonia/scm/ProxyPushStateDispatcher.java new file mode 100644 index 0000000000..ce0aadf246 --- /dev/null +++ b/scm-webapp/src/main/java/sonia/scm/ProxyPushStateDispatcher.java @@ -0,0 +1,134 @@ +package sonia.scm; + +import com.google.common.annotations.VisibleForTesting; +import com.google.common.io.ByteStreams; + +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; +import java.io.FileNotFoundException; +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.net.HttpURLConnection; +import java.net.MalformedURLException; +import java.net.URL; +import java.util.Enumeration; +import java.util.List; +import java.util.Map; + +/** + * PushStateDispatcher which delegates the request to a different server. This dispatcher should only be used for + * development and never in production. + * + * @since 2.0.0 + */ +public final class ProxyPushStateDispatcher implements PushStateDispatcher { + + @FunctionalInterface + interface ConnectionFactory { + + HttpURLConnection open(URL url) throws IOException; + + } + + private final String target; + private final ConnectionFactory connectionFactory; + + /** + * Creates a new dispatcher for the given target. The target must be a valid url. + * + * @param target proxy target + */ + public ProxyPushStateDispatcher(String target) { + this(target, ProxyPushStateDispatcher::openConnection); + } + + /** + * This Constructor should only be used for testing. + * + * @param target proxy target + * @param connectionFactory factory for creating an connection from a url + */ + @VisibleForTesting + ProxyPushStateDispatcher(String target, ConnectionFactory connectionFactory) { + this.target = target; + this.connectionFactory = connectionFactory; + } + + @Override + public void dispatch(HttpServletRequest request, HttpServletResponse response, String uri) throws IOException { + try { + proxy(request, response, uri); + } catch (FileNotFoundException ex) { + response.setStatus(HttpServletResponse.SC_NOT_FOUND); + } + } + + private void proxy(HttpServletRequest request, HttpServletResponse response, String uri) throws IOException { + URL url = createProxyUrl(uri); + + HttpURLConnection connection = connectionFactory.open(url); + connection.setRequestMethod(request.getMethod()); + copyRequestHeaders(request, connection); + + if (request.getContentLength() > 0) { + copyRequestBody(request, connection); + } + + int responseCode = connection.getResponseCode(); + response.setStatus(responseCode); + copyResponseHeaders(response, connection); + + appendProxyHeader(response, url); + + copyResponseBody(response, connection); + } + + private void appendProxyHeader(HttpServletResponse response, URL url) { + response.addHeader("X-Forwarded-Port", String.valueOf(url.getPort())); + } + + private void copyResponseBody(HttpServletResponse response, HttpURLConnection connection) throws IOException { + try (InputStream input = connection.getInputStream(); OutputStream output = response.getOutputStream()) { + ByteStreams.copy(input, output); + } + } + + private void copyResponseHeaders(HttpServletResponse response, HttpURLConnection connection) { + Map> headerFields = connection.getHeaderFields(); + for (Map.Entry> entry : headerFields.entrySet()) { + if (entry.getKey() != null && !"Transfer-Encoding".equalsIgnoreCase(entry.getKey())) { + for (String value : entry.getValue()) { + response.addHeader(entry.getKey(), value); + } + } + } + } + + private void copyRequestBody(HttpServletRequest request, HttpURLConnection connection) throws IOException { + connection.setDoOutput(true); + try (InputStream input = request.getInputStream(); OutputStream output = connection.getOutputStream()) { + ByteStreams.copy(input, output); + } + } + + private void copyRequestHeaders(HttpServletRequest request, HttpURLConnection connection) { + Enumeration headers = request.getHeaderNames(); + while (headers.hasMoreElements()) { + String header = headers.nextElement(); + Enumeration values = request.getHeaders(header); + while (values.hasMoreElements()) { + String value = values.nextElement(); + connection.setRequestProperty(header, value); + } + } + } + + private URL createProxyUrl(String uri) throws MalformedURLException { + return new URL(target + uri); + } + + private static HttpURLConnection openConnection(URL url) throws IOException { + return (HttpURLConnection) url.openConnection(); + } +} diff --git a/scm-webapp/src/main/java/sonia/scm/PushStateDispatcher.java b/scm-webapp/src/main/java/sonia/scm/PushStateDispatcher.java new file mode 100644 index 0000000000..c593d5fd67 --- /dev/null +++ b/scm-webapp/src/main/java/sonia/scm/PushStateDispatcher.java @@ -0,0 +1,28 @@ +package sonia.scm; + +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; +import java.io.IOException; + +/** + * The PushStateDispatcher is responsible for dispatching the request, to the main entry point of the ui, if no resource + * could be found for the requested path. This allows us the implementation of a ui which work with "pushstate" of + * html5. + * + * @since 2.0.0 + * @see HTML5 Push State + */ +public interface PushStateDispatcher { + + /** + * Dispatches the request to the main entry point of the ui. + * + * @param request http request + * @param response http response + * @param uri request uri + * + * @throws IOException + */ + void dispatch(HttpServletRequest request, HttpServletResponse response, String uri) throws IOException; + +} diff --git a/scm-webapp/src/main/java/sonia/scm/PushStateDispatcherProvider.java b/scm-webapp/src/main/java/sonia/scm/PushStateDispatcherProvider.java new file mode 100644 index 0000000000..653f7b4bdc --- /dev/null +++ b/scm-webapp/src/main/java/sonia/scm/PushStateDispatcherProvider.java @@ -0,0 +1,28 @@ +package sonia.scm; + +import com.google.common.annotations.VisibleForTesting; +import com.google.common.base.Strings; + +import javax.inject.Provider; + +/** + * Injection Provider for the {@link PushStateDispatcher}. The provider will return a {@link ProxyPushStateDispatcher} + * if the system property {@code PushStateDispatcherProvider#PROPERTY_TARGET} is set to a proxy target url, otherwise + * a {@link ForwardingPushStateDispatcher} is used. + * + * @since 2.0.0 + */ +public class PushStateDispatcherProvider implements Provider { + + @VisibleForTesting + static final String PROPERTY_TARGET = "sonia.scm.ui.proxy"; + + @Override + public PushStateDispatcher get() { + String target = System.getProperty(PROPERTY_TARGET); + if (Strings.isNullOrEmpty(target)) { + return new ForwardingPushStateDispatcher(); + } + return new ProxyPushStateDispatcher(target); + } +} diff --git a/scm-webapp/src/main/java/sonia/scm/ScmServletModule.java b/scm-webapp/src/main/java/sonia/scm/ScmServletModule.java index 1d318ce1c8..7ee89ba16e 100644 --- a/scm-webapp/src/main/java/sonia/scm/ScmServletModule.java +++ b/scm-webapp/src/main/java/sonia/scm/ScmServletModule.java @@ -313,7 +313,7 @@ public class ScmServletModule extends ServletModule // bind events // bind(LastModifiedUpdateListener.class); - + bind(PushStateDispatcher.class).toProvider(PushStateDispatcherProvider.class); } diff --git a/scm-webapp/src/main/java/sonia/scm/WebResourceServlet.java b/scm-webapp/src/main/java/sonia/scm/WebResourceServlet.java index a2f96827e6..b4ce14a0c1 100644 --- a/scm-webapp/src/main/java/sonia/scm/WebResourceServlet.java +++ b/scm-webapp/src/main/java/sonia/scm/WebResourceServlet.java @@ -33,19 +33,21 @@ public class WebResourceServlet extends HttpServlet { * TODO remove old frontend servlets */ @VisibleForTesting - static final String PATTERN = "/(?!api/|index.html|error.html|plugins/resources).+"; + static final String PATTERN = "/(?!api/).*"; private static final Logger LOG = LoggerFactory.getLogger(WebResourceServlet.class); private final UberWebResourceLoader webResourceLoader; + private final PushStateDispatcher pushStateDispatcher; @Inject - public WebResourceServlet(PluginLoader pluginLoader) { + public WebResourceServlet(PluginLoader pluginLoader, PushStateDispatcher dispatcher) { this.webResourceLoader = pluginLoader.getUberWebResourceLoader(); + this.pushStateDispatcher = dispatcher; } @Override - protected void doGet(HttpServletRequest request, HttpServletResponse response) { + protected void doGet(HttpServletRequest request, HttpServletResponse response) throws IOException { String uri = normalizeUri(request); LOG.trace("try to load {}", uri); @@ -53,7 +55,7 @@ public class WebResourceServlet extends HttpServlet { if (url != null) { serveResource(response, url); } else { - handleResourceNotFound(response); + pushStateDispatcher.dispatch(request, response, uri); } } @@ -71,7 +73,4 @@ public class WebResourceServlet extends HttpServlet { } } - private void handleResourceNotFound(HttpServletResponse response) { - response.setStatus(HttpServletResponse.SC_NOT_FOUND); - } } diff --git a/scm-webapp/src/test/java/sonia/scm/ForwardingPushStateDispatcherTest.java b/scm-webapp/src/test/java/sonia/scm/ForwardingPushStateDispatcherTest.java new file mode 100644 index 0000000000..e96464ee98 --- /dev/null +++ b/scm-webapp/src/test/java/sonia/scm/ForwardingPushStateDispatcherTest.java @@ -0,0 +1,51 @@ +package sonia.scm; + +import org.junit.Test; +import org.junit.runner.RunWith; +import org.mockito.Mock; +import org.mockito.junit.MockitoJUnitRunner; + +import javax.servlet.RequestDispatcher; +import javax.servlet.ServletException; +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; + +import java.io.IOException; + +import static org.junit.Assert.*; +import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@RunWith(MockitoJUnitRunner.class) +public class ForwardingPushStateDispatcherTest { + + @Mock + private HttpServletRequest request; + + @Mock + private RequestDispatcher requestDispatcher; + + @Mock + private HttpServletResponse response; + + private ForwardingPushStateDispatcher dispatcher = new ForwardingPushStateDispatcher(); + + @Test + public void testDispatch() throws ServletException, IOException { + when(request.getRequestDispatcher("/index.html")).thenReturn(requestDispatcher); + + dispatcher.dispatch(request, response, "/something"); + + verify(requestDispatcher).forward(request, response); + } + + @Test(expected = IOException.class) + public void testWrapServletException() throws ServletException, IOException { + when(request.getRequestDispatcher("/index.html")).thenReturn(requestDispatcher); + doThrow(ServletException.class).when(requestDispatcher).forward(request, response); + + dispatcher.dispatch(request, response, "/something"); + } + +} diff --git a/scm-webapp/src/test/java/sonia/scm/ProxyPushStateDispatcherTest.java b/scm-webapp/src/test/java/sonia/scm/ProxyPushStateDispatcherTest.java new file mode 100644 index 0000000000..52c13a4d54 --- /dev/null +++ b/scm-webapp/src/test/java/sonia/scm/ProxyPushStateDispatcherTest.java @@ -0,0 +1,139 @@ +package sonia.scm; + +import com.google.common.base.Charsets; +import com.google.common.collect.ImmutableSet; +import com.google.common.collect.Lists; +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.mockito.Mock; +import org.mockito.junit.MockitoJUnitRunner; + +import javax.servlet.ServletInputStream; +import javax.servlet.ServletOutputStream; +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.net.HttpURLConnection; +import java.util.*; + +import static org.junit.Assert.assertEquals; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@RunWith(MockitoJUnitRunner.class) +public class ProxyPushStateDispatcherTest { + + private ProxyPushStateDispatcher dispatcher; + + @Mock + private HttpServletRequest request; + + @Mock + private HttpServletResponse response; + + @Mock + private HttpURLConnection connection; + + @Before + public void setUp() { + dispatcher = new ProxyPushStateDispatcher("http://hitchhiker.com", url -> connection); + } + + @Test + public void testWithGetRequest() throws IOException { + // configure request mock + when(request.getMethod()).thenReturn("GET"); + when(request.getHeaderNames()).thenReturn(toEnum("Content-Type")); + when(request.getHeaders("Content-Type")).thenReturn(toEnum("application/json")); + + // configure proxy url connection mock + when(connection.getInputStream()).thenReturn(new ByteArrayInputStream("hitchhicker".getBytes(Charsets.UTF_8))); + Map> headerFields = new HashMap<>(); + headerFields.put("Content-Type", Lists.newArrayList("application/yaml")); + when(connection.getHeaderFields()).thenReturn(headerFields); + when(connection.getResponseCode()).thenReturn(200); + + // configure response mock + DevServletOutputStream output = new DevServletOutputStream(); + when(response.getOutputStream()).thenReturn(output); + + dispatcher.dispatch(request, response, "/people/trillian"); + + // verify connection + verify(connection).setRequestMethod("GET"); + verify(connection).setRequestProperty("Content-Type", "application/json"); + + // verify response + verify(response).setStatus(200); + verify(response).addHeader("Content-Type", "application/yaml"); + assertEquals("hitchhicker", output.stream.toString()); + } + + @Test + public void testWithPOSTRequest() throws IOException { + // configure request mock + when(request.getMethod()).thenReturn("POST"); + when(request.getHeaderNames()).thenReturn(toEnum()); + when(request.getInputStream()).thenReturn(new DevServletInputStream("hitchhiker")); + when(request.getContentLength()).thenReturn(1); + + // configure proxy url connection mock + when(connection.getInputStream()).thenReturn(new ByteArrayInputStream(new byte[0])); + Map> headerFields = new HashMap<>(); + when(connection.getHeaderFields()).thenReturn(headerFields); + when(connection.getResponseCode()).thenReturn(204); + + // configure response mock + when(response.getOutputStream()).thenReturn(new DevServletOutputStream()); + + ByteArrayOutputStream output = new ByteArrayOutputStream(); + when(connection.getOutputStream()).thenReturn(output); + + dispatcher.dispatch(request, response, "/people/trillian"); + + // verify connection + verify(connection).setRequestMethod("POST"); + assertEquals("hitchhiker", output.toString()); + + // verify response + verify(response).setStatus(204); + } + + private Enumeration toEnum(String... values) { + Set set = ImmutableSet.copyOf(values); + return toEnum(set); + } + + private Enumeration toEnum(Collection collection) { + return new Vector<>(collection).elements(); + } + + private class DevServletInputStream extends ServletInputStream { + + private InputStream inputStream; + + private DevServletInputStream(String content) { + inputStream = new ByteArrayInputStream(content.getBytes(Charsets.UTF_8)); + } + + @Override + public int read() throws IOException { + return inputStream.read(); + } + } + + private class DevServletOutputStream extends ServletOutputStream { + + private ByteArrayOutputStream stream = new ByteArrayOutputStream(); + + @Override + public void write(int b) { + stream.write(b); + } + } + +} diff --git a/scm-webapp/src/test/java/sonia/scm/PushStateDispatcherProviderTest.java b/scm-webapp/src/test/java/sonia/scm/PushStateDispatcherProviderTest.java new file mode 100644 index 0000000000..31e5f7c6dc --- /dev/null +++ b/scm-webapp/src/test/java/sonia/scm/PushStateDispatcherProviderTest.java @@ -0,0 +1,31 @@ +package sonia.scm; + +import org.assertj.core.api.Assertions; +import org.junit.After; +import org.junit.Test; + +import static org.junit.Assert.*; + +public class PushStateDispatcherProviderTest { + + private PushStateDispatcherProvider provider = new PushStateDispatcherProvider(); + + @Test + public void testGetProxyPushStateWithPropertySet() { + System.setProperty(PushStateDispatcherProvider.PROPERTY_TARGET, "http://localhost:9966"); + PushStateDispatcher dispatcher = provider.get(); + Assertions.assertThat(dispatcher).isInstanceOf(ProxyPushStateDispatcher.class); + } + + @Test + public void testGetProxyPushStateWithoutProperty() { + PushStateDispatcher dispatcher = provider.get(); + Assertions.assertThat(dispatcher).isInstanceOf(ForwardingPushStateDispatcher.class); + } + + @After + public void cleanupSystemProperty() { + System.clearProperty(PushStateDispatcherProvider.PROPERTY_TARGET); + } + +} diff --git a/scm-webapp/src/test/java/sonia/scm/WebResourceServletTest.java b/scm-webapp/src/test/java/sonia/scm/WebResourceServletTest.java index af17fda77d..7e42afdf17 100644 --- a/scm-webapp/src/test/java/sonia/scm/WebResourceServletTest.java +++ b/scm-webapp/src/test/java/sonia/scm/WebResourceServletTest.java @@ -40,13 +40,16 @@ public class WebResourceServletTest { @Mock private UberWebResourceLoader webResourceLoader; + @Mock + private PushStateDispatcher pushStateDispatcher; + private WebResourceServlet servlet; @Before public void setUpMocks() { when(pluginLoader.getUberWebResourceLoader()).thenReturn(webResourceLoader); when(request.getContextPath()).thenReturn("/scm"); - servlet = new WebResourceServlet(pluginLoader); + servlet = new WebResourceServlet(pluginLoader, pushStateDispatcher); } @Test @@ -57,17 +60,17 @@ public class WebResourceServletTest { assertFalse("/api/v2/repositories".matches(WebResourceServlet.PATTERN)); // exclude old style ui template servlets - assertFalse("/".matches(WebResourceServlet.PATTERN)); - assertFalse("/index.html".matches(WebResourceServlet.PATTERN)); - assertFalse("/error.html".matches(WebResourceServlet.PATTERN)); - assertFalse("/plugins/resources/js/sonia/scm/hg.config-wizard.js".matches(WebResourceServlet.PATTERN)); + assertTrue("/".matches(WebResourceServlet.PATTERN)); + assertTrue("/index.html".matches(WebResourceServlet.PATTERN)); + assertTrue("/error.html".matches(WebResourceServlet.PATTERN)); + assertTrue("/plugins/resources/js/sonia/scm/hg.config-wizard.js".matches(WebResourceServlet.PATTERN)); } @Test - public void testDoGetWithNonExistingResource() { + public void testDoGetWithNonExistingResource() throws IOException { when(request.getRequestURI()).thenReturn("/scm/awesome.jpg"); servlet.doGet(request, response); - verify(response).setStatus(HttpServletResponse.SC_NOT_FOUND); + verify(pushStateDispatcher).dispatch(request, response, "/awesome.jpg"); }