001/*
002 * Copyright 2002-2019 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.File;
020import java.io.IOException;
021import java.io.InputStream;
022import java.net.URI;
023import java.net.URL;
024import java.util.ArrayList;
025import java.util.Arrays;
026import java.util.Collections;
027import java.util.LinkedHashMap;
028import java.util.List;
029import java.util.Map;
030
031import javax.servlet.http.HttpServletRequest;
032
033import org.springframework.core.io.AbstractResource;
034import org.springframework.core.io.Resource;
035import org.springframework.http.HttpHeaders;
036import org.springframework.lang.Nullable;
037import org.springframework.util.Assert;
038
039/**
040 * Resolver that delegates to the chain, and if a resource is found, it then
041 * attempts to find an encoded (e.g. gzip, brotli) variant that is acceptable
042 * based on the "Accept-Encoding" request header.
043 *
044 * <p>The list of supported {@link #setContentCodings(List) contentCodings} can
045 * be configured, in order of preference, and each coding must be associated
046 * with {@link #setExtensions(Map) extensions}.
047 *
048 * <p>Note that this resolver must be ordered ahead of a
049 * {@link VersionResourceResolver} with a content-based, version strategy to
050 * ensure the version calculation is not impacted by the encoding.
051 *
052 * @author Rossen Stoyanchev
053 * @since 5.1
054 */
055public class EncodedResourceResolver extends AbstractResourceResolver {
056
057        /**
058         * The default content codings.
059         */
060        public static final List<String> DEFAULT_CODINGS = Arrays.asList("br", "gzip");
061
062
063        private final List<String> contentCodings = new ArrayList<>(DEFAULT_CODINGS);
064
065        private final Map<String, String> extensions = new LinkedHashMap<>();
066
067
068        public EncodedResourceResolver() {
069                this.extensions.put("gzip", ".gz");
070                this.extensions.put("br", ".br");
071        }
072
073
074        /**
075         * Configure the supported content codings in order of preference. The first
076         * coding that is present in the {@literal "Accept-Encoding"} header for a
077         * given request, and that has a file present with the associated extension,
078         * is used.
079         * <p><strong>Note:</strong> Each coding must be associated with a file
080         * extension via {@link #registerExtension} or {@link #setExtensions}. Also
081         * customizations to the list of codings here should be matched by
082         * customizations to the same list in {@link CachingResourceResolver} to
083         * ensure encoded variants of a resource are cached under separate keys.
084         * <p>By default this property is set to {@literal ["br", "gzip"]}.
085         * @param codings one or more supported content codings
086         */
087        public void setContentCodings(List<String> codings) {
088                Assert.notEmpty(codings, "At least one content coding expected");
089                this.contentCodings.clear();
090                this.contentCodings.addAll(codings);
091        }
092
093        /**
094         * Return a read-only list with the supported content codings.
095         */
096        public List<String> getContentCodings() {
097                return Collections.unmodifiableList(this.contentCodings);
098        }
099
100        /**
101         * Configure mappings from content codings to file extensions. A dot "."
102         * will be prepended in front of the extension value if not present.
103         * <p>By default this is configured with {@literal ["br" -> ".br"]} and
104         * {@literal ["gzip" -> ".gz"]}.
105         * @param extensions the extensions to use.
106         * @see #registerExtension(String, String)
107         */
108        public void setExtensions(Map<String, String> extensions) {
109                extensions.forEach(this::registerExtension);
110        }
111
112        /**
113         * Return a read-only map with coding-to-extension mappings.
114         */
115        public Map<String, String> getExtensions() {
116                return Collections.unmodifiableMap(this.extensions);
117        }
118
119        /**
120         * Java config friendly alternative to {@link #setExtensions(Map)}.
121         * @param coding the content coding
122         * @param extension the associated file extension
123         */
124        public void registerExtension(String coding, String extension) {
125                this.extensions.put(coding, (extension.startsWith(".") ? extension : "." + extension));
126        }
127
128
129        @Override
130        protected Resource resolveResourceInternal(@Nullable HttpServletRequest request, String requestPath,
131                        List<? extends Resource> locations, ResourceResolverChain chain) {
132
133                Resource resource = chain.resolveResource(request, requestPath, locations);
134                if (resource == null || request == null) {
135                        return resource;
136                }
137
138                String acceptEncoding = getAcceptEncoding(request);
139                if (acceptEncoding == null) {
140                        return resource;
141                }
142
143                for (String coding : this.contentCodings) {
144                        if (acceptEncoding.contains(coding)) {
145                                try {
146                                        String extension = getExtension(coding);
147                                        Resource encoded = new EncodedResource(resource, coding, extension);
148                                        if (encoded.exists()) {
149                                                return encoded;
150                                        }
151                                }
152                                catch (IOException ex) {
153                                        if (logger.isTraceEnabled()) {
154                                                logger.trace("No " + coding + " resource for [" + resource.getFilename() + "]", ex);
155                                        }
156                                }
157                        }
158                }
159
160                return resource;
161        }
162
163        @Nullable
164        private String getAcceptEncoding(HttpServletRequest request) {
165                String header = request.getHeader(HttpHeaders.ACCEPT_ENCODING);
166                return (header != null ? header.toLowerCase() : null);
167        }
168
169        private String getExtension(String coding) {
170                String extension = this.extensions.get(coding);
171                if (extension == null) {
172                        throw new IllegalStateException("No file extension associated with content coding " + coding);
173                }
174                return extension;
175        }
176
177        @Override
178        protected String resolveUrlPathInternal(String resourceUrlPath,
179                        List<? extends Resource> locations, ResourceResolverChain chain) {
180
181                return chain.resolveUrlPath(resourceUrlPath, locations);
182        }
183
184
185        /**
186         * An encoded {@link HttpResource}.
187         */
188        static final class EncodedResource extends AbstractResource implements HttpResource {
189
190                private final Resource original;
191
192                private final String coding;
193
194                private final Resource encoded;
195
196                EncodedResource(Resource original, String coding, String extension) throws IOException {
197                        this.original = original;
198                        this.coding = coding;
199                        this.encoded = original.createRelative(original.getFilename() + extension);
200                }
201
202
203                @Override
204                public InputStream getInputStream() throws IOException {
205                        return this.encoded.getInputStream();
206                }
207
208                @Override
209                public boolean exists() {
210                        return this.encoded.exists();
211                }
212
213                @Override
214                public boolean isReadable() {
215                        return this.encoded.isReadable();
216                }
217
218                @Override
219                public boolean isOpen() {
220                        return this.encoded.isOpen();
221                }
222
223                @Override
224                public boolean isFile() {
225                        return this.encoded.isFile();
226                }
227
228                @Override
229                public URL getURL() throws IOException {
230                        return this.encoded.getURL();
231                }
232
233                @Override
234                public URI getURI() throws IOException {
235                        return this.encoded.getURI();
236                }
237
238                @Override
239                public File getFile() throws IOException {
240                        return this.encoded.getFile();
241                }
242
243                @Override
244                public long contentLength() throws IOException {
245                        return this.encoded.contentLength();
246                }
247
248                @Override
249                public long lastModified() throws IOException {
250                        return this.encoded.lastModified();
251                }
252
253                @Override
254                public Resource createRelative(String relativePath) throws IOException {
255                        return this.encoded.createRelative(relativePath);
256                }
257
258                @Override
259                @Nullable
260                public String getFilename() {
261                        return this.original.getFilename();
262                }
263
264                @Override
265                public String getDescription() {
266                        return this.encoded.getDescription();
267                }
268
269                @Override
270                public HttpHeaders getResponseHeaders() {
271                        HttpHeaders headers;
272                        if (this.original instanceof HttpResource) {
273                                headers = ((HttpResource) this.original).getResponseHeaders();
274                        }
275                        else {
276                                headers = new HttpHeaders();
277                        }
278                        headers.add(HttpHeaders.CONTENT_ENCODING, this.coding);
279                        headers.add(HttpHeaders.VARY, HttpHeaders.ACCEPT_ENCODING);
280                        return headers;
281                }
282        }
283
284}