001/* 002 * Copyright 2002-2017 the original author or authors. 003 * 004 * Licensed under the Apache License, Version 2.0 (the "License"); 005 * you may not use this file except in compliance with the License. 006 * You may obtain a copy of the License at 007 * 008 * https://www.apache.org/licenses/LICENSE-2.0 009 * 010 * Unless required by applicable law or agreed to in writing, software 011 * distributed under the License is distributed on an "AS IS" BASIS, 012 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 013 * See the License for the specific language governing permissions and 014 * limitations under the License. 015 */ 016 017package org.springframework.web.servlet.resource; 018 019import java.io.ByteArrayOutputStream; 020import java.io.IOException; 021import java.io.StringWriter; 022import java.nio.charset.Charset; 023import java.util.Collections; 024import java.util.HashMap; 025import java.util.Map; 026import java.util.Scanner; 027import javax.servlet.http.HttpServletRequest; 028 029import org.apache.commons.logging.Log; 030import org.apache.commons.logging.LogFactory; 031 032import org.springframework.core.io.Resource; 033import org.springframework.util.DigestUtils; 034import org.springframework.util.FileCopyUtils; 035import org.springframework.util.StringUtils; 036 037/** 038 * A {@link ResourceTransformer} implementation that helps handling resources 039 * within HTML5 AppCache manifests for HTML5 offline applications. 040 * 041 * <p>This transformer: 042 * <ul> 043 * <li>modifies links to match the public URL paths that should be exposed to clients, 044 * using configured {@code ResourceResolver} strategies 045 * <li>appends a comment in the manifest, containing a Hash (e.g. "# Hash: 9de0f09ed7caf84e885f1f0f11c7e326"), 046 * thus changing the content of the manifest in order to trigger an appcache reload in the browser. 047 * </ul> 048 * 049 * All files that have the ".manifest" file extension, or the extension given in the constructor, 050 * will be transformed by this class. 051 * 052 * <p>This hash is computed using the content of the appcache manifest and the content of the linked resources; 053 * so changing a resource linked in the manifest or the manifest itself should invalidate the browser cache. 054 * 055 * @author Brian Clozel 056 * @since 4.1 057 * @see <a href="https://www.whatwg.org/specs/web-apps/current-work/multipage/offline.html#offline">HTML5 offline applications spec</a> 058 */ 059public class AppCacheManifestTransformer extends ResourceTransformerSupport { 060 061 private static final String MANIFEST_HEADER = "CACHE MANIFEST"; 062 063 private static final Charset DEFAULT_CHARSET = Charset.forName("UTF-8"); 064 065 private static final Log logger = LogFactory.getLog(AppCacheManifestTransformer.class); 066 067 068 private final Map<String, SectionTransformer> sectionTransformers = new HashMap<String, SectionTransformer>(); 069 070 private final String fileExtension; 071 072 073 /** 074 * Create an AppCacheResourceTransformer that transforms files with extension ".manifest". 075 */ 076 public AppCacheManifestTransformer() { 077 this("manifest"); 078 } 079 080 /** 081 * Create an AppCacheResourceTransformer that transforms files with the extension 082 * given as a parameter. 083 */ 084 public AppCacheManifestTransformer(String fileExtension) { 085 this.fileExtension = fileExtension; 086 087 SectionTransformer noOpSection = new NoOpSection(); 088 this.sectionTransformers.put(MANIFEST_HEADER, noOpSection); 089 this.sectionTransformers.put("NETWORK:", noOpSection); 090 this.sectionTransformers.put("FALLBACK:", noOpSection); 091 this.sectionTransformers.put("CACHE:", new CacheSection()); 092 } 093 094 095 @Override 096 public Resource transform(HttpServletRequest request, Resource resource, ResourceTransformerChain transformerChain) 097 throws IOException { 098 099 resource = transformerChain.transform(request, resource); 100 if (!this.fileExtension.equals(StringUtils.getFilenameExtension(resource.getFilename()))) { 101 return resource; 102 } 103 104 byte[] bytes = FileCopyUtils.copyToByteArray(resource.getInputStream()); 105 String content = new String(bytes, DEFAULT_CHARSET); 106 107 if (!content.startsWith(MANIFEST_HEADER)) { 108 if (logger.isTraceEnabled()) { 109 logger.trace("AppCache manifest does not start with 'CACHE MANIFEST', skipping: " + resource); 110 } 111 return resource; 112 } 113 114 if (logger.isTraceEnabled()) { 115 logger.trace("Transforming resource: " + resource); 116 } 117 118 StringWriter contentWriter = new StringWriter(); 119 HashBuilder hashBuilder = new HashBuilder(content.length()); 120 121 Scanner scanner = new Scanner(content); 122 SectionTransformer currentTransformer = this.sectionTransformers.get(MANIFEST_HEADER); 123 while (scanner.hasNextLine()) { 124 String line = scanner.nextLine(); 125 if (this.sectionTransformers.containsKey(line.trim())) { 126 currentTransformer = this.sectionTransformers.get(line.trim()); 127 contentWriter.write(line + "\n"); 128 hashBuilder.appendString(line); 129 } 130 else { 131 contentWriter.write( 132 currentTransformer.transform(line, hashBuilder, resource, transformerChain, request) + "\n"); 133 } 134 } 135 136 String hash = hashBuilder.build(); 137 contentWriter.write("\n" + "# Hash: " + hash); 138 if (logger.isTraceEnabled()) { 139 logger.trace("AppCache file: [" + resource.getFilename()+ "] hash: [" + hash + "]"); 140 } 141 142 return new TransformedResource(resource, contentWriter.toString().getBytes(DEFAULT_CHARSET)); 143 } 144 145 146 private static interface SectionTransformer { 147 148 /** 149 * Transforms a line in a section of the manifest. 150 * <p>The actual transformation depends on the chosen transformation strategy 151 * for the current manifest section (CACHE, NETWORK, FALLBACK, etc). 152 */ 153 String transform(String line, HashBuilder builder, Resource resource, 154 ResourceTransformerChain transformerChain, HttpServletRequest request) throws IOException; 155 } 156 157 158 private static class NoOpSection implements SectionTransformer { 159 160 public String transform(String line, HashBuilder builder, Resource resource, 161 ResourceTransformerChain transformerChain, HttpServletRequest request) throws IOException { 162 163 builder.appendString(line); 164 return line; 165 } 166 } 167 168 169 private class CacheSection implements SectionTransformer { 170 171 private static final String COMMENT_DIRECTIVE = "#"; 172 173 @Override 174 public String transform(String line, HashBuilder builder, Resource resource, 175 ResourceTransformerChain transformerChain, HttpServletRequest request) throws IOException { 176 177 if (isLink(line) && !hasScheme(line)) { 178 ResourceResolverChain resolverChain = transformerChain.getResolverChain(); 179 Resource appCacheResource = 180 resolverChain.resolveResource(null, line, Collections.singletonList(resource)); 181 String path = resolveUrlPath(line, request, resource, transformerChain); 182 builder.appendResource(appCacheResource); 183 if (logger.isTraceEnabled()) { 184 logger.trace("Link modified: " + path + " (original: " + line + ")"); 185 } 186 return path; 187 } 188 builder.appendString(line); 189 return line; 190 } 191 192 private boolean hasScheme(String link) { 193 int schemeIndex = link.indexOf(':'); 194 return (link.startsWith("//") || (schemeIndex > 0 && !link.substring(0, schemeIndex).contains("/"))); 195 } 196 197 private boolean isLink(String line) { 198 return (StringUtils.hasText(line) && !line.startsWith(COMMENT_DIRECTIVE)); 199 } 200 } 201 202 203 private static class HashBuilder { 204 205 private final ByteArrayOutputStream baos; 206 207 public HashBuilder(int initialSize) { 208 this.baos = new ByteArrayOutputStream(initialSize); 209 } 210 211 public void appendResource(Resource resource) throws IOException { 212 byte[] content = FileCopyUtils.copyToByteArray(resource.getInputStream()); 213 this.baos.write(DigestUtils.md5Digest(content)); 214 } 215 216 public void appendString(String content) throws IOException { 217 this.baos.write(content.getBytes(DEFAULT_CHARSET)); 218 } 219 220 public String build() { 221 return DigestUtils.md5DigestAsHex(this.baos.toByteArray()); 222 } 223 } 224 225}