]> source.dussan.org Git - sonarqube.git/commitdiff
SONAR-10674 Update cache policy for js, css and images path files
authorJulien Lancelot <julien.lancelot@sonarsource.com>
Wed, 30 May 2018 16:26:46 +0000 (18:26 +0200)
committerSonarTech <sonartech@sonarsource.com>
Wed, 30 May 2018 18:20:48 +0000 (20:20 +0200)
server/sonar-server/src/main/java/org/sonar/server/platform/web/CacheControlFilter.java [new file with mode: 0644]
server/sonar-server/src/test/java/org/sonar/server/platform/web/CacheControlFilterTest.java [new file with mode: 0644]
server/sonar-server/src/test/java/org/sonar/server/platform/web/SecurityServletFilterTest.java
server/sonar-web/public/WEB-INF/web.xml
tests/src/test/java/org/sonarqube/tests/serverSystem/HttpHeadersTest.java

diff --git a/server/sonar-server/src/main/java/org/sonar/server/platform/web/CacheControlFilter.java b/server/sonar-server/src/main/java/org/sonar/server/platform/web/CacheControlFilter.java
new file mode 100644 (file)
index 0000000..b8a238f
--- /dev/null
@@ -0,0 +1,83 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2018 SonarSource SA
+ * mailto:info 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 com.google.common.collect.ImmutableMap;
+import java.io.IOException;
+import java.util.Map;
+import javax.servlet.Filter;
+import javax.servlet.FilterChain;
+import javax.servlet.FilterConfig;
+import javax.servlet.ServletException;
+import javax.servlet.ServletRequest;
+import javax.servlet.ServletResponse;
+import javax.servlet.http.HttpServletRequest;
+import javax.servlet.http.HttpServletResponse;
+
+import static java.lang.String.format;
+
+/**
+ * This servlet filter sets response headers that enable cache control on some static resources
+ */
+public class CacheControlFilter implements Filter {
+
+  private static final String CACHE_CONTROL_HEADER = "Cache-Control";
+
+  /**
+   * Recommended max value for max-age is 1 year
+   * @see <a href="https://stackoverflow.com/questions/7071763/max-value-for-cache-control-header-in-http">stackoverflow thread</a>
+   */
+  private static final int ONE_YEAR_IN_SECONDS = 365 * 24 * 60 * 60;
+
+  private static final int FIVE_MINUTES_IN_SECONDS = 5 * 60 * 60;
+
+  private static final String MAX_AGE_TEMPLATE = "max-age=%d";
+
+  private static final Map<String, Integer> MAX_AGE_BY_PATH = ImmutableMap.of(
+    // These folders contains files that are suffixed with their content hash : the cache should never be invalidated
+    "/js/", ONE_YEAR_IN_SECONDS,
+    "/css/", ONE_YEAR_IN_SECONDS,
+    // This folder contains static resources from plugins : the cache should be set to a small value
+    "/static/", FIVE_MINUTES_IN_SECONDS,
+    "/images/", FIVE_MINUTES_IN_SECONDS);
+
+  @Override
+  public void init(FilterConfig filterConfig) {
+    // nothing
+  }
+
+  @Override
+  public void doFilter(ServletRequest req, ServletResponse resp, FilterChain chain) throws IOException, ServletException {
+    String path = ((HttpServletRequest) req).getRequestURI().replaceFirst(((HttpServletRequest) req).getContextPath(), "");
+
+    MAX_AGE_BY_PATH.entrySet().stream()
+      .filter(m -> path.startsWith(m.getKey()))
+      .map(Map.Entry::getValue)
+      .findFirst()
+      .ifPresent(maxAge -> ((HttpServletResponse) resp).addHeader(CACHE_CONTROL_HEADER, format(MAX_AGE_TEMPLATE, maxAge)));
+
+    chain.doFilter(req, resp);
+  }
+
+  @Override
+  public void destroy() {
+    // nothing
+  }
+}
diff --git a/server/sonar-server/src/test/java/org/sonar/server/platform/web/CacheControlFilterTest.java b/server/sonar-server/src/test/java/org/sonar/server/platform/web/CacheControlFilterTest.java
new file mode 100644 (file)
index 0000000..5f967c6
--- /dev/null
@@ -0,0 +1,112 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2018 SonarSource SA
+ * mailto:info 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 javax.servlet.FilterChain;
+import javax.servlet.http.HttpServletRequest;
+import javax.servlet.http.HttpServletResponse;
+import org.junit.Test;
+
+import static java.lang.String.format;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.verifyZeroInteractions;
+import static org.mockito.Mockito.when;
+
+public class CacheControlFilterTest {
+
+  private HttpServletResponse response = mock(HttpServletResponse.class);
+  private FilterChain chain = mock(FilterChain.class);
+
+  private CacheControlFilter underTest = new CacheControlFilter();
+
+  @Test
+  public void max_age_is_set_to_one_year_on_js() throws Exception {
+    HttpServletRequest request = newRequest("/js/sonar.js");
+
+    underTest.doFilter(request, response, chain);
+
+    verify(response).addHeader("Cache-Control", format("max-age=%s", 31_536_000));
+  }
+
+  @Test
+  public void max_age_is_set_to_one_year_on_css() throws Exception {
+    HttpServletRequest request = newRequest("/css/sonar.css");
+
+    underTest.doFilter(request, response, chain);
+
+    verify(response).addHeader("Cache-Control", format("max-age=%s", 31_536_000));
+  }
+
+  @Test
+  public void max_age_is_set_to_five_minutes_on_images() throws Exception {
+    HttpServletRequest request = newRequest("/images/logo.png");
+
+    underTest.doFilter(request, response, chain);
+
+    verify(response).addHeader("Cache-Control", format("max-age=%s", 18_000));
+  }
+
+  @Test
+  public void max_age_is_set_to_five_minutes_on_static() throws Exception {
+    HttpServletRequest request = newRequest("/static/something");
+
+    underTest.doFilter(request, response, chain);
+
+    verify(response).addHeader("Cache-Control", format("max-age=%s", 18_000));
+  }
+
+  @Test
+  public void max_age_is_set_to_five_minutes_on_css_of_static() throws Exception {
+    HttpServletRequest request = newRequest("/static/css/custom.css");
+
+    underTest.doFilter(request, response, chain);
+
+    verify(response).addHeader("Cache-Control", format("max-age=%s", 18_000));
+  }
+
+  @Test
+  public void does_nothing_on_home() throws Exception {
+    HttpServletRequest request = newRequest("/");
+
+    underTest.doFilter(request, response, chain);
+
+    verifyZeroInteractions(response);
+  }
+
+  @Test
+  public void does_nothing_on_web_service() throws Exception {
+    HttpServletRequest request = newRequest("/api/ping");
+
+    underTest.doFilter(request, response, chain);
+
+    verifyZeroInteractions(response);
+  }
+
+  private static HttpServletRequest newRequest(String path) {
+    HttpServletRequest req = mock(HttpServletRequest.class);
+    when(req.getMethod()).thenReturn("GET");
+    when(req.getRequestURI()).thenReturn(path);
+    when(req.getContextPath()).thenReturn("");
+    return req;
+  }
+
+}
index 4faf044eadbd5974bd5b92f966112ebd42a0de6c..162f4dad76024606b48462036a6c83e795259c38 100644 (file)
@@ -21,7 +21,6 @@ package org.sonar.server.platform.web;
 
 import java.io.IOException;
 import javax.servlet.FilterChain;
-import javax.servlet.FilterConfig;
 import javax.servlet.ServletException;
 import javax.servlet.http.HttpServletRequest;
 import javax.servlet.http.HttpServletResponse;
@@ -84,7 +83,6 @@ public class SecurityServletFilterTest {
 
   @Test
   public void set_security_headers() throws Exception {
-    underTest.init(mock(FilterConfig.class));
     HttpServletRequest request = newRequest("GET", "/");
 
     underTest.doFilter(request, response, chain);
@@ -92,13 +90,10 @@ public class SecurityServletFilterTest {
     verify(response).addHeader("X-Frame-Options", "SAMEORIGIN");
     verify(response).addHeader("X-XSS-Protection", "1; mode=block");
     verify(response).addHeader("X-Content-Type-Options", "nosniff");
-
-    underTest.destroy();
   }
 
   @Test
   public void do_not_set_frame_protection_on_integration_resources() throws Exception {
-    underTest.init(mock(FilterConfig.class));
     HttpServletRequest request = newRequest("GET", "/integration/vsts/index.html");
 
     underTest.doFilter(request, response, chain);
@@ -106,13 +101,10 @@ public class SecurityServletFilterTest {
     verify(response, never()).addHeader(eq("X-Frame-Options"), anyString());
     verify(response).addHeader("X-XSS-Protection", "1; mode=block");
     verify(response).addHeader("X-Content-Type-Options", "nosniff");
-
-    underTest.destroy();
   }
 
   @Test
   public void do_not_set_frame_protection_on_integration_resources_with_context() throws Exception {
-    underTest.init(mock(FilterConfig.class));
     HttpServletRequest request = mock(HttpServletRequest.class);
     when(request.getMethod()).thenReturn("GET");
     when(request.getRequestURI()).thenReturn("/sonarqube/integration/vsts/index.html");
@@ -123,8 +115,6 @@ public class SecurityServletFilterTest {
     verify(response, never()).addHeader(eq("X-Frame-Options"), anyString());
     verify(response).addHeader("X-XSS-Protection", "1; mode=block");
     verify(response).addHeader("X-Content-Type-Options", "nosniff");
-
-    underTest.destroy();
   }
 
   private static HttpServletRequest newRequest(String httpMethod, String path) {
index 0079ac0ed108c66bd0c55d12d015dc0962928653..c06dc4eecc838aa1ad5915d200f39b19e322187d 100644 (file)
     <filter-name>WebPagesFilter</filter-name>
     <filter-class>org.sonar.server.platform.web.WebPagesFilter</filter-class>
   </filter>
+  <filter>
+    <filter-name>CacheControlFilter</filter-name>
+    <filter-class>org.sonar.server.platform.web.CacheControlFilter</filter-class>
+  </filter>
 
   <!-- order of execution is important -->
   <filter-mapping>
     <filter-name>SecurityFilter</filter-name>
     <url-pattern>/*</url-pattern>
   </filter-mapping>
+  <filter-mapping>
+    <filter-name>CacheControlFilter</filter-name>
+    <url-pattern>/*</url-pattern>
+  </filter-mapping>
   <filter-mapping>
     <filter-name>UserSessionFilter</filter-name>
     <url-pattern>/*</url-pattern>
index 5eead374ed423318212ed69af79a6164591f942a..c4f3fe363822f877e9ee345f73361827618b143e 100644 (file)
@@ -36,6 +36,9 @@ import static util.ItUtils.call;
 
 public class HttpHeadersTest {
 
+  private static final int ONE_YEAR_IN_SECONDS = 365 * 24 * 60 * 60;
+  private static final int FIVE_MINUTES_IN_SECONDS = 5 * 60 * 60;
+
   @ClassRule
   public static final Orchestrator orchestrator = Category4Suite.ORCHESTRATOR;
 
@@ -55,6 +58,8 @@ public class HttpHeadersTest {
 
     // SONAR-6964
     assertNoCacheInBrowser(response);
+
+    assertThat(response.cacheControl().maxAgeSeconds()).isEqualTo(-1);
   }
 
   @Test
@@ -64,6 +69,8 @@ public class HttpHeadersTest {
     verifySecurityHeaders(response);
     verifyContentType(response, "application/json");
     assertNoCacheInBrowser(response);
+
+    assertThat(response.cacheControl().maxAgeSeconds()).isEqualTo(-1);
   }
 
   @Test
@@ -73,6 +80,8 @@ public class HttpHeadersTest {
     verifySecurityHeaders(response);
     verifyContentType(response, "image/svg+xml");
     assertCacheInBrowser(response);
+
+    assertThat(response.cacheControl().maxAgeSeconds()).isEqualTo(FIVE_MINUTES_IN_SECONDS);
   }
 
   @Test
@@ -82,6 +91,8 @@ public class HttpHeadersTest {
     verifySecurityHeaders(response);
     verifyContentType(response, "text/css");
     assertCacheInBrowser(response);
+
+    assertThat(response.cacheControl().maxAgeSeconds()).isEqualTo(ONE_YEAR_IN_SECONDS);
   }
 
   @Test
@@ -90,6 +101,9 @@ public class HttpHeadersTest {
 
     verifySecurityHeaders(response);
     verifyContentType(response, "application/javascript");
+    assertCacheInBrowser(response);
+
+    assertThat(response.cacheControl().maxAgeSeconds()).isEqualTo(ONE_YEAR_IN_SECONDS);
   }
 
   @Test
@@ -98,6 +112,9 @@ public class HttpHeadersTest {
 
     verifySecurityHeaders(response);
     verifyContentType(response, "image/jpeg");
+    assertCacheInBrowser(response);
+
+    assertThat(response.cacheControl().maxAgeSeconds()).isEqualTo(FIVE_MINUTES_IN_SECONDS);
   }
 
   @Test
@@ -106,6 +123,9 @@ public class HttpHeadersTest {
 
     verifySecurityHeaders(response);
     verifyContentType(response, "application/javascript");
+    assertCacheInBrowser(response);
+
+    assertThat(response.cacheControl().maxAgeSeconds()).isEqualTo(FIVE_MINUTES_IN_SECONDS);
   }
 
   @Test
@@ -114,6 +134,9 @@ public class HttpHeadersTest {
 
     verifySecurityHeaders(response);
     verifyContentType(response, "text/html");
+    assertCacheInBrowser(response);
+
+    assertThat(response.cacheControl().maxAgeSeconds()).isEqualTo(FIVE_MINUTES_IN_SECONDS);
   }
 
   private static void assertCacheInBrowser(Response httpResponse) {