Browse Source
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-11964pull/554/head
4 changed files with 359 additions and 0 deletions
@ -0,0 +1,225 @@
@@ -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()); |
||||
} |
||||
} |
||||
|
||||
} |
||||
@ -0,0 +1,113 @@
@@ -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")); |
||||
} |
||||
|
||||
} |
||||
@ -0,0 +1,17 @@
@@ -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 |
||||
Loading…
Reference in new issue