diff --git a/spring-webmvc/src/main/java/org/springframework/web/servlet/resource/AppCacheResourceTransformer.java b/spring-webmvc/src/main/java/org/springframework/web/servlet/resource/AppCacheResourceTransformer.java new file mode 100644 index 0000000000..3b6293e5cf --- /dev/null +++ b/spring-webmvc/src/main/java/org/springframework/web/servlet/resource/AppCacheResourceTransformer.java @@ -0,0 +1,225 @@ +/* + * Copyright 2002-2014 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.springframework.web.servlet.resource; + +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.io.StringWriter; +import java.nio.charset.Charset; +import java.util.Arrays; +import java.util.HashMap; +import java.util.Map; +import java.util.Scanner; + +import javax.servlet.http.HttpServletRequest; + +import org.apache.commons.logging.Log; +import org.apache.commons.logging.LogFactory; + +import org.springframework.core.io.Resource; +import org.springframework.util.DigestUtils; +import org.springframework.util.FileCopyUtils; +import org.springframework.util.StringUtils; + +/** + * A {@link ResourceTransformer} implementation that helps handling resources + * within HTML5 AppCache manifests for HTML5 offline applications. + * + *

This transformer: + *

+ * + * All files that have the ".manifest" file extension, or the extension given in the constructor, will be transformed + * by this class. + * + * This hash is computed using the content of the appcache manifest and the content of the linked resources; so + * changing a resource linked in the manifest or the manifest itself should invalidate browser cache. + * + * @author Brian Clozel + * @see HTML5 offline + * applications spec + * @since 4.1 + */ +public class AppCacheResourceTransformer implements ResourceTransformer { + + private static final String MANIFEST_HEADER = "CACHE MANIFEST"; + + private static final Charset DEFAULT_CHARSET = Charset.forName("UTF-8"); + + private static final Log logger = LogFactory.getLog(AppCacheResourceTransformer.class); + + private final Map sectionTransformers = new HashMap(); + + private final String fileExtension; + + /** + * Create an AppCacheResourceTransformer that transforms files with extension ".manifest" + */ + public AppCacheResourceTransformer() { + this("manifest"); + } + + /** + * Create an AppCacheResourceTransformer that transforms files with the extension + * given as a parameter. + */ + public AppCacheResourceTransformer(String fileExtension) { + this.fileExtension = fileExtension; + + SectionTransformer noOpSection = new NoOpSection(); + this.sectionTransformers.put(MANIFEST_HEADER, noOpSection); + this.sectionTransformers.put("NETWORK:", noOpSection); + this.sectionTransformers.put("FALLBACK:", noOpSection); + this.sectionTransformers.put("CACHE:", new CacheSection()); + } + + @Override + public Resource transform(HttpServletRequest request, Resource resource, ResourceTransformerChain transformerChain) throws IOException { + resource = transformerChain.transform(request, resource); + + String filename = resource.getFilename(); + if (!this.fileExtension.equals(StringUtils.getFilenameExtension(filename))) { + return resource; + } + + byte[] bytes = FileCopyUtils.copyToByteArray(resource.getInputStream()); + String content = new String(bytes, DEFAULT_CHARSET); + + if(!content.startsWith(MANIFEST_HEADER)) { + if (logger.isTraceEnabled()) { + logger.trace("AppCache manifest does not start with 'CACHE MANIFEST', skipping: " + resource); + } + return resource; + } + + if (logger.isTraceEnabled()) { + logger.trace("Transforming resource: " + resource); + } + + StringWriter contentWriter = new StringWriter(); + HashBuilder hashBuilder = new HashBuilder(content.length()); + + Scanner scanner = new Scanner(content); + SectionTransformer currentTransformer = this.sectionTransformers.get(MANIFEST_HEADER); + while(scanner.hasNextLine()) { + String line = scanner.nextLine(); + + if(this.sectionTransformers.containsKey(line.trim())) { + currentTransformer = this.sectionTransformers.get(line.trim()); + contentWriter.write(line + "\n"); + hashBuilder.appendString(line); + } + else { + + + contentWriter.write(currentTransformer.transform(line, hashBuilder, resource, transformerChain) + "\n"); + } + } + + String hash = hashBuilder.build(); + contentWriter.write("\n" + "# Hash: " + hash); + if (logger.isTraceEnabled()) { + logger.trace("AppCache file: [" + resource.getFilename()+ "] Hash: [" + hash + "]"); + } + + return new TransformedResource(resource, contentWriter.toString().getBytes(DEFAULT_CHARSET)); + } + + + private static interface SectionTransformer { + + /** + * Transforms a line in a section of the manifest + * + * The actual transformation depends on the chose transformation strategy + * for the current manifest section (CACHE, NETWORK, FALLBACK, etc). + */ + String transform(String line, HashBuilder builder, Resource resource, + ResourceTransformerChain transformerChain) throws IOException; + } + + private static class NoOpSection implements SectionTransformer { + + public String transform(String line, HashBuilder builder, + Resource resource, ResourceTransformerChain transformerChain) throws IOException { + builder.appendString(line); + return line; + } + } + + private static class CacheSection implements SectionTransformer { + + private final String COMMENT_DIRECTIVE = "#"; + + @Override + public String transform(String line, HashBuilder builder, + Resource resource, ResourceTransformerChain transformerChain) throws IOException { + + if(isLink(line) && !hasScheme(line)) { + + Resource appCacheResource = transformerChain.getResolverChain().resolveResource(null, line, Arrays.asList(resource)); + String path = transformerChain.getResolverChain().resolveUrlPath(line, Arrays.asList(resource)); + + builder.appendResource(appCacheResource); + if (logger.isTraceEnabled()) { + logger.trace("Link modified: " + path + " (original: " + line + ")"); + } + + return path; + } + + builder.appendString(line); + return line; + } + + private boolean hasScheme(String link) { + int schemeIndex = link.indexOf(":"); + return link.startsWith("//") || (schemeIndex > 0 && !link.substring(0, schemeIndex).contains("/")); + } + + private boolean isLink(String line) { + return StringUtils.hasText(line) && !line.startsWith(COMMENT_DIRECTIVE); + } + } + + + private static class HashBuilder { + + private final ByteArrayOutputStream baos; + + private HashBuilder(int initialSize) { + this.baos = new ByteArrayOutputStream(initialSize); + } + + public void appendResource(Resource resource) throws IOException { + byte[] content = FileCopyUtils.copyToByteArray(resource.getInputStream()); + this.baos.write(DigestUtils.md5Digest(content)); + } + + public void appendString(String content) throws IOException { + this.baos.write(content.getBytes(DEFAULT_CHARSET)); + } + + public String build() { + return DigestUtils.md5DigestAsHex(this.baos.toByteArray()); + } + } + +} diff --git a/spring-webmvc/src/test/java/org/springframework/web/servlet/resource/AppCacheResourceTransformerTests.java b/spring-webmvc/src/test/java/org/springframework/web/servlet/resource/AppCacheResourceTransformerTests.java new file mode 100644 index 0000000000..79433ad236 --- /dev/null +++ b/spring-webmvc/src/test/java/org/springframework/web/servlet/resource/AppCacheResourceTransformerTests.java @@ -0,0 +1,113 @@ +/* + * Copyright 2002-2014 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.web.servlet.resource; + +import static org.junit.Assert.*; +import static org.mockito.Mockito.*; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; + +import javax.servlet.http.HttpServletRequest; + +import org.hamcrest.Matchers; +import org.junit.Before; +import org.junit.Test; + +import org.springframework.core.io.ClassPathResource; +import org.springframework.core.io.Resource; +import org.springframework.util.FileCopyUtils; + +/** + * Unit tests for + * {@link org.springframework.web.servlet.resource.AppCacheResourceTransformer}. + * + * @author Brian Clozel + */ +public class AppCacheResourceTransformerTests { + + private AppCacheResourceTransformer transformer; + + private ResourceTransformerChain chain; + + private HttpServletRequest request; + + @Before + public void setup() { + this.transformer = new AppCacheResourceTransformer(); + this.chain = mock(ResourceTransformerChain.class); + this.request = mock(HttpServletRequest.class); + } + + @Test + public void noTransformIfExtensionNoMatch() throws Exception { + Resource resource = mock(Resource.class); + when(resource.getFilename()).thenReturn("foobar.file"); + when(this.chain.transform(this.request, resource)).thenReturn(resource); + + Resource result = this.transformer.transform(this.request, resource, this.chain); + assertEquals(resource, result); + } + + @Test + public void syntaxErrorInManifest() throws Exception { + Resource resource = new ClassPathResource("test/error.manifest", getClass()); + when(this.chain.transform(this.request, resource)).thenReturn(resource); + + Resource result = this.transformer.transform(this.request, resource, this.chain); + assertEquals(resource, result); + } + + @Test + public void transformManifest() throws Exception { + + VersionResourceResolver versionResourceResolver = new VersionResourceResolver(); + versionResourceResolver + .setVersionStrategyMap(Collections.singletonMap("/**", new ContentBasedVersionStrategy())); + + List resolvers = new ArrayList(); + resolvers.add(versionResourceResolver); + resolvers.add(new PathResourceResolver()); + ResourceResolverChain resolverChain = new DefaultResourceResolverChain(resolvers); + + List transformers = new ArrayList<>(); + transformers.add(new CssLinkResourceTransformer()); + this.chain = new DefaultResourceTransformerChain(resolverChain, transformers); + + Resource resource = new ClassPathResource("test/appcache.manifest", getClass()); + Resource result = this.transformer.transform(this.request, resource, this.chain); + byte[] bytes = FileCopyUtils.copyToByteArray(result.getInputStream()); + String content = new String(bytes, "UTF-8"); + + assertThat("should rewrite resource links", content, + Matchers.containsString("foo-e36d2e05253c6c7085a91522ce43a0b4.css")); + assertThat("should rewrite resource links", content, + Matchers.containsString("bar-11e16cf79faee7ac698c805cf28248d2.css")); + assertThat("should rewrite resource links", content, + Matchers.containsString("js/bar-bd508c62235b832d960298ca6c0b7645.js")); + + assertThat("should not rewrite external resources", content, + Matchers.containsString("//example.org/style.css")); + assertThat("should not rewrite external resources", content, + Matchers.containsString("http://example.org/image.png")); + + assertThat("should generate fingerprint", content, + Matchers.containsString("# Hash: 4bf0338bcbeb0a5b3a4ec9ed8864107d")); + } + +} diff --git a/spring-webmvc/src/test/resources/org/springframework/web/servlet/resource/test/appcache.manifest b/spring-webmvc/src/test/resources/org/springframework/web/servlet/resource/test/appcache.manifest new file mode 100644 index 0000000000..76e2f32a98 --- /dev/null +++ b/spring-webmvc/src/test/resources/org/springframework/web/servlet/resource/test/appcache.manifest @@ -0,0 +1,17 @@ +CACHE MANIFEST + +# this is a comment +CACHE: +bar.css +foo.css +//example.org/style.css + +NETWORK: +* + +CACHE: +js/bar.js +http://example.org/image.png + +FALLBACK: +/main /static.html \ No newline at end of file diff --git a/spring-webmvc/src/test/resources/org/springframework/web/servlet/resource/test/error.manifest b/spring-webmvc/src/test/resources/org/springframework/web/servlet/resource/test/error.manifest new file mode 100644 index 0000000000..3746fec402 --- /dev/null +++ b/spring-webmvc/src/test/resources/org/springframework/web/servlet/resource/test/error.manifest @@ -0,0 +1,4 @@ +THIS DOES NOT START WITH "CACHE MANIFEST" + +CACHE: +bar.css \ No newline at end of file