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:
+ *
+ * - modifies links to match the public URL paths that should be exposed to clients, using
+ * configured {@code ResourceResolver} strategies
+ *
- appends a comment in the manifest, containing a Hash (e.g. "# Hash: 9de0f09ed7caf84e885f1f0f11c7e326"),
+ * thus changing the content of the manifest in order to trigger an appcache reload in the browser.
+ *
+ *
+ * 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