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.Comparator; 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.AntPathMatcher; 038import org.springframework.util.Assert; 039import org.springframework.util.StringUtils; 040 041/** 042 * Resolves request paths containing a version string that can be used as part 043 * of an HTTP caching strategy in which a resource is cached with a date in the 044 * distant future (e.g. 1 year) and cached until the version, and therefore the 045 * URL, is changed. 046 * 047 * <p>Different versioning strategies exist, and this resolver must be configured 048 * with one or more such strategies along with path mappings to indicate which 049 * strategy applies to which resources. 050 * 051 * <p>{@code ContentVersionStrategy} is a good default choice except in cases 052 * where it cannot be used. Most notably the {@code ContentVersionStrategy} 053 * cannot be combined with JavaScript module loaders. For such cases the 054 * {@code FixedVersionStrategy} is a better choice. 055 * 056 * <p>Note that using this resolver to serve CSS files means that the 057 * {@link CssLinkResourceTransformer} should also be used in order to modify 058 * links within CSS files to also contain the appropriate versions generated 059 * by this resolver. 060 * 061 * @author Brian Clozel 062 * @author Rossen Stoyanchev 063 * @since 4.1 064 * @see VersionStrategy 065 */ 066public class VersionResourceResolver extends AbstractResourceResolver { 067 068 private AntPathMatcher pathMatcher = new AntPathMatcher(); 069 070 /** Map from path pattern -> VersionStrategy. */ 071 private final Map<String, VersionStrategy> versionStrategyMap = new LinkedHashMap<>(); 072 073 074 /** 075 * Set a Map with URL paths as keys and {@code VersionStrategy} as values. 076 * <p>Supports direct URL matches and Ant-style pattern matches. For syntax 077 * details, see the {@link org.springframework.util.AntPathMatcher} javadoc. 078 * @param map a map with URLs as keys and version strategies as values 079 */ 080 public void setStrategyMap(Map<String, VersionStrategy> map) { 081 this.versionStrategyMap.clear(); 082 this.versionStrategyMap.putAll(map); 083 } 084 085 /** 086 * Return the map with version strategies keyed by path pattern. 087 */ 088 public Map<String, VersionStrategy> getStrategyMap() { 089 return this.versionStrategyMap; 090 } 091 092 /** 093 * Insert a content-based version in resource URLs that match the given path 094 * patterns. The version is computed from the content of the file, e.g. 095 * {@code "css/main-e36d2e05253c6c7085a91522ce43a0b4.css"}. This is a good 096 * default strategy to use except when it cannot be, for example when using 097 * JavaScript module loaders, use {@link #addFixedVersionStrategy} instead 098 * for serving JavaScript files. 099 * @param pathPatterns one or more resource URL path patterns, 100 * relative to the pattern configured with the resource handler 101 * @return the current instance for chained method invocation 102 * @see ContentVersionStrategy 103 */ 104 public VersionResourceResolver addContentVersionStrategy(String... pathPatterns) { 105 addVersionStrategy(new ContentVersionStrategy(), pathPatterns); 106 return this; 107 } 108 109 /** 110 * Insert a fixed, prefix-based version in resource URLs that match the given 111 * path patterns, for example: <code>"{version}/js/main.js"</code>. This is useful (vs. 112 * content-based versions) when using JavaScript module loaders. 113 * <p>The version may be a random number, the current date, or a value 114 * fetched from a git commit sha, a property file, or environment variable 115 * and set with SpEL expressions in the configuration (e.g. see {@code @Value} 116 * in Java config). 117 * <p>If not done already, variants of the given {@code pathPatterns}, prefixed with 118 * the {@code version} will be also configured. For example, adding a {@code "/js/**"} path pattern 119 * will also cofigure automatically a {@code "/v1.0.0/js/**"} with {@code "v1.0.0"} the 120 * {@code version} String given as an argument. 121 * @param version a version string 122 * @param pathPatterns one or more resource URL path patterns, 123 * relative to the pattern configured with the resource handler 124 * @return the current instance for chained method invocation 125 * @see FixedVersionStrategy 126 */ 127 public VersionResourceResolver addFixedVersionStrategy(String version, String... pathPatterns) { 128 List<String> patternsList = Arrays.asList(pathPatterns); 129 List<String> prefixedPatterns = new ArrayList<>(pathPatterns.length); 130 String versionPrefix = "/" + version; 131 for (String pattern : patternsList) { 132 prefixedPatterns.add(pattern); 133 if (!pattern.startsWith(versionPrefix) && !patternsList.contains(versionPrefix + pattern)) { 134 prefixedPatterns.add(versionPrefix + pattern); 135 } 136 } 137 return addVersionStrategy(new FixedVersionStrategy(version), StringUtils.toStringArray(prefixedPatterns)); 138 } 139 140 /** 141 * Register a custom VersionStrategy to apply to resource URLs that match the 142 * given path patterns. 143 * @param strategy the custom strategy 144 * @param pathPatterns one or more resource URL path patterns, 145 * relative to the pattern configured with the resource handler 146 * @return the current instance for chained method invocation 147 * @see VersionStrategy 148 */ 149 public VersionResourceResolver addVersionStrategy(VersionStrategy strategy, String... pathPatterns) { 150 for (String pattern : pathPatterns) { 151 getStrategyMap().put(pattern, strategy); 152 } 153 return this; 154 } 155 156 157 @Override 158 protected Resource resolveResourceInternal(@Nullable HttpServletRequest request, String requestPath, 159 List<? extends Resource> locations, ResourceResolverChain chain) { 160 161 Resource resolved = chain.resolveResource(request, requestPath, locations); 162 if (resolved != null) { 163 return resolved; 164 } 165 166 VersionStrategy versionStrategy = getStrategyForPath(requestPath); 167 if (versionStrategy == null) { 168 return null; 169 } 170 171 String candidateVersion = versionStrategy.extractVersion(requestPath); 172 if (!StringUtils.hasLength(candidateVersion)) { 173 return null; 174 } 175 176 String simplePath = versionStrategy.removeVersion(requestPath, candidateVersion); 177 Resource baseResource = chain.resolveResource(request, simplePath, locations); 178 if (baseResource == null) { 179 return null; 180 } 181 182 String actualVersion = versionStrategy.getResourceVersion(baseResource); 183 if (candidateVersion.equals(actualVersion)) { 184 return new FileNameVersionedResource(baseResource, candidateVersion); 185 } 186 else { 187 if (logger.isTraceEnabled()) { 188 logger.trace("Found resource for \"" + requestPath + "\", but version [" + 189 candidateVersion + "] does not match"); 190 } 191 return null; 192 } 193 } 194 195 @Override 196 protected String resolveUrlPathInternal(String resourceUrlPath, 197 List<? extends Resource> locations, ResourceResolverChain chain) { 198 199 String baseUrl = chain.resolveUrlPath(resourceUrlPath, locations); 200 if (StringUtils.hasText(baseUrl)) { 201 VersionStrategy versionStrategy = getStrategyForPath(resourceUrlPath); 202 if (versionStrategy == null) { 203 return baseUrl; 204 } 205 Resource resource = chain.resolveResource(null, baseUrl, locations); 206 Assert.state(resource != null, "Unresolvable resource"); 207 String version = versionStrategy.getResourceVersion(resource); 208 return versionStrategy.addVersion(baseUrl, version); 209 } 210 return baseUrl; 211 } 212 213 /** 214 * Find a {@code VersionStrategy} for the request path of the requested resource. 215 * @return an instance of a {@code VersionStrategy} or null if none matches that request path 216 */ 217 @Nullable 218 protected VersionStrategy getStrategyForPath(String requestPath) { 219 String path = "/".concat(requestPath); 220 List<String> matchingPatterns = new ArrayList<>(); 221 for (String pattern : this.versionStrategyMap.keySet()) { 222 if (this.pathMatcher.match(pattern, path)) { 223 matchingPatterns.add(pattern); 224 } 225 } 226 if (!matchingPatterns.isEmpty()) { 227 Comparator<String> comparator = this.pathMatcher.getPatternComparator(path); 228 matchingPatterns.sort(comparator); 229 return this.versionStrategyMap.get(matchingPatterns.get(0)); 230 } 231 return null; 232 } 233 234 235 private class FileNameVersionedResource extends AbstractResource implements HttpResource { 236 237 private final Resource original; 238 239 private final String version; 240 241 public FileNameVersionedResource(Resource original, String version) { 242 this.original = original; 243 this.version = version; 244 } 245 246 @Override 247 public boolean exists() { 248 return this.original.exists(); 249 } 250 251 @Override 252 public boolean isReadable() { 253 return this.original.isReadable(); 254 } 255 256 @Override 257 public boolean isOpen() { 258 return this.original.isOpen(); 259 } 260 261 @Override 262 public boolean isFile() { 263 return this.original.isFile(); 264 } 265 266 @Override 267 public URL getURL() throws IOException { 268 return this.original.getURL(); 269 } 270 271 @Override 272 public URI getURI() throws IOException { 273 return this.original.getURI(); 274 } 275 276 @Override 277 public File getFile() throws IOException { 278 return this.original.getFile(); 279 } 280 281 @Override 282 @Nullable 283 public String getFilename() { 284 return this.original.getFilename(); 285 } 286 287 @Override 288 public long contentLength() throws IOException { 289 return this.original.contentLength(); 290 } 291 292 @Override 293 public long lastModified() throws IOException { 294 return this.original.lastModified(); 295 } 296 297 @Override 298 public Resource createRelative(String relativePath) throws IOException { 299 return this.original.createRelative(relativePath); 300 } 301 302 @Override 303 public String getDescription() { 304 return this.original.getDescription(); 305 } 306 307 @Override 308 public InputStream getInputStream() throws IOException { 309 return this.original.getInputStream(); 310 } 311 312 @Override 313 public HttpHeaders getResponseHeaders() { 314 HttpHeaders headers = (this.original instanceof HttpResource ? 315 ((HttpResource) this.original).getResponseHeaders() : new HttpHeaders()); 316 headers.setETag("\"" + this.version + "\""); 317 return headers; 318 } 319 } 320 321}