001/* 002 * Copyright 2002-2020 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.nio.charset.StandardCharsets; 024import java.util.Arrays; 025import java.util.Collection; 026import java.util.Collections; 027import java.util.Scanner; 028 029import javax.servlet.http.HttpServletRequest; 030 031import org.apache.commons.logging.Log; 032import org.apache.commons.logging.LogFactory; 033 034import org.springframework.core.io.Resource; 035import org.springframework.lang.Nullable; 036import org.springframework.util.DigestUtils; 037import org.springframework.util.FileCopyUtils; 038import org.springframework.util.StringUtils; 039 040/** 041 * A {@link ResourceTransformer} implementation that helps handling resources 042 * within HTML5 AppCache manifests for HTML5 offline applications. 043 * 044 * <p>This transformer: 045 * <ul> 046 * <li>modifies links to match the public URL paths that should be exposed to clients, 047 * using configured {@code ResourceResolver} strategies 048 * <li>appends a comment in the manifest, containing a Hash (e.g. "# Hash: 9de0f09ed7caf84e885f1f0f11c7e326"), 049 * thus changing the content of the manifest in order to trigger an appcache reload in the browser. 050 * </ul> 051 * 052 * <p>All files that have the ".appcache" file extension, or the extension given in the constructor, 053 * will be transformed by this class. This hash is computed using the content of the appcache manifest 054 * and the content of the linked resources; so changing a resource linked in the manifest 055 * or the manifest itself should invalidate the browser cache. 056 * 057 * <p>In order to serve manifest files with the proper {@code "text/manifest"} content type, 058 * it is required to configure it with 059 * {@code contentNegotiationConfigurer.mediaType("appcache", MediaType.valueOf("text/manifest")} 060 * in a {@code WebMvcConfigurer}. 061 * 062 * @author Brian Clozel 063 * @since 4.1 064 * @see <a href="https://html.spec.whatwg.org/multipage/browsers.html#offline">HTML5 offline applications spec</a> 065 */ 066public class AppCacheManifestTransformer extends ResourceTransformerSupport { 067 068 private static final String MANIFEST_HEADER = "CACHE MANIFEST"; 069 070 private static final String CACHE_HEADER = "CACHE:"; 071 072 private static final Collection<String> MANIFEST_SECTION_HEADERS = 073 Arrays.asList(MANIFEST_HEADER, "NETWORK:", "FALLBACK:", CACHE_HEADER); 074 075 private static final Charset DEFAULT_CHARSET = StandardCharsets.UTF_8; 076 077 private static final Log logger = LogFactory.getLog(AppCacheManifestTransformer.class); 078 079 080 private final String fileExtension; 081 082 083 /** 084 * Create an AppCacheResourceTransformer that transforms files with extension ".appcache". 085 */ 086 public AppCacheManifestTransformer() { 087 this("appcache"); 088 } 089 090 /** 091 * Create an AppCacheResourceTransformer that transforms files with the extension 092 * given as a parameter. 093 */ 094 public AppCacheManifestTransformer(String fileExtension) { 095 this.fileExtension = fileExtension; 096 } 097 098 099 @Override 100 public Resource transform(HttpServletRequest request, Resource resource, 101 ResourceTransformerChain chain) throws IOException { 102 103 resource = chain.transform(request, resource); 104 if (!this.fileExtension.equals(StringUtils.getFilenameExtension(resource.getFilename()))) { 105 return resource; 106 } 107 108 byte[] bytes = FileCopyUtils.copyToByteArray(resource.getInputStream()); 109 String content = new String(bytes, DEFAULT_CHARSET); 110 111 if (!content.startsWith(MANIFEST_HEADER)) { 112 if (logger.isTraceEnabled()) { 113 logger.trace("Skipping " + resource + ": Manifest does not start with 'CACHE MANIFEST'"); 114 } 115 return resource; 116 } 117 118 @SuppressWarnings("resource") 119 Scanner scanner = new Scanner(content); 120 LineInfo previous = null; 121 LineAggregator aggregator = new LineAggregator(resource, content); 122 123 while (scanner.hasNext()) { 124 String line = scanner.nextLine(); 125 LineInfo current = new LineInfo(line, previous); 126 LineOutput lineOutput = processLine(current, request, resource, chain); 127 aggregator.add(lineOutput); 128 previous = current; 129 } 130 131 return aggregator.createResource(); 132 } 133 134 private static byte[] getResourceBytes(Resource resource) throws IOException { 135 return FileCopyUtils.copyToByteArray(resource.getInputStream()); 136 } 137 138 private LineOutput processLine(LineInfo info, HttpServletRequest request, 139 Resource resource, ResourceTransformerChain transformerChain) { 140 141 if (!info.isLink()) { 142 return new LineOutput(info.getLine(), null); 143 } 144 145 Resource appCacheResource = transformerChain.getResolverChain() 146 .resolveResource(null, info.getLine(), Collections.singletonList(resource)); 147 148 String path = info.getLine(); 149 String absolutePath = toAbsolutePath(path, request); 150 String newPath = resolveUrlPath(absolutePath, request, resource, transformerChain); 151 152 return new LineOutput((newPath != null ? newPath : path), appCacheResource); 153 } 154 155 156 private static class LineInfo { 157 158 private final String line; 159 160 private final boolean cacheSection; 161 162 private final boolean link; 163 164 public LineInfo(String line, @Nullable LineInfo previous) { 165 this.line = line; 166 this.cacheSection = initCacheSectionFlag(line, previous); 167 this.link = iniLinkFlag(line, this.cacheSection); 168 } 169 170 private static boolean initCacheSectionFlag(String line, @Nullable LineInfo previousLine) { 171 String trimmedLine = line.trim(); 172 if (MANIFEST_SECTION_HEADERS.contains(trimmedLine)) { 173 return trimmedLine.equals(CACHE_HEADER); 174 } 175 else if (previousLine != null) { 176 return previousLine.isCacheSection(); 177 } 178 throw new IllegalStateException( 179 "Manifest does not start with " + MANIFEST_HEADER + ": " + line); 180 } 181 182 private static boolean iniLinkFlag(String line, boolean isCacheSection) { 183 return (isCacheSection && StringUtils.hasText(line) && !line.startsWith("#") 184 && !line.startsWith("//") && !hasScheme(line)); 185 } 186 187 private static boolean hasScheme(String line) { 188 int index = line.indexOf(':'); 189 return (line.startsWith("//") || (index > 0 && !line.substring(0, index).contains("/"))); 190 } 191 192 public String getLine() { 193 return this.line; 194 } 195 196 public boolean isCacheSection() { 197 return this.cacheSection; 198 } 199 200 public boolean isLink() { 201 return this.link; 202 } 203 } 204 205 206 private static class LineOutput { 207 208 private final String line; 209 210 @Nullable 211 private final Resource resource; 212 213 public LineOutput(String line, @Nullable Resource resource) { 214 this.line = line; 215 this.resource = resource; 216 } 217 218 public String getLine() { 219 return this.line; 220 } 221 222 @Nullable 223 public Resource getResource() { 224 return this.resource; 225 } 226 } 227 228 229 private static class LineAggregator { 230 231 private final StringWriter writer = new StringWriter(); 232 233 private final ByteArrayOutputStream baos; 234 235 private final Resource resource; 236 237 public LineAggregator(Resource resource, String content) { 238 this.resource = resource; 239 this.baos = new ByteArrayOutputStream(content.length()); 240 } 241 242 public void add(LineOutput lineOutput) throws IOException { 243 this.writer.write(lineOutput.getLine() + "\n"); 244 byte[] bytes = (lineOutput.getResource() != null ? 245 DigestUtils.md5Digest(getResourceBytes(lineOutput.getResource())) : 246 lineOutput.getLine().getBytes(DEFAULT_CHARSET)); 247 this.baos.write(bytes); 248 } 249 250 public TransformedResource createResource() { 251 String hash = DigestUtils.md5DigestAsHex(this.baos.toByteArray()); 252 this.writer.write("\n" + "# Hash: " + hash); 253 byte[] bytes = this.writer.toString().getBytes(DEFAULT_CHARSET); 254 return new TransformedResource(this.resource, bytes); 255 } 256 } 257 258}