From f0a6128db37ea67d6a9c8151632eec1196dc3ab9 Mon Sep 17 00:00:00 2001 From: Brian Clozel Date: Tue, 10 Nov 2020 13:40:25 +0100 Subject: [PATCH] Add spring.web.resources.cache.use-last-modified Prior to this commit, packaging a Spring Boot application as a container image with Cloud Native Buildpacks could result in unwanted browser caching behavior, with "Last-Modified" HTTP response headers pointing to dates in the far past. This is due to CNB resetting the last-modified date metadata for static files (for build reproducibility and container layer caching) and Spring static resource handling relying on that information when serving static resources. This commit introduces a new configuration property `spring.web.resources.cache.use-last-modified` that can be used to disable this behavior in Spring if the application is meant to run as a container image built by CNB. The default value for this property remains `true` since this remains the default value in Spring Framework and using that information in other deployment models is a perfectly valid use case. Fixes gh-24099 --- .../boot/autoconfigure/web/WebProperties.java | 14 ++++++ .../reactive/WebFluxAutoConfiguration.java | 1 + .../web/servlet/WebMvcAutoConfiguration.java | 6 ++- .../WebFluxAutoConfigurationTests.java | 13 ++++++ .../servlet/WebMvcAutoConfigurationTests.java | 44 ++++++++++--------- .../docs/asciidoc/spring-boot-features.adoc | 4 +- 6 files changed, 59 insertions(+), 23 deletions(-) diff --git a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/web/WebProperties.java b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/web/WebProperties.java index e3dc6f197677..b1bd0f12d863 100644 --- a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/web/WebProperties.java +++ b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/web/WebProperties.java @@ -361,6 +361,12 @@ public static class Cache { */ private final Cachecontrol cachecontrol = new Cachecontrol(); + /** + * Whether we should use the "lastModified" metadata of the files in HTTP + * caching headers. Enabled by default. + */ + private boolean useLastModified = true; + public Duration getPeriod() { return this.period; } @@ -374,6 +380,14 @@ public Cachecontrol getCachecontrol() { return this.cachecontrol; } + public boolean isUseLastModified() { + return this.useLastModified; + } + + public void setUseLastModified(boolean useLastModified) { + this.useLastModified = useLastModified; + } + private boolean hasBeenCustomized() { return this.customized || getCachecontrol().hasBeenCustomized(); } diff --git a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/web/reactive/WebFluxAutoConfiguration.java b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/web/reactive/WebFluxAutoConfiguration.java index bdd14cb31aae..2be93494b667 100644 --- a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/web/reactive/WebFluxAutoConfiguration.java +++ b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/web/reactive/WebFluxAutoConfiguration.java @@ -205,6 +205,7 @@ private void configureResourceCaching(ResourceHandlerRegistration registration) cacheControl.setMaxAge(cachePeriod); } registration.setCacheControl(cacheControl.toHttpCacheControl()); + registration.setUseLastModified(this.resourceProperties.getCache().isUseLastModified()); } @Override diff --git a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/web/servlet/WebMvcAutoConfiguration.java b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/web/servlet/WebMvcAutoConfiguration.java index c8e39a3013d5..40c8ea4fb7b4 100644 --- a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/web/servlet/WebMvcAutoConfiguration.java +++ b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/web/servlet/WebMvcAutoConfiguration.java @@ -329,13 +329,15 @@ public void addResourceHandlers(ResourceHandlerRegistry registry) { if (!registry.hasMappingForPattern("/webjars/**")) { customizeResourceHandlerRegistration(registry.addResourceHandler("/webjars/**") .addResourceLocations("classpath:/META-INF/resources/webjars/") - .setCachePeriod(getSeconds(cachePeriod)).setCacheControl(cacheControl)); + .setCachePeriod(getSeconds(cachePeriod)).setCacheControl(cacheControl) + .setUseLastModified(this.resourceProperties.getCache().isUseLastModified())); } String staticPathPattern = this.mvcProperties.getStaticPathPattern(); if (!registry.hasMappingForPattern(staticPathPattern)) { customizeResourceHandlerRegistration(registry.addResourceHandler(staticPathPattern) .addResourceLocations(getResourceLocations(this.resourceProperties.getStaticLocations())) - .setCachePeriod(getSeconds(cachePeriod)).setCacheControl(cacheControl)); + .setCachePeriod(getSeconds(cachePeriod)).setCacheControl(cacheControl) + .setUseLastModified(this.resourceProperties.getCache().isUseLastModified())); } } diff --git a/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/web/reactive/WebFluxAutoConfigurationTests.java b/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/web/reactive/WebFluxAutoConfigurationTests.java index f5fa5b249292..51c88e647f8f 100644 --- a/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/web/reactive/WebFluxAutoConfigurationTests.java +++ b/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/web/reactive/WebFluxAutoConfigurationTests.java @@ -449,6 +449,19 @@ void cacheControl(String prefix) { Assertions.setExtractBareNamePropertyMethods(true); } + @Test + void useLastModified() { + this.contextRunner.withPropertyValues("spring.web.resources.cache.use-last-modified=false").run((context) -> { + Map handlerMap = getHandlerMap(context); + assertThat(handlerMap).hasSize(2); + for (Object handler : handlerMap.values()) { + if (handler instanceof ResourceWebHandler) { + assertThat(((ResourceWebHandler) handler).isUseLastModified()).isFalse(); + } + } + }); + } + @Test void customPrinterAndParserShouldBeRegisteredAsConverters() { this.contextRunner.withUserConfiguration(ParserConfiguration.class, PrinterConfiguration.class) diff --git a/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/web/servlet/WebMvcAutoConfigurationTests.java b/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/web/servlet/WebMvcAutoConfigurationTests.java index 560949734928..2ebebee25d22 100644 --- a/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/web/servlet/WebMvcAutoConfigurationTests.java +++ b/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/web/servlet/WebMvcAutoConfigurationTests.java @@ -28,7 +28,6 @@ import java.util.List; import java.util.Locale; import java.util.Map; -import java.util.Map.Entry; import java.util.concurrent.Executor; import java.util.concurrent.TimeUnit; import java.util.function.Consumer; @@ -754,26 +753,25 @@ void httpMessageConverterThatUsesConversionServiceDoesNotCreateACycle() { @ParameterizedTest @ValueSource(strings = { "spring.resources.", "spring.web.resources." }) void cachePeriod(String prefix) { - this.contextRunner.withPropertyValues(prefix + "cache.period:5").run(this::assertCachePeriod); - } - - private void assertCachePeriod(AssertableWebApplicationContext context) { - Map handlerMap = getHandlerMap(context.getBean("resourceHandlerMapping", HandlerMapping.class)); - assertThat(handlerMap).hasSize(2); - for (Entry entry : handlerMap.entrySet()) { - Object handler = entry.getValue(); - if (handler instanceof ResourceHttpRequestHandler) { - assertThat(((ResourceHttpRequestHandler) handler).getCacheSeconds()).isEqualTo(5); - assertThat(((ResourceHttpRequestHandler) handler).getCacheControl()).isNull(); - } - } + this.contextRunner.withPropertyValues(prefix + "cache.period:5").run((context) -> { + assertResourceHttpRequestHandler((context), (handler) -> { + assertThat(handler.getCacheSeconds()).isEqualTo(5); + assertThat(handler.getCacheControl()).isNull(); + }); + }); } @ParameterizedTest @ValueSource(strings = { "spring.resources.", "spring.web.resources." }) void cacheControl(String prefix) { - this.contextRunner.withPropertyValues(prefix + "cache.cachecontrol.max-age:5", - prefix + "cache.cachecontrol.proxy-revalidate:true").run(this::assertCacheControl); + this.contextRunner + .withPropertyValues(prefix + "cache.cachecontrol.max-age:5", + prefix + "cache.cachecontrol.proxy-revalidate:true") + .run((context) -> assertResourceHttpRequestHandler(context, (handler) -> { + assertThat(handler.getCacheSeconds()).isEqualTo(-1); + assertThat(handler.getCacheControl()).usingRecursiveComparison() + .isEqualTo(CacheControl.maxAge(5, TimeUnit.SECONDS).proxyRevalidate()); + })); } @Test @@ -939,14 +937,20 @@ void urlPathHelperDoesNotUseFullPathWithAdditionalUntypedDispatcherServlet() { }); } - private void assertCacheControl(AssertableWebApplicationContext context) { + @Test + void lastModifiedNotUsedIfDisabled() { + this.contextRunner.withPropertyValues("spring.web.resources.cache.use-last-modified=false") + .run((context) -> assertResourceHttpRequestHandler(context, + (handler) -> assertThat(handler.isUseLastModified()).isFalse())); + } + + private void assertResourceHttpRequestHandler(AssertableWebApplicationContext context, + Consumer handlerConsumer) { Map handlerMap = getHandlerMap(context.getBean("resourceHandlerMapping", HandlerMapping.class)); assertThat(handlerMap).hasSize(2); for (Object handler : handlerMap.keySet()) { if (handler instanceof ResourceHttpRequestHandler) { - assertThat(((ResourceHttpRequestHandler) handler).getCacheSeconds()).isEqualTo(-1); - assertThat(((ResourceHttpRequestHandler) handler).getCacheControl()).usingRecursiveComparison() - .isEqualTo(CacheControl.maxAge(5, TimeUnit.SECONDS).proxyRevalidate()); + handlerConsumer.accept((ResourceHttpRequestHandler) handler); } } } diff --git a/spring-boot-project/spring-boot-docs/src/docs/asciidoc/spring-boot-features.adoc b/spring-boot-project/spring-boot-docs/src/docs/asciidoc/spring-boot-features.adoc index 76b6fc11dd46..6b80d2d750ae 100644 --- a/spring-boot-project/spring-boot-docs/src/docs/asciidoc/spring-boot-features.adoc +++ b/spring-boot-project/spring-boot-docs/src/docs/asciidoc/spring-boot-features.adoc @@ -8925,7 +8925,9 @@ This means you can just type a single command and quickly get a sensible image i Refer to the individual plugin documentation on how to use buildpacks with {spring-boot-maven-plugin-docs}#build-image[Maven] and {spring-boot-gradle-plugin-docs}#build-image[Gradle]. - +NOTE: In order to achieve reproducible builds and container image caching, Buildpacks can manipulate the application resources metadata (such as the file "last modified" information). +You should ensure that your application does not rely on that metadata at runtime. +Spring Boot can use that information when serving static resources, but this can be disabled with configprop:spring.web.resources.cache.use-last-modified[] [[boot-features-whats-next]] == What to Read Next