From 65bf2d0dea8e5b2ca27a53ad668d77b4e2ded44b Mon Sep 17 00:00:00 2001 From: Julien Lancelot Date: Tue, 29 Nov 2016 12:03:58 +0100 Subject: [PATCH] SONAR-8448 Provide a unique HTML page for every url except statics files and WS --- .../java/it/serverSystem/HttpHeadersTest.java | 31 ++- .../test/java/it/serverSystem/LogsTest.java | 4 +- .../platformlevel/PlatformLevel4.java | 2 + .../platformlevel/PlatformLevelSafeMode.java | 3 + .../server/platform/web/WebPagesFilter.java | 98 +++++++++ .../platform/web/WebPagesFilterTest.java | 190 ++++++++++++++++++ .../web/WebPagesFilterTest/index.html | 14 ++ server/sonar-web/public/index.html | 5 +- .../sonar-web/src/main/webapp/WEB-INF/web.xml | 8 + .../java/org/sonarqube/ws/MediaTypes.java | 6 +- 10 files changed, 351 insertions(+), 10 deletions(-) create mode 100644 server/sonar-server/src/main/java/org/sonar/server/platform/web/WebPagesFilter.java create mode 100644 server/sonar-server/src/test/java/org/sonar/server/platform/web/WebPagesFilterTest.java create mode 100644 server/sonar-server/src/test/resources/org/sonar/server/platform/web/WebPagesFilterTest/index.html diff --git a/it/it-tests/src/test/java/it/serverSystem/HttpHeadersTest.java b/it/it-tests/src/test/java/it/serverSystem/HttpHeadersTest.java index 7f25eefddbe..359c53c9c3d 100644 --- a/it/it-tests/src/test/java/it/serverSystem/HttpHeadersTest.java +++ b/it/it-tests/src/test/java/it/serverSystem/HttpHeadersTest.java @@ -22,12 +22,17 @@ package it.serverSystem; import com.google.common.base.Throwables; import com.sonar.orchestrator.Orchestrator; import it.Category4Suite; +import java.io.File; import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Optional; import java.util.concurrent.TimeUnit; import okhttp3.CacheControl; import okhttp3.OkHttpClient; import okhttp3.Request; import okhttp3.Response; +import org.junit.BeforeClass; import org.junit.ClassRule; import org.junit.Test; @@ -38,6 +43,13 @@ public class HttpHeadersTest { @ClassRule public static final Orchestrator orchestrator = Category4Suite.ORCHESTRATOR; + private static String JS_HASH; + + @BeforeClass + public static void setUp() throws Exception { + JS_HASH = getJsHash(); + } + @Test public void verify_headers_of_base_url() throws Exception { Response response = call(orchestrator.getServer().getUrl() + "/"); @@ -78,7 +90,7 @@ public class HttpHeadersTest { @Test public void verify_headers_of_css() throws Exception { - Response response = call(orchestrator.getServer().getUrl() + "/css/sonar.css"); + Response response = call(orchestrator.getServer().getUrl() + "/css/sonar." + JS_HASH + ".css"); verifySecurityHeaders(response); verifyContentType(response, "text/css"); @@ -87,7 +99,7 @@ public class HttpHeadersTest { @Test public void verify_headers_of_js() throws Exception { - Response response = call(orchestrator.getServer().getUrl() + "/js/bundles/app.js"); + Response response = call(orchestrator.getServer().getUrl() + "/js/app." + JS_HASH + ".js"); verifySecurityHeaders(response); verifyContentType(response, "application/javascript"); @@ -135,7 +147,7 @@ public class HttpHeadersTest { * SONAR-8247 */ private static void verifySecurityHeaders(Response httpResponse) { - assertThat(httpResponse.isSuccessful()).isTrue(); + assertThat(httpResponse.isSuccessful()).as("Code is %s", httpResponse.code()).isTrue(); assertThat(httpResponse.headers().get("X-Frame-Options")).isEqualTo("SAMEORIGIN"); assertThat(httpResponse.headers().get("X-XSS-Protection")).isEqualTo("1; mode=block"); assertThat(httpResponse.headers().get("X-Content-Type-Options")).isEqualTo("nosniff"); @@ -159,4 +171,17 @@ public class HttpHeadersTest { throw Throwables.propagate(e); } } + + /** + * Every JS and CSS files contains a hash between the file name and the extension. + */ + private static String getJsHash() throws IOException { + File cssFolder = new File(orchestrator.getServer().getHome(), "web/css"); + Optional cssPath = Files.list(cssFolder.toPath()).map(Path::getFileName).findFirst(); + if (cssPath.isPresent()) { + String fileName = cssPath.get().toFile().getName(); + return fileName.replace("sonar.", "").replace(".css", ""); + } + throw new IllegalStateException("sonar.css hasn't been found"); + } } diff --git a/it/it-tests/src/test/java/it/serverSystem/LogsTest.java b/it/it-tests/src/test/java/it/serverSystem/LogsTest.java index 4252df014ba..7d232031966 100644 --- a/it/it-tests/src/test/java/it/serverSystem/LogsTest.java +++ b/it/it-tests/src/test/java/it/serverSystem/LogsTest.java @@ -64,10 +64,10 @@ public class LogsTest { // log "-" for anonymous sendHttpRequest(ItUtils.newWsClient(orchestrator), PATH); assertThat(accessLogsFile()).isFile().exists(); - verifyLastAccessLogLine("-", PATH, 404); + verifyLastAccessLogLine("-", PATH, 200); sendHttpRequest(ItUtils.newAdminWsClient(orchestrator), PATH); - verifyLastAccessLogLine("admin", PATH, 404); + verifyLastAccessLogLine("admin", PATH, 200); } @Test diff --git a/server/sonar-server/src/main/java/org/sonar/server/platform/platformlevel/PlatformLevel4.java b/server/sonar-server/src/main/java/org/sonar/server/platform/platformlevel/PlatformLevel4.java index 809e02c0643..4064e747a88 100644 --- a/server/sonar-server/src/main/java/org/sonar/server/platform/platformlevel/PlatformLevel4.java +++ b/server/sonar-server/src/main/java/org/sonar/server/platform/platformlevel/PlatformLevel4.java @@ -120,6 +120,7 @@ import org.sonar.server.platform.monitoring.PluginsMonitor; import org.sonar.server.platform.monitoring.SettingsMonitor; import org.sonar.server.platform.monitoring.SonarQubeMonitor; import org.sonar.server.platform.monitoring.SystemMonitor; +import org.sonar.server.platform.web.WebPagesFilter; import org.sonar.server.platform.web.requestid.HttpRequestIdModule; import org.sonar.server.platform.ws.ChangeLogLevelAction; import org.sonar.server.platform.ws.DbMigrationStatusAction; @@ -257,6 +258,7 @@ public class PlatformLevel4 extends PlatformLevel { ServerWs.class, BackendCleanup.class, IndexDefinitions.class, + WebPagesFilter.class, // batch BatchWsModule.class, diff --git a/server/sonar-server/src/main/java/org/sonar/server/platform/platformlevel/PlatformLevelSafeMode.java b/server/sonar-server/src/main/java/org/sonar/server/platform/platformlevel/PlatformLevelSafeMode.java index c2e5af4c7cc..91cf5b83310 100644 --- a/server/sonar-server/src/main/java/org/sonar/server/platform/platformlevel/PlatformLevelSafeMode.java +++ b/server/sonar-server/src/main/java/org/sonar/server/platform/platformlevel/PlatformLevelSafeMode.java @@ -21,6 +21,7 @@ package org.sonar.server.platform.platformlevel; import org.sonar.server.organization.NoopDefaultOrganizationCache; import org.sonar.server.platform.ServerImpl; +import org.sonar.server.platform.web.WebPagesFilter; import org.sonar.server.platform.ws.DbMigrationStatusAction; import org.sonar.server.platform.ws.MigrateDbAction; import org.sonar.server.platform.ws.StatusAction; @@ -38,6 +39,8 @@ public class PlatformLevelSafeMode extends PlatformLevel { protected void configureLevel() { add( ServerImpl.class, + WebPagesFilter.class, + // Server WS StatusAction.class, MigrateDbAction.class, diff --git a/server/sonar-server/src/main/java/org/sonar/server/platform/web/WebPagesFilter.java b/server/sonar-server/src/main/java/org/sonar/server/platform/web/WebPagesFilter.java new file mode 100644 index 00000000000..53e69d4759a --- /dev/null +++ b/server/sonar-server/src/main/java/org/sonar/server/platform/web/WebPagesFilter.java @@ -0,0 +1,98 @@ +/* + * SonarQube + * Copyright (C) 2009-2016 SonarSource SA + * mailto:contact AT sonarsource DOT com + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 3 of the License, or (at your option) any later version. + * + * This program 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 this program; if not, write to the Free Software Foundation, + * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + */ + +package org.sonar.server.platform.web; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import javax.servlet.Filter; +import javax.servlet.FilterChain; +import javax.servlet.FilterConfig; +import javax.servlet.ServletContext; +import javax.servlet.ServletException; +import javax.servlet.ServletRequest; +import javax.servlet.ServletResponse; +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; +import org.apache.commons.io.IOUtils; +import org.sonar.api.web.ServletFilter; + +import static java.util.Locale.ENGLISH; +import static java.util.Objects.requireNonNull; +import static org.apache.commons.codec.Charsets.UTF_8; +import static org.apache.commons.io.IOUtils.write; +import static org.sonar.api.web.ServletFilter.UrlPattern.Builder.staticResourcePatterns; +import static org.sonarqube.ws.MediaTypes.HTML; + +/** + * This filter provide the HTML file that will be used to display every web pages. + * The same file should be provided for any URLs except WS and static resources. + */ +public class WebPagesFilter implements Filter { + + private static final String CACHE_CONTROL_HEADER = "Cache-Control"; + private static final String CACHE_CONTROL_VALUE = "no-cache, no-store, must-revalidate"; + + private static final String CONTEXT_PLACEHOLDER = "%WEB_CONTEXT%"; + + private static final ServletFilter.UrlPattern URL_PATTERN = ServletFilter.UrlPattern + .builder() + .excludes(staticResourcePatterns()) + // These exclusions won't be needed anymore when this filter will be last filter (no more rails filter) + .excludes("/api/*", "/batch/*") + .build(); + + private String indexDotHtml; + + @Override + public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException { + HttpServletRequest httpServletRequest = (HttpServletRequest) request; + HttpServletResponse httpServletResponse = (HttpServletResponse) response; + String path = httpServletRequest.getRequestURI().replaceFirst(httpServletRequest.getContextPath(), ""); + if (!URL_PATTERN.matches(path)) { + chain.doFilter(request, response); + return; + } + httpServletResponse.setContentType(HTML); + httpServletResponse.setCharacterEncoding(UTF_8.name().toLowerCase(ENGLISH)); + httpServletResponse.setHeader(CACHE_CONTROL_HEADER, CACHE_CONTROL_VALUE); + write(indexDotHtml, httpServletResponse.getOutputStream()); + } + + @Override + public void init(FilterConfig filterConfig) throws ServletException { + String context = filterConfig.getServletContext().getContextPath(); + String indexFile = readIndexFile(filterConfig.getServletContext()); + this.indexDotHtml = indexFile.replaceAll(CONTEXT_PLACEHOLDER, context); + } + + private static String readIndexFile(ServletContext servletContext) { + try { + return IOUtils.toString(requireNonNull(servletContext.getResource("/index.html")), StandardCharsets.UTF_8); + } catch (Exception e) { + throw new IllegalStateException("Fail to provide index file", e); + } + } + + @Override + public void destroy() { + // Nothing to do + } +} diff --git a/server/sonar-server/src/test/java/org/sonar/server/platform/web/WebPagesFilterTest.java b/server/sonar-server/src/test/java/org/sonar/server/platform/web/WebPagesFilterTest.java new file mode 100644 index 00000000000..d20a95a0b68 --- /dev/null +++ b/server/sonar-server/src/test/java/org/sonar/server/platform/web/WebPagesFilterTest.java @@ -0,0 +1,190 @@ +/* + * SonarQube + * Copyright (C) 2009-2016 SonarSource SA + * mailto:contact AT sonarsource DOT com + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 3 of the License, or (at your option) any later version. + * + * This program 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 this program; if not, write to the Free Software Foundation, + * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + */ + +package org.sonar.server.platform.web; + +import java.io.IOException; +import java.net.MalformedURLException; +import javax.servlet.FilterChain; +import javax.servlet.FilterConfig; +import javax.servlet.ServletContext; +import javax.servlet.ServletOutputStream; +import javax.servlet.WriteListener; +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; +import org.junit.Before; +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.ExpectedException; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Matchers.anyString; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.reset; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoMoreInteractions; +import static org.mockito.Mockito.verifyZeroInteractions; +import static org.mockito.Mockito.when; + +public class WebPagesFilterTest { + + @Rule + public ExpectedException expectedException = ExpectedException.none(); + + private HttpServletRequest request = mock(HttpServletRequest.class); + private HttpServletResponse response = mock(HttpServletResponse.class); + private FilterChain chain = mock(FilterChain.class); + private ServletContext servletContext = mock(ServletContext.class); + private FilterConfig filterConfig = mock(FilterConfig.class); + private StringOutputStream outputStream = new StringOutputStream(); + + private WebPagesFilter underTest = new WebPagesFilter(); + + @Before + public void setUp() throws Exception { + when(filterConfig.getServletContext()).thenReturn(servletContext); + when(response.getOutputStream()).thenReturn(outputStream); + } + + @Test + public void verify_paths() throws Exception { + mockIndexFile(); + verifyPathIsHandled("/"); + verifyPathIsHandled("/issues"); + verifyPathIsHandled("/foo"); + verifyPthIsIgnored("/api/issues/search"); + verifyPthIsIgnored("/batch/index"); + } + + @Test + public void return_index_file_content() throws Exception { + mockIndexFile(); + mockPath("/foo", ""); + underTest.init(filterConfig); + underTest.doFilter(request, response, chain); + + assertThat(outputStream.toString()).contains(""); + verify(response).setContentType("text/html"); + verify(response).setCharacterEncoding("utf-8"); + verify(response).setHeader("Cache-Control", "no-cache, no-store, must-revalidate"); + verify(response).getOutputStream(); + verifyNoMoreInteractions(response); + } + + @Test + public void return_index_file_content_with_default_web_context() throws Exception { + mockIndexFile(); + mockPath("/foo", ""); + underTest.init(filterConfig); + underTest.doFilter(request, response, chain); + + assertThat(outputStream.toString()).contains("href=\"/sonar.css\""); + assertThat(outputStream.toString()).contains(""); + assertThat(outputStream.toString()).doesNotContain("%WEB_CONTEXT%"); + } + + @Test + public void return_index_file_content_with_web_context() throws Exception { + mockIndexFile(); + mockPath("/foo", "/web"); + underTest.init(filterConfig); + underTest.doFilter(request, response, chain); + + assertThat(outputStream.toString()).contains("href=\"/web/sonar.css\""); + assertThat(outputStream.toString()).contains(""); + assertThat(outputStream.toString()).doesNotContain("%WEB_CONTEXT%"); + } + + @Test + public void fail_when_index_is_not_found() throws Exception { + mockPath("/foo", ""); + when(servletContext.getResource("/index.html")).thenReturn(null); + + expectedException.expect(IllegalStateException.class); + underTest.init(filterConfig); + } + + private void mockIndexFile() throws MalformedURLException { + when(servletContext.getResource("/index.html")).thenReturn(getClass().getResource("WebPagesFilterTest/index.html")); + } + + private void mockPath(String path, String context) throws MalformedURLException { + when(request.getRequestURI()).thenReturn(path); + when(request.getContextPath()).thenReturn(context); + when(servletContext.getContextPath()).thenReturn(context); + } + + private void verifyPathIsHandled(String path) throws Exception { + mockPath(path, ""); + underTest.init(filterConfig); + + underTest.doFilter(request, response, chain); + + verify(response).getOutputStream(); + verify(response).setContentType(anyString()); + reset(response); + when(response.getOutputStream()).thenReturn(outputStream); + } + + private void verifyPthIsIgnored(String path) throws Exception { + mockPath(path, ""); + underTest.init(filterConfig); + + underTest.doFilter(request, response, chain); + + verifyZeroInteractions(response); + reset(response); + when(response.getOutputStream()).thenReturn(outputStream); + } + + class StringOutputStream extends ServletOutputStream { + private StringBuffer buf = new StringBuffer(); + + StringOutputStream() { + } + + @Override + public boolean isReady() { + return false; + } + + @Override + public void setWriteListener(WriteListener listener) { + + } + + public void write(byte[] b) throws IOException { + this.buf.append(new String(b)); + } + + public void write(byte[] b, int off, int len) throws IOException { + this.buf.append(new String(b, off, len)); + } + + public void write(int b) throws IOException { + byte[] bytes = new byte[] {(byte) b}; + this.buf.append(new String(bytes)); + } + + public String toString() { + return this.buf.toString(); + } + } +} diff --git a/server/sonar-server/src/test/resources/org/sonar/server/platform/web/WebPagesFilterTest/index.html b/server/sonar-server/src/test/resources/org/sonar/server/platform/web/WebPagesFilterTest/index.html new file mode 100644 index 00000000000..379f83f53c7 --- /dev/null +++ b/server/sonar-server/src/test/resources/org/sonar/server/platform/web/WebPagesFilterTest/index.html @@ -0,0 +1,14 @@ + + + + + + + SonarQube + + +
+ + + + diff --git a/server/sonar-web/public/index.html b/server/sonar-web/public/index.html index 0864aeb17d2..cf8fd6ce5a4 100644 --- a/server/sonar-web/public/index.html +++ b/server/sonar-web/public/index.html @@ -1,7 +1,8 @@ - + + <% for (var css in htmlWebpackPlugin.files.css) { %> @@ -15,4 +16,4 @@ <% } %> - \ No newline at end of file + diff --git a/server/sonar-web/src/main/webapp/WEB-INF/web.xml b/server/sonar-web/src/main/webapp/WEB-INF/web.xml index 865206d2030..5786dff2edb 100644 --- a/server/sonar-web/src/main/webapp/WEB-INF/web.xml +++ b/server/sonar-web/src/main/webapp/WEB-INF/web.xml @@ -64,6 +64,10 @@ RequestUidFilter org.sonar.server.platform.web.requestid.RequestIdFilter + + WebPagesFilter + org.sonar.server.platform.web.WebPagesFilter + @@ -90,6 +94,10 @@ ServletFilters /* + + WebPagesFilter + /* + RackFilter /* diff --git a/sonar-ws/src/main/java/org/sonarqube/ws/MediaTypes.java b/sonar-ws/src/main/java/org/sonarqube/ws/MediaTypes.java index d7677c4ea2b..f91ff21a0f8 100644 --- a/sonar-ws/src/main/java/org/sonarqube/ws/MediaTypes.java +++ b/sonar-ws/src/main/java/org/sonarqube/ws/MediaTypes.java @@ -21,10 +21,9 @@ package org.sonarqube.ws; import com.google.common.base.Strings; import com.google.common.collect.ImmutableMap; -import org.apache.commons.io.FilenameUtils; - import java.util.Locale; import java.util.Map; +import org.apache.commons.io.FilenameUtils; /** * @since 5.3 @@ -37,6 +36,7 @@ public final class MediaTypes { public static final String PROTOBUF = "application/x-protobuf"; public static final String ZIP = "application/zip"; public static final String JAVASCRIPT = "application/javascript"; + public static final String HTML = "text/html"; public static final String DEFAULT = "application/octet-stream"; private static final Map MAP = new ImmutableMap.Builder() @@ -65,7 +65,7 @@ public final class MediaTypes { .put("csv", "text/csv") .put("properties", "text/plain") .put("rtf", "text/rtf") - .put("html", "text/html") + .put("html", HTML) .put("css", "text/css") .put("tsv", "text/tab-separated-values") .build(); -- 2.39.5