View Javadoc
1   /*
2    * Copyright 2002-2014 the original author or authors.
3    *
4    * Licensed under the Apache License, Version 2.0 (the "License");
5    * you may not use this file except in compliance with the License.
6    * You may obtain a copy of the License at
7    *
8    *      http://www.apache.org/licenses/LICENSE-2.0
9    *
10   * Unless required by applicable law or agreed to in writing, software
11   * distributed under the License is distributed on an "AS IS" BASIS,
12   * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13   * See the License for the specific language governing permissions and
14   * limitations under the License.
15   */
16  
17  package org.springframework.web.servlet.resource;
18  
19  import java.io.ByteArrayOutputStream;
20  import java.io.IOException;
21  import java.io.StringWriter;
22  import java.nio.charset.Charset;
23  import java.util.Arrays;
24  import java.util.HashMap;
25  import java.util.Map;
26  import java.util.Scanner;
27  import javax.servlet.http.HttpServletRequest;
28  
29  import org.apache.commons.logging.Log;
30  import org.apache.commons.logging.LogFactory;
31  
32  import org.springframework.core.io.Resource;
33  import org.springframework.util.DigestUtils;
34  import org.springframework.util.FileCopyUtils;
35  import org.springframework.util.StringUtils;
36  
37  /**
38   * A {@link ResourceTransformer} implementation that helps handling resources
39   * within HTML5 AppCache manifests for HTML5 offline applications.
40   *
41   * <p>This transformer:
42   * <ul>
43   * <li>modifies links to match the public URL paths that should be exposed to clients,
44   * using configured {@code ResourceResolver} strategies
45   * <li>appends a comment in the manifest, containing a Hash (e.g. "# Hash: 9de0f09ed7caf84e885f1f0f11c7e326"),
46   * thus changing the content of the manifest in order to trigger an appcache reload in the browser.
47   * </ul>
48   *
49   * All files that have the ".manifest" file extension, or the extension given in the constructor,
50   * will be transformed by this class.
51   *
52   * <p>This hash is computed using the content of the appcache manifest and the content of the linked resources;
53   * so changing a resource linked in the manifest or the manifest itself should invalidate the browser cache.
54   *
55   * @author Brian Clozel
56   * @since 4.1
57   * @see <a href="http://www.whatwg.org/specs/web-apps/current-work/multipage/offline.html#offline">HTML5 offline applications spec</a>
58   */
59  public class AppCacheManifestTransformer extends ResourceTransformerSupport {
60  
61  	private static final String MANIFEST_HEADER = "CACHE MANIFEST";
62  
63  	private static final Charset DEFAULT_CHARSET = Charset.forName("UTF-8");
64  
65  	private static final Log logger = LogFactory.getLog(AppCacheManifestTransformer.class);
66  
67  
68  	private final Map<String, SectionTransformer> sectionTransformers = new HashMap<String, SectionTransformer>();
69  
70  	private final String fileExtension;
71  
72  
73  	/**
74  	 * Create an AppCacheResourceTransformer that transforms files with extension ".manifest".
75  	 */
76  	public AppCacheManifestTransformer() {
77  		this("manifest");
78  	}
79  
80  	/**
81  	 * Create an AppCacheResourceTransformer that transforms files with the extension
82  	 * given as a parameter.
83  	 */
84  	public AppCacheManifestTransformer(String fileExtension) {
85  		this.fileExtension = fileExtension;
86  
87  		SectionTransformer noOpSection = new NoOpSection();
88  		this.sectionTransformers.put(MANIFEST_HEADER, noOpSection);
89  		this.sectionTransformers.put("NETWORK:", noOpSection);
90  		this.sectionTransformers.put("FALLBACK:", noOpSection);
91  		this.sectionTransformers.put("CACHE:", new CacheSection());
92  	}
93  
94  
95  	@Override
96  	public Resource transform(HttpServletRequest request, Resource resource, ResourceTransformerChain transformerChain)
97  			throws IOException {
98  
99  		resource = transformerChain.transform(request, resource);
100 
101 		String filename = resource.getFilename();
102 		if (!this.fileExtension.equals(StringUtils.getFilenameExtension(filename))) {
103 			return resource;
104 		}
105 
106 		byte[] bytes = FileCopyUtils.copyToByteArray(resource.getInputStream());
107 		String content = new String(bytes, DEFAULT_CHARSET);
108 
109 		if (!content.startsWith(MANIFEST_HEADER)) {
110 			if (logger.isTraceEnabled()) {
111 				logger.trace("AppCache manifest does not start with 'CACHE MANIFEST', skipping: " + resource);
112 			}
113 			return resource;
114 		}
115 
116 		if (logger.isTraceEnabled()) {
117 			logger.trace("Transforming resource: " + resource);
118 		}
119 
120 		StringWriter contentWriter = new StringWriter();
121 		HashBuilder hashBuilder = new HashBuilder(content.length());
122 
123 		Scanner scanner = new Scanner(content);
124 		SectionTransformer currentTransformer = this.sectionTransformers.get(MANIFEST_HEADER);
125 		while (scanner.hasNextLine()) {
126 			String line = scanner.nextLine();
127 			if (this.sectionTransformers.containsKey(line.trim())) {
128 				currentTransformer = this.sectionTransformers.get(line.trim());
129 				contentWriter.write(line + "\n");
130 				hashBuilder.appendString(line);
131 			}
132 			else {
133 				contentWriter.write(
134 						currentTransformer.transform(line, hashBuilder, resource, transformerChain, request)  + "\n");
135 			}
136 		}
137 
138 		String hash = hashBuilder.build();
139 		contentWriter.write("\n" + "# Hash: " + hash);
140 		if (logger.isTraceEnabled()) {
141 			logger.trace("AppCache file: [" + resource.getFilename()+ "] Hash: [" + hash + "]");
142 		}
143 
144 		return new TransformedResource(resource, contentWriter.toString().getBytes(DEFAULT_CHARSET));
145 	}
146 
147 
148 	private static interface SectionTransformer {
149 
150 		/**
151 		 * Transforms a line in a section of the manifest.
152 		 * <p>The actual transformation depends on the chosen transformation strategy
153 		 * for the current manifest section (CACHE, NETWORK, FALLBACK, etc).
154 		 */
155 		String transform(String line, HashBuilder builder, Resource resource,
156 				ResourceTransformerChain transformerChain, HttpServletRequest request) throws IOException;
157 	}
158 
159 
160 	private static class NoOpSection implements SectionTransformer {
161 
162 		public String transform(String line, HashBuilder builder, Resource resource,
163 				ResourceTransformerChain transformerChain, HttpServletRequest request) throws IOException {
164 
165 			builder.appendString(line);
166 			return line;
167 		}
168 	}
169 
170 
171 	private class CacheSection implements SectionTransformer {
172 
173 		private final String COMMENT_DIRECTIVE = "#";
174 
175 		@Override
176 		public String transform(String line, HashBuilder builder, Resource resource,
177 				ResourceTransformerChain transformerChain, HttpServletRequest request) throws IOException {
178 
179 			if (isLink(line) && !hasScheme(line)) {
180 				ResourceResolverChain resolverChain = transformerChain.getResolverChain();
181 				Resource appCacheResource = resolverChain.resolveResource(null, line, Arrays.asList(resource));
182 				String path = resolveUrlPath(line, request, resource, transformerChain);
183 				builder.appendResource(appCacheResource);
184 				if (logger.isTraceEnabled()) {
185 					logger.trace("Link modified: " + path + " (original: " + line + ")");
186 				}
187 				return path;
188 			}
189 			builder.appendString(line);
190 			return line;
191 		}
192 
193 		private boolean hasScheme(String link) {
194 			int schemeIndex = link.indexOf(":");
195 			return (link.startsWith("//") || (schemeIndex > 0 && !link.substring(0, schemeIndex).contains("/")));
196 		}
197 
198 		private boolean isLink(String line) {
199 			return (StringUtils.hasText(line) && !line.startsWith(COMMENT_DIRECTIVE));
200 		}
201 	}
202 
203 
204 	private static class HashBuilder {
205 
206 		private final ByteArrayOutputStream baos;
207 
208 		public HashBuilder(int initialSize) {
209 			this.baos = new ByteArrayOutputStream(initialSize);
210 		}
211 
212 		public void appendResource(Resource resource) throws IOException {
213 			byte[] content = FileCopyUtils.copyToByteArray(resource.getInputStream());
214 			this.baos.write(DigestUtils.md5Digest(content));
215 		}
216 
217 		public void appendString(String content) throws IOException {
218 			this.baos.write(content.getBytes(DEFAULT_CHARSET));
219 		}
220 
221 		public String build() {
222 			return DigestUtils.md5DigestAsHex(this.baos.toByteArray());
223 		}
224 	}
225 
226 }