Add AppCacheResourceTransformer

This change adds a new ResourceTransformer that helps handling resources
within HTML5 AppCache manifests for HTML5 offline application.

This transformer:
* modifies links to match the public URL paths
* appends a comment in the manifest, containing a Hash (e.g. "# Hash:
9de0f09ed7caf84e885f1f0f11c7e326")

See http://www.whatwg.org/specs/web-apps/current-work/multipage/offline.html#offline
for more details on HTML5 offline apps and appcache manifests.

Here is a WebConfig example:

  @Override
  public void addResourceHandlers(ResourceHandlerRegistry registry) {

    AppCacheResourceTransformer appCacheTransformer =
          new AppCacheResourceTransformer();

      registry.addResourceHandler("/**")
          .addResourceLocations("classpath:static/")
          .setResourceResolvers(...)
          .setResourceTransformers(..., appCacheTransformer);
  }

Issue: SPR-11964
This commit is contained in:
Brian Clozel 2014-07-10 16:55:25 +02:00
parent 2bd6e24b65
commit f11815c960
4 changed files with 359 additions and 0 deletions

View File

@ -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.
*
* <p>This transformer:
* <ul>
* <li>modifies links to match the public URL paths that should be exposed to clients, using
* configured {@code ResourceResolver} strategies
* <li>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.
* </ul>
*
* 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 <a href="http://www.whatwg.org/specs/web-apps/current-work/multipage/offline.html#offline">HTML5 offline
* applications spec</a>
* @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<String, SectionTransformer> sectionTransformers = new HashMap<String, SectionTransformer>();
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());
}
}
}

View File

@ -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<ResourceResolver> resolvers = new ArrayList<ResourceResolver>();
resolvers.add(versionResourceResolver);
resolvers.add(new PathResourceResolver());
ResourceResolverChain resolverChain = new DefaultResourceResolverChain(resolvers);
List<ResourceTransformer> 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"));
}
}

View File

@ -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

View File

@ -0,0 +1,4 @@
THIS DOES NOT START WITH "CACHE MANIFEST"
CACHE:
bar.css