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.reactive.resource; 018 019import java.io.ByteArrayOutputStream; 020import java.io.IOException; 021import java.nio.CharBuffer; 022import java.nio.charset.Charset; 023import java.nio.charset.StandardCharsets; 024import java.util.Arrays; 025import java.util.Collection; 026import java.util.Scanner; 027import java.util.function.Consumer; 028 029import org.apache.commons.logging.Log; 030import org.apache.commons.logging.LogFactory; 031import reactor.core.Exceptions; 032import reactor.core.publisher.Flux; 033import reactor.core.publisher.Mono; 034import reactor.core.publisher.SynchronousSink; 035 036import org.springframework.core.io.Resource; 037import org.springframework.core.io.buffer.DataBuffer; 038import org.springframework.core.io.buffer.DataBufferFactory; 039import org.springframework.core.io.buffer.DataBufferUtils; 040import org.springframework.lang.Nullable; 041import org.springframework.util.DigestUtils; 042import org.springframework.util.StreamUtils; 043import org.springframework.util.StringUtils; 044import org.springframework.web.server.ServerWebExchange; 045 046/** 047 * A {@link ResourceTransformer} HTML5 AppCache manifests. 048 * 049 * <p>This transformer: 050 * <ul> 051 * <li>modifies links to match the public URL paths that should be exposed to 052 * clients, using configured {@code ResourceResolver} strategies 053 * <li>appends a comment in the manifest, containing a Hash 054 * (e.g. "# Hash: 9de0f09ed7caf84e885f1f0f11c7e326"), thus changing the content 055 * of the manifest in order to trigger an appcache reload in the browser. 056 * </ul> 057 * 058 * <p>All files with an ".appcache" file extension (or the extension given 059 * to the constructor) will be transformed by this class. The hash is computed 060 * using the content of the appcache manifest so that changes in the manifest 061 * should invalidate the browser cache. This should also work with changes in 062 * referenced resources whose links are also versioned. 063 * 064 * @author Rossen Stoyanchev 065 * @author Brian Clozel 066 * @since 5.0 067 * @see <a href="https://html.spec.whatwg.org/multipage/browsers.html#offline">HTML5 offline applications spec</a> 068 */ 069public class AppCacheManifestTransformer extends ResourceTransformerSupport { 070 071 private static final String MANIFEST_HEADER = "CACHE MANIFEST"; 072 073 private static final String CACHE_HEADER = "CACHE:"; 074 075 private static final Collection<String> MANIFEST_SECTION_HEADERS = 076 Arrays.asList(MANIFEST_HEADER, "NETWORK:", "FALLBACK:", CACHE_HEADER); 077 078 private static final Charset DEFAULT_CHARSET = StandardCharsets.UTF_8; 079 080 private static final Log logger = LogFactory.getLog(AppCacheManifestTransformer.class); 081 082 083 private final String fileExtension; 084 085 086 /** 087 * Create an AppCacheResourceTransformer that transforms files with extension ".appcache". 088 */ 089 public AppCacheManifestTransformer() { 090 this("appcache"); 091 } 092 093 /** 094 * Create an AppCacheResourceTransformer that transforms files with the extension 095 * given as a parameter. 096 */ 097 public AppCacheManifestTransformer(String fileExtension) { 098 this.fileExtension = fileExtension; 099 } 100 101 102 @Override 103 public Mono<Resource> transform(ServerWebExchange exchange, Resource inputResource, 104 ResourceTransformerChain chain) { 105 106 return chain.transform(exchange, inputResource) 107 .flatMap(outputResource -> { 108 String name = outputResource.getFilename(); 109 if (!this.fileExtension.equals(StringUtils.getFilenameExtension(name))) { 110 return Mono.just(outputResource); 111 } 112 DataBufferFactory bufferFactory = exchange.getResponse().bufferFactory(); 113 Flux<DataBuffer> flux = DataBufferUtils 114 .read(outputResource, bufferFactory, StreamUtils.BUFFER_SIZE); 115 return DataBufferUtils.join(flux) 116 .flatMap(dataBuffer -> { 117 CharBuffer charBuffer = DEFAULT_CHARSET.decode(dataBuffer.asByteBuffer()); 118 DataBufferUtils.release(dataBuffer); 119 String content = charBuffer.toString(); 120 return transform(content, outputResource, chain, exchange); 121 }); 122 }); 123 } 124 125 private Mono<? extends Resource> transform(String content, Resource resource, 126 ResourceTransformerChain chain, ServerWebExchange exchange) { 127 128 if (!content.startsWith(MANIFEST_HEADER)) { 129 if (logger.isTraceEnabled()) { 130 logger.trace(exchange.getLogPrefix() + 131 "Skipping " + resource + ": Manifest does not start with 'CACHE MANIFEST'"); 132 } 133 return Mono.just(resource); 134 } 135 return Flux.generate(new LineInfoGenerator(content)) 136 .concatMap(info -> processLine(info, exchange, resource, chain)) 137 .reduce(new ByteArrayOutputStream(), (out, line) -> { 138 writeToByteArrayOutputStream(out, line + "\n"); 139 return out; 140 }) 141 .map(out -> { 142 String hash = DigestUtils.md5DigestAsHex(out.toByteArray()); 143 writeToByteArrayOutputStream(out, "\n" + "# Hash: " + hash); 144 return new TransformedResource(resource, out.toByteArray()); 145 }); 146 } 147 148 private static void writeToByteArrayOutputStream(ByteArrayOutputStream out, String toWrite) { 149 try { 150 byte[] bytes = toWrite.getBytes(DEFAULT_CHARSET); 151 out.write(bytes); 152 } 153 catch (IOException ex) { 154 throw Exceptions.propagate(ex); 155 } 156 } 157 158 private Mono<String> processLine(LineInfo info, ServerWebExchange exchange, 159 Resource resource, ResourceTransformerChain chain) { 160 161 if (!info.isLink()) { 162 return Mono.just(info.getLine()); 163 } 164 165 String link = toAbsolutePath(info.getLine(), exchange); 166 return resolveUrlPath(link, exchange, resource, chain); 167 } 168 169 170 private static class LineInfoGenerator implements Consumer<SynchronousSink<LineInfo>> { 171 172 private final Scanner scanner; 173 174 @Nullable 175 private LineInfo previous; 176 177 178 LineInfoGenerator(String content) { 179 this.scanner = new Scanner(content); 180 } 181 182 183 @Override 184 public void accept(SynchronousSink<LineInfo> sink) { 185 if (this.scanner.hasNext()) { 186 String line = this.scanner.nextLine(); 187 LineInfo current = new LineInfo(line, this.previous); 188 sink.next(current); 189 this.previous = current; 190 } 191 else { 192 sink.complete(); 193 } 194 } 195 } 196 197 198 private static class LineInfo { 199 200 private final String line; 201 202 private final boolean cacheSection; 203 204 private final boolean link; 205 206 207 LineInfo(String line, @Nullable LineInfo previousLine) { 208 this.line = line; 209 this.cacheSection = initCacheSectionFlag(line, previousLine); 210 this.link = iniLinkFlag(line, this.cacheSection); 211 } 212 213 214 private static boolean initCacheSectionFlag(String line, @Nullable LineInfo previousLine) { 215 String trimmedLine = line.trim(); 216 if (MANIFEST_SECTION_HEADERS.contains(trimmedLine)) { 217 return trimmedLine.equals(CACHE_HEADER); 218 } 219 else if (previousLine != null) { 220 return previousLine.isCacheSection(); 221 } 222 throw new IllegalStateException( 223 "Manifest does not start with " + MANIFEST_HEADER + ": " + line); 224 } 225 226 private static boolean iniLinkFlag(String line, boolean isCacheSection) { 227 return (isCacheSection && StringUtils.hasText(line) && !line.startsWith("#") 228 && !line.startsWith("//") && !hasScheme(line)); 229 } 230 231 private static boolean hasScheme(String line) { 232 int index = line.indexOf(':'); 233 return (line.startsWith("//") || (index > 0 && !line.substring(0, index).contains("/"))); 234 } 235 236 237 public String getLine() { 238 return this.line; 239 } 240 241 public boolean isCacheSection() { 242 return this.cacheSection; 243 } 244 245 public boolean isLink() { 246 return this.link; 247 } 248 } 249 250}