From daffaa48bde31ed22c3c5547b5e035cf2161a2f8 Mon Sep 17 00:00:00 2001 From: Julien Lancelot Date: Wed, 30 May 2018 18:26:46 +0200 Subject: [PATCH] SONAR-10674 Update cache policy for js, css and images path files --- .../platform/web/CacheControlFilter.java | 83 +++++++++++++ .../platform/web/CacheControlFilterTest.java | 112 ++++++++++++++++++ .../web/SecurityServletFilterTest.java | 10 -- server/sonar-web/public/WEB-INF/web.xml | 8 ++ .../tests/serverSystem/HttpHeadersTest.java | 23 ++++ 5 files changed, 226 insertions(+), 10 deletions(-) create mode 100644 server/sonar-server/src/main/java/org/sonar/server/platform/web/CacheControlFilter.java create mode 100644 server/sonar-server/src/test/java/org/sonar/server/platform/web/CacheControlFilterTest.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 index 00000000000..b8a238f5653 --- /dev/null +++ b/server/sonar-server/src/main/java/org/sonar/server/platform/web/CacheControlFilter.java @@ -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 stackoverflow thread + */ + 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 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 index 00000000000..5f967c642c7 --- /dev/null +++ b/server/sonar-server/src/test/java/org/sonar/server/platform/web/CacheControlFilterTest.java @@ -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; + } + +} diff --git a/server/sonar-server/src/test/java/org/sonar/server/platform/web/SecurityServletFilterTest.java b/server/sonar-server/src/test/java/org/sonar/server/platform/web/SecurityServletFilterTest.java index 4faf044eadb..162f4dad760 100644 --- a/server/sonar-server/src/test/java/org/sonar/server/platform/web/SecurityServletFilterTest.java +++ b/server/sonar-server/src/test/java/org/sonar/server/platform/web/SecurityServletFilterTest.java @@ -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) { diff --git a/server/sonar-web/public/WEB-INF/web.xml b/server/sonar-web/public/WEB-INF/web.xml index 0079ac0ed10..c06dc4eecc8 100644 --- a/server/sonar-web/public/WEB-INF/web.xml +++ b/server/sonar-web/public/WEB-INF/web.xml @@ -44,6 +44,10 @@ WebPagesFilter org.sonar.server.platform.web.WebPagesFilter + + CacheControlFilter + org.sonar.server.platform.web.CacheControlFilter + @@ -66,6 +70,10 @@ SecurityFilter /* + + CacheControlFilter + /* + UserSessionFilter /* diff --git a/tests/src/test/java/org/sonarqube/tests/serverSystem/HttpHeadersTest.java b/tests/src/test/java/org/sonarqube/tests/serverSystem/HttpHeadersTest.java index 5eead374ed4..c4f3fe36382 100644 --- a/tests/src/test/java/org/sonarqube/tests/serverSystem/HttpHeadersTest.java +++ b/tests/src/test/java/org/sonarqube/tests/serverSystem/HttpHeadersTest.java @@ -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) { -- 2.39.5