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.IOException;
020import java.io.UnsupportedEncodingException;
021import java.net.URLDecoder;
022import java.time.Instant;
023import java.util.ArrayList;
024import java.util.Collections;
025import java.util.EnumSet;
026import java.util.List;
027import java.util.Set;
028import java.util.stream.Collectors;
029
030import org.apache.commons.logging.Log;
031import org.apache.commons.logging.LogFactory;
032import reactor.core.publisher.Mono;
033
034import org.springframework.beans.factory.InitializingBean;
035import org.springframework.core.ResolvableType;
036import org.springframework.core.codec.Hints;
037import org.springframework.core.io.Resource;
038import org.springframework.core.io.ResourceLoader;
039import org.springframework.http.CacheControl;
040import org.springframework.http.HttpHeaders;
041import org.springframework.http.HttpMethod;
042import org.springframework.http.HttpStatus;
043import org.springframework.http.MediaType;
044import org.springframework.http.MediaTypeFactory;
045import org.springframework.http.codec.ResourceHttpMessageWriter;
046import org.springframework.http.server.PathContainer;
047import org.springframework.lang.Nullable;
048import org.springframework.util.Assert;
049import org.springframework.util.CollectionUtils;
050import org.springframework.util.ObjectUtils;
051import org.springframework.util.ResourceUtils;
052import org.springframework.util.StringUtils;
053import org.springframework.web.reactive.HandlerMapping;
054import org.springframework.web.server.MethodNotAllowedException;
055import org.springframework.web.server.ResponseStatusException;
056import org.springframework.web.server.ServerWebExchange;
057import org.springframework.web.server.WebHandler;
058
059/**
060 * {@code HttpRequestHandler} that serves static resources in an optimized way
061 * according to the guidelines of Page Speed, YSlow, etc.
062 *
063 * <p>The {@linkplain #setLocations "locations"} property takes a list of Spring
064 * {@link Resource} locations from which static resources are allowed to
065 * be served by this handler. Resources could be served from a classpath location,
066 * e.g. "classpath:/META-INF/public-web-resources/", allowing convenient packaging
067 * and serving of resources such as .js, .css, and others in jar files.
068 *
069 * <p>This request handler may also be configured with a
070 * {@link #setResourceResolvers(List) resourcesResolver} and
071 * {@link #setResourceTransformers(List) resourceTransformer} chains to support
072 * arbitrary resolution and transformation of resources being served. By default a
073 * {@link PathResourceResolver} simply finds resources based on the configured
074 * "locations". An application can configure additional resolvers and
075 * transformers such as the {@link VersionResourceResolver} which can resolve
076 * and prepare URLs for resources with a version in the URL.
077 *
078 * <p>This handler also properly evaluates the {@code Last-Modified} header (if
079 * present) so that a {@code 304} status code will be returned as appropriate,
080 * avoiding unnecessary overhead for resources that are already cached by the
081 * client.
082 *
083 * @author Rossen Stoyanchev
084 * @author Brian Clozel
085 * @since 5.0
086 */
087public class ResourceWebHandler implements WebHandler, InitializingBean {
088
089        private static final Set<HttpMethod> SUPPORTED_METHODS = EnumSet.of(HttpMethod.GET, HttpMethod.HEAD);
090
091        private static final Log logger = LogFactory.getLog(ResourceWebHandler.class);
092
093
094        private final List<String> locationValues = new ArrayList<>(4);
095
096        private final List<Resource> locations = new ArrayList<>(4);
097
098        private final List<ResourceResolver> resourceResolvers = new ArrayList<>(4);
099
100        private final List<ResourceTransformer> resourceTransformers = new ArrayList<>(4);
101
102        @Nullable
103        private ResourceResolverChain resolverChain;
104
105        @Nullable
106        private ResourceTransformerChain transformerChain;
107
108        @Nullable
109        private CacheControl cacheControl;
110
111        @Nullable
112        private ResourceHttpMessageWriter resourceHttpMessageWriter;
113
114        @Nullable
115        private ResourceLoader resourceLoader;
116
117
118        /**
119         * Accepts a list of String-based location values to be resolved into
120         * {@link Resource} locations.
121         * @since 5.1
122         */
123        public void setLocationValues(List<String> locationValues) {
124                Assert.notNull(locationValues, "Location values list must not be null");
125                this.locationValues.clear();
126                this.locationValues.addAll(locationValues);
127        }
128
129        /**
130         * Return the configured location values.
131         * @since 5.1
132         */
133        public List<String> getLocationValues() {
134                return this.locationValues;
135        }
136
137        /**
138         * Set the {@code List} of {@code Resource} paths to use as sources
139         * for serving static resources.
140         */
141        public void setLocations(@Nullable List<Resource> locations) {
142                this.locations.clear();
143                if (locations != null) {
144                        this.locations.addAll(locations);
145                }
146        }
147
148        /**
149         * Return the {@code List} of {@code Resource} paths to use as sources
150         * for serving static resources.
151         * <p>Note that if {@link #setLocationValues(List) locationValues} are provided,
152         * instead of loaded Resource-based locations, this method will return
153         * empty until after initialization via {@link #afterPropertiesSet()}.
154         * @see #setLocationValues
155         * @see #setLocations
156         */
157        public List<Resource> getLocations() {
158                return this.locations;
159        }
160
161        /**
162         * Configure the list of {@link ResourceResolver ResourceResolvers} to use.
163         * <p>By default {@link PathResourceResolver} is configured. If using this property,
164         * it is recommended to add {@link PathResourceResolver} as the last resolver.
165         */
166        public void setResourceResolvers(@Nullable List<ResourceResolver> resourceResolvers) {
167                this.resourceResolvers.clear();
168                if (resourceResolvers != null) {
169                        this.resourceResolvers.addAll(resourceResolvers);
170                }
171        }
172
173        /**
174         * Return the list of configured resource resolvers.
175         */
176        public List<ResourceResolver> getResourceResolvers() {
177                return this.resourceResolvers;
178        }
179
180        /**
181         * Configure the list of {@link ResourceTransformer ResourceTransformers} to use.
182         * <p>By default no transformers are configured for use.
183         */
184        public void setResourceTransformers(@Nullable List<ResourceTransformer> resourceTransformers) {
185                this.resourceTransformers.clear();
186                if (resourceTransformers != null) {
187                        this.resourceTransformers.addAll(resourceTransformers);
188                }
189        }
190
191        /**
192         * Return the list of configured resource transformers.
193         */
194        public List<ResourceTransformer> getResourceTransformers() {
195                return this.resourceTransformers;
196        }
197
198        /**
199         * Set the {@link org.springframework.http.CacheControl} instance to build
200         * the Cache-Control HTTP response header.
201         */
202        public void setCacheControl(@Nullable CacheControl cacheControl) {
203                this.cacheControl = cacheControl;
204        }
205
206        /**
207         * Return the {@link org.springframework.http.CacheControl} instance to build
208         * the Cache-Control HTTP response header.
209         */
210        @Nullable
211        public CacheControl getCacheControl() {
212                return this.cacheControl;
213        }
214
215        /**
216         * Configure the {@link ResourceHttpMessageWriter} to use.
217         * <p>By default a {@link ResourceHttpMessageWriter} will be configured.
218         */
219        public void setResourceHttpMessageWriter(@Nullable ResourceHttpMessageWriter httpMessageWriter) {
220                this.resourceHttpMessageWriter = httpMessageWriter;
221        }
222
223        /**
224         * Return the configured resource message writer.
225         */
226        @Nullable
227        public ResourceHttpMessageWriter getResourceHttpMessageWriter() {
228                return this.resourceHttpMessageWriter;
229        }
230
231        /**
232         * Provide the ResourceLoader to load {@link #setLocationValues(List)
233         * location values} with.
234         * @since 5.1
235         */
236        public void setResourceLoader(ResourceLoader resourceLoader) {
237                this.resourceLoader = resourceLoader;
238        }
239
240
241        @Override
242        public void afterPropertiesSet() throws Exception {
243                resolveResourceLocations();
244
245                if (logger.isWarnEnabled() && CollectionUtils.isEmpty(this.locations)) {
246                        logger.warn("Locations list is empty. No resources will be served unless a " +
247                                        "custom ResourceResolver is configured as an alternative to PathResourceResolver.");
248                }
249
250                if (this.resourceResolvers.isEmpty()) {
251                        this.resourceResolvers.add(new PathResourceResolver());
252                }
253
254                initAllowedLocations();
255
256                if (getResourceHttpMessageWriter() == null) {
257                        this.resourceHttpMessageWriter = new ResourceHttpMessageWriter();
258                }
259
260                // Initialize immutable resolver and transformer chains
261                this.resolverChain = new DefaultResourceResolverChain(this.resourceResolvers);
262                this.transformerChain = new DefaultResourceTransformerChain(this.resolverChain, this.resourceTransformers);
263        }
264
265        private void resolveResourceLocations() {
266                if (CollectionUtils.isEmpty(this.locationValues)) {
267                        return;
268                }
269                else if (!CollectionUtils.isEmpty(this.locations)) {
270                        throw new IllegalArgumentException("Please set either Resource-based \"locations\" or " +
271                                        "String-based \"locationValues\", but not both.");
272                }
273
274                Assert.notNull(this.resourceLoader,
275                                "ResourceLoader is required when \"locationValues\" are configured.");
276
277                for (String location : this.locationValues) {
278                        Resource resource = this.resourceLoader.getResource(location);
279                        this.locations.add(resource);
280                }
281        }
282
283        /**
284         * Look for a {@code PathResourceResolver} among the configured resource
285         * resolvers and set its {@code allowedLocations} property (if empty) to
286         * match the {@link #setLocations locations} configured on this class.
287         */
288        protected void initAllowedLocations() {
289                if (CollectionUtils.isEmpty(this.locations)) {
290                        if (logger.isInfoEnabled()) {
291                                logger.info("Locations list is empty. No resources will be served unless a " +
292                                                "custom ResourceResolver is configured as an alternative to PathResourceResolver.");
293                        }
294                        return;
295                }
296                for (int i = getResourceResolvers().size() - 1; i >= 0; i--) {
297                        if (getResourceResolvers().get(i) instanceof PathResourceResolver) {
298                                PathResourceResolver resolver = (PathResourceResolver) getResourceResolvers().get(i);
299                                if (ObjectUtils.isEmpty(resolver.getAllowedLocations())) {
300                                        resolver.setAllowedLocations(getLocations().toArray(new Resource[0]));
301                                }
302                                break;
303                        }
304                }
305        }
306
307
308        /**
309         * Processes a resource request.
310         * <p>Checks for the existence of the requested resource in the configured list of locations.
311         * If the resource does not exist, a {@code 404} response will be returned to the client.
312         * If the resource exists, the request will be checked for the presence of the
313         * {@code Last-Modified} header, and its value will be compared against the last-modified
314         * timestamp of the given resource, returning a {@code 304} status code if the
315         * {@code Last-Modified} value  is greater. If the resource is newer than the
316         * {@code Last-Modified} value, or the header is not present, the content resource
317         * of the resource will be written to the response with caching headers
318         * set to expire one year in the future.
319         */
320        @Override
321        public Mono<Void> handle(ServerWebExchange exchange) {
322                return getResource(exchange)
323                                .switchIfEmpty(Mono.defer(() -> {
324                                        logger.debug(exchange.getLogPrefix() + "Resource not found");
325                                        return Mono.error(new ResponseStatusException(HttpStatus.NOT_FOUND));
326                                }))
327                                .flatMap(resource -> {
328                                        try {
329                                                if (HttpMethod.OPTIONS.matches(exchange.getRequest().getMethodValue())) {
330                                                        exchange.getResponse().getHeaders().add("Allow", "GET,HEAD,OPTIONS");
331                                                        return Mono.empty();
332                                                }
333
334                                                // Supported methods and required session
335                                                HttpMethod httpMethod = exchange.getRequest().getMethod();
336                                                if (!SUPPORTED_METHODS.contains(httpMethod)) {
337                                                        return Mono.error(new MethodNotAllowedException(
338                                                                        exchange.getRequest().getMethodValue(), SUPPORTED_METHODS));
339                                                }
340
341                                                // Header phase
342                                                if (exchange.checkNotModified(Instant.ofEpochMilli(resource.lastModified()))) {
343                                                        logger.trace(exchange.getLogPrefix() + "Resource not modified");
344                                                        return Mono.empty();
345                                                }
346
347                                                // Apply cache settings, if any
348                                                CacheControl cacheControl = getCacheControl();
349                                                if (cacheControl != null) {
350                                                        exchange.getResponse().getHeaders().setCacheControl(cacheControl);
351                                                }
352
353                                                // Check the media type for the resource
354                                                MediaType mediaType = MediaTypeFactory.getMediaType(resource).orElse(null);
355                                                setHeaders(exchange, resource, mediaType);
356
357                                                // Content phase
358                                                ResourceHttpMessageWriter writer = getResourceHttpMessageWriter();
359                                                Assert.state(writer != null, "No ResourceHttpMessageWriter");
360                                                return writer.write(Mono.just(resource),
361                                                                null, ResolvableType.forClass(Resource.class), mediaType,
362                                                                exchange.getRequest(), exchange.getResponse(),
363                                                                Hints.from(Hints.LOG_PREFIX_HINT, exchange.getLogPrefix()));
364                                        }
365                                        catch (IOException ex) {
366                                                return Mono.error(ex);
367                                        }
368                                });
369        }
370
371        protected Mono<Resource> getResource(ServerWebExchange exchange) {
372                String name = HandlerMapping.PATH_WITHIN_HANDLER_MAPPING_ATTRIBUTE;
373                PathContainer pathWithinHandler = exchange.getRequiredAttribute(name);
374
375                String path = processPath(pathWithinHandler.value());
376                if (!StringUtils.hasText(path) || isInvalidPath(path)) {
377                        return Mono.empty();
378                }
379                if (isInvalidEncodedPath(path)) {
380                        return Mono.empty();
381                }
382
383                Assert.state(this.resolverChain != null, "ResourceResolverChain not initialized");
384                Assert.state(this.transformerChain != null, "ResourceTransformerChain not initialized");
385
386                return this.resolverChain.resolveResource(exchange, path, getLocations())
387                                .flatMap(resource -> this.transformerChain.transform(exchange, resource));
388        }
389
390        /**
391         * Process the given resource path.
392         * <p>The default implementation replaces:
393         * <ul>
394         * <li>Backslash with forward slash.
395         * <li>Duplicate occurrences of slash with a single slash.
396         * <li>Any combination of leading slash and control characters (00-1F and 7F)
397         * with a single "/" or "". For example {@code "  / // foo/bar"}
398         * becomes {@code "/foo/bar"}.
399         * </ul>
400         * @since 3.2.12
401         */
402        protected String processPath(String path) {
403                path = StringUtils.replace(path, "\\", "/");
404                path = cleanDuplicateSlashes(path);
405                return cleanLeadingSlash(path);
406        }
407
408        private String cleanDuplicateSlashes(String path) {
409                StringBuilder sb = null;
410                char prev = 0;
411                for (int i = 0; i < path.length(); i++) {
412                        char curr = path.charAt(i);
413                        try {
414                                if (curr == '/' && prev == '/') {
415                                        if (sb == null) {
416                                                sb = new StringBuilder(path.substring(0, i));
417                                        }
418                                        continue;
419                                }
420                                if (sb != null) {
421                                        sb.append(path.charAt(i));
422                                }
423                        }
424                        finally {
425                                prev = curr;
426                        }
427                }
428                return (sb != null ? sb.toString() : path);
429        }
430
431        private String cleanLeadingSlash(String path) {
432                boolean slash = false;
433                for (int i = 0; i < path.length(); i++) {
434                        if (path.charAt(i) == '/') {
435                                slash = true;
436                        }
437                        else if (path.charAt(i) > ' ' && path.charAt(i) != 127) {
438                                if (i == 0 || (i == 1 && slash)) {
439                                        return path;
440                                }
441                                return (slash ? "/" + path.substring(i) : path.substring(i));
442                        }
443                }
444                return (slash ? "/" : "");
445        }
446
447        /**
448         * Check whether the given path contains invalid escape sequences.
449         * @param path the path to validate
450         * @return {@code true} if the path is invalid, {@code false} otherwise
451         */
452        private boolean isInvalidEncodedPath(String path) {
453                if (path.contains("%")) {
454                        try {
455                                // Use URLDecoder (vs UriUtils) to preserve potentially decoded UTF-8 chars
456                                String decodedPath = URLDecoder.decode(path, "UTF-8");
457                                if (isInvalidPath(decodedPath)) {
458                                        return true;
459                                }
460                                decodedPath = processPath(decodedPath);
461                                if (isInvalidPath(decodedPath)) {
462                                        return true;
463                                }
464                        }
465                        catch (IllegalArgumentException ex) {
466                                // May not be possible to decode...
467                        }
468                        catch (UnsupportedEncodingException ex) {
469                                // Should never happen...
470                        }
471                }
472                return false;
473        }
474
475        /**
476         * Identifies invalid resource paths. By default rejects:
477         * <ul>
478         * <li>Paths that contain "WEB-INF" or "META-INF"
479         * <li>Paths that contain "../" after a call to
480         * {@link StringUtils#cleanPath}.
481         * <li>Paths that represent a {@link ResourceUtils#isUrl
482         * valid URL} or would represent one after the leading slash is removed.
483         * </ul>
484         * <p><strong>Note:</strong> this method assumes that leading, duplicate '/'
485         * or control characters (e.g. white space) have been trimmed so that the
486         * path starts predictably with a single '/' or does not have one.
487         * @param path the path to validate
488         * @return {@code true} if the path is invalid, {@code false} otherwise
489         */
490        protected boolean isInvalidPath(String path) {
491                if (path.contains("WEB-INF") || path.contains("META-INF")) {
492                        if (logger.isWarnEnabled()) {
493                                logger.warn("Path with \"WEB-INF\" or \"META-INF\": [" + path + "]");
494                        }
495                        return true;
496                }
497                if (path.contains(":/")) {
498                        String relativePath = (path.charAt(0) == '/' ? path.substring(1) : path);
499                        if (ResourceUtils.isUrl(relativePath) || relativePath.startsWith("url:")) {
500                                if (logger.isWarnEnabled()) {
501                                        logger.warn("Path represents URL or has \"url:\" prefix: [" + path + "]");
502                                }
503                                return true;
504                        }
505                }
506                if (path.contains("..") && StringUtils.cleanPath(path).contains("../")) {
507                        if (logger.isWarnEnabled()) {
508                                logger.warn("Path contains \"../\" after call to StringUtils#cleanPath: [" + path + "]");
509                        }
510                        return true;
511                }
512                return false;
513        }
514
515        /**
516         * Set headers on the response. Called for both GET and HEAD requests.
517         * @param exchange current exchange
518         * @param resource the identified resource (never {@code null})
519         * @param mediaType the resource's media type (never {@code null})
520         */
521        protected void setHeaders(ServerWebExchange exchange, Resource resource, @Nullable MediaType mediaType)
522                        throws IOException {
523
524                HttpHeaders headers = exchange.getResponse().getHeaders();
525
526                long length = resource.contentLength();
527                headers.setContentLength(length);
528
529                if (mediaType != null) {
530                        headers.setContentType(mediaType);
531                }
532
533                if (resource instanceof HttpResource) {
534                        HttpHeaders resourceHeaders = ((HttpResource) resource).getResponseHeaders();
535                        exchange.getResponse().getHeaders().putAll(resourceHeaders);
536                }
537        }
538
539
540        @Override
541        public String toString() {
542                return "ResourceWebHandler " + formatLocations();
543        }
544
545        private Object formatLocations() {
546                if (!this.locationValues.isEmpty()) {
547                        return this.locationValues.stream().collect(Collectors.joining("\", \"", "[\"", "\"]"));
548                }
549                else if (!this.locations.isEmpty()) {
550                        return "[" + this.locations + "]";
551                }
552                return Collections.emptyList();
553        }
554}