]> source.dussan.org Git - sonarqube.git/commitdiff
SONAR-8448 Provide a unique HTML page for every url except statics files and WS
authorJulien Lancelot <julien.lancelot@sonarsource.com>
Tue, 29 Nov 2016 11:03:58 +0000 (12:03 +0100)
committerStas Vilchik <vilchiks@gmail.com>
Wed, 7 Dec 2016 13:36:18 +0000 (14:36 +0100)
it/it-tests/src/test/java/it/serverSystem/HttpHeadersTest.java
it/it-tests/src/test/java/it/serverSystem/LogsTest.java
server/sonar-server/src/main/java/org/sonar/server/platform/platformlevel/PlatformLevel4.java
server/sonar-server/src/main/java/org/sonar/server/platform/platformlevel/PlatformLevelSafeMode.java
server/sonar-server/src/main/java/org/sonar/server/platform/web/WebPagesFilter.java [new file with mode: 0644]
server/sonar-server/src/test/java/org/sonar/server/platform/web/WebPagesFilterTest.java [new file with mode: 0644]
server/sonar-server/src/test/resources/org/sonar/server/platform/web/WebPagesFilterTest/index.html [new file with mode: 0644]
server/sonar-web/public/index.html
server/sonar-web/src/main/webapp/WEB-INF/web.xml
sonar-ws/src/main/java/org/sonarqube/ws/MediaTypes.java

index 7f25eefddbe13b1287275b5e7effd1e2fa7fbc83..359c53c9c3d3b214339e058145ed3b07cee9ba1c 100644 (file)
@@ -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<Path> 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");
+  }
 }
index 4252df014ba7b347012f326110dbe8c6e410a16a..7d2320319669e0e3471983bf0f6b2a3233f910ec 100644 (file)
@@ -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
index 809e02c0643393bb803be1d06ffbfdaf99aa0f28..4064e747a88223f97cb3f1c867ca740915066017 100644 (file)
@@ -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,
index c2e5af4c7cc1b6e793eb9b194b5c2afd81c194e1..91cf5b83310abc6cdf5e9a6ff3e606159e27ed93 100644 (file)
@@ -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 (file)
index 0000000..53e69d4
--- /dev/null
@@ -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 (file)
index 0000000..d20a95a
--- /dev/null
@@ -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("<head>");
+    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("<script src=\"/sonar.js\"></script>");
+    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("<script src=\"/web/sonar.js\"></script>");
+    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 (file)
index 0000000..379f83f
--- /dev/null
@@ -0,0 +1,14 @@
+<!DOCTYPE html>
+<html lang="en">
+<head>
+  <meta charset="UTF-8">
+  <link href="%WEB_CONTEXT%/favicon.ico" rel="shortcut icon" type="image/x-icon">
+  <link href="%WEB_CONTEXT%/sonar.css" rel="stylesheet">
+  <title>SonarQube</title>
+</head>
+<body>
+<div id="content"></div>
+<script>window.baseUrl = '%WEB_CONTEXT%';</script>
+<script src="%WEB_CONTEXT%/sonar.js"></script>
+</body>
+</html>
index 0864aeb17d2a94859f7f446f54e229df8321f553..cf8fd6ce5a4f4c664c16ed201fae1dec16759e0e 100644 (file)
@@ -1,7 +1,8 @@
 <!DOCTYPE html>
 <html lang="en">
   <head>
-    <meta charset="UTF-8">
+    <meta http-equiv="content-type" content="text/html; charset=UTF-8" charset="UTF-8"/>
+    <meta http-equiv="X-UA-Compatible" content="IE=edge">
     <link href="%WEB_CONTEXT%/favicon.ico" rel="shortcut icon" type="image/x-icon">
     <% for (var css in htmlWebpackPlugin.files.css) { %>
     <link href="%WEB_CONTEXT%/<%= htmlWebpackPlugin.files.css[css] %>" rel="stylesheet">
@@ -15,4 +16,4 @@
     <script src="%WEB_CONTEXT%/<%= htmlWebpackPlugin.files.chunks[chunk].entry %>"></script>
     <% } %>
   </body>
-</html>
\ No newline at end of file
+</html>
index 865206d2030100e3f485d792ba3bcf87b8bdffff..5786dff2edb2bc954d461e067228ce029381784d 100644 (file)
     <filter-name>RequestUidFilter</filter-name>
     <filter-class>org.sonar.server.platform.web.requestid.RequestIdFilter</filter-class>
   </filter>
+  <filter>
+    <filter-name>WebPagesFilter</filter-name>
+    <filter-class>org.sonar.server.platform.web.WebPagesFilter</filter-class>
+  </filter>
 
   <!-- order of execution is important -->
   <filter-mapping>
     <filter-name>ServletFilters</filter-name>
     <url-pattern>/*</url-pattern>
   </filter-mapping>
+  <filter-mapping>
+    <filter-name>WebPagesFilter</filter-name>
+    <url-pattern>/*</url-pattern>
+  </filter-mapping>
   <filter-mapping>
     <filter-name>RackFilter</filter-name>
     <url-pattern>/*</url-pattern>
index d7677c4ea2b6e1c406b3229b5ae7a9d9ba83d369..f91ff21a0f827b804174dfbe8e3f62142ac805b9 100644 (file)
@@ -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<String, String> MAP = new ImmutableMap.Builder<String, String>()
@@ -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();