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}