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}