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}