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}