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.reactive.resource; 018 019import java.io.StringWriter; 020import java.nio.CharBuffer; 021import java.nio.charset.Charset; 022import java.nio.charset.StandardCharsets; 023import java.util.ArrayList; 024import java.util.Collections; 025import java.util.List; 026import java.util.Set; 027import java.util.SortedSet; 028import java.util.TreeSet; 029 030import org.apache.commons.logging.Log; 031import org.apache.commons.logging.LogFactory; 032import reactor.core.publisher.Flux; 033import reactor.core.publisher.Mono; 034 035import org.springframework.core.io.Resource; 036import org.springframework.core.io.buffer.DataBuffer; 037import org.springframework.core.io.buffer.DataBufferFactory; 038import org.springframework.core.io.buffer.DataBufferUtils; 039import org.springframework.lang.Nullable; 040import org.springframework.util.StreamUtils; 041import org.springframework.util.StringUtils; 042import org.springframework.web.server.ServerWebExchange; 043 044/** 045 * A {@link ResourceTransformer} implementation that modifies links in a CSS 046 * file to match the public URL paths that should be exposed to clients (e.g. 047 * with an MD5 content-based hash inserted in the URL). 048 * 049 * <p>The implementation looks for links in CSS {@code @import} statements and 050 * also inside CSS {@code url()} functions. All links are then passed through the 051 * {@link ResourceResolverChain} and resolved relative to the location of the 052 * containing CSS file. If successfully resolved, the link is modified, otherwise 053 * the original link is preserved. 054 * 055 * @author Rossen Stoyanchev 056 * @since 5.0 057 */ 058public class CssLinkResourceTransformer extends ResourceTransformerSupport { 059 060 private static final Charset DEFAULT_CHARSET = StandardCharsets.UTF_8; 061 062 private static final Log logger = LogFactory.getLog(CssLinkResourceTransformer.class); 063 064 private final List<LinkParser> linkParsers = new ArrayList<>(2); 065 066 067 public CssLinkResourceTransformer() { 068 this.linkParsers.add(new ImportLinkParser()); 069 this.linkParsers.add(new UrlFunctionLinkParser()); 070 } 071 072 073 @SuppressWarnings("deprecation") 074 @Override 075 public Mono<Resource> transform(ServerWebExchange exchange, Resource inputResource, 076 ResourceTransformerChain transformerChain) { 077 078 return transformerChain.transform(exchange, inputResource) 079 .flatMap(outputResource -> { 080 String filename = outputResource.getFilename(); 081 if (!"css".equals(StringUtils.getFilenameExtension(filename)) || 082 inputResource instanceof EncodedResourceResolver.EncodedResource || 083 inputResource instanceof GzipResourceResolver.GzippedResource) { 084 return Mono.just(outputResource); 085 } 086 087 DataBufferFactory bufferFactory = exchange.getResponse().bufferFactory(); 088 Flux<DataBuffer> flux = DataBufferUtils 089 .read(outputResource, bufferFactory, StreamUtils.BUFFER_SIZE); 090 return DataBufferUtils.join(flux) 091 .flatMap(dataBuffer -> { 092 CharBuffer charBuffer = DEFAULT_CHARSET.decode(dataBuffer.asByteBuffer()); 093 DataBufferUtils.release(dataBuffer); 094 String cssContent = charBuffer.toString(); 095 return transformContent(cssContent, outputResource, transformerChain, exchange); 096 }); 097 }); 098 } 099 100 private Mono<? extends Resource> transformContent(String cssContent, Resource resource, 101 ResourceTransformerChain chain, ServerWebExchange exchange) { 102 103 List<ContentChunkInfo> contentChunkInfos = parseContent(cssContent); 104 if (contentChunkInfos.isEmpty()) { 105 return Mono.just(resource); 106 } 107 108 return Flux.fromIterable(contentChunkInfos) 109 .concatMap(contentChunkInfo -> { 110 String contentChunk = contentChunkInfo.getContent(cssContent); 111 if (contentChunkInfo.isLink() && !hasScheme(contentChunk)) { 112 String link = toAbsolutePath(contentChunk, exchange); 113 return resolveUrlPath(link, exchange, resource, chain).defaultIfEmpty(contentChunk); 114 } 115 else { 116 return Mono.just(contentChunk); 117 } 118 }) 119 .reduce(new StringWriter(), (writer, chunk) -> { 120 writer.write(chunk); 121 return writer; 122 }) 123 .map(writer -> { 124 byte[] newContent = writer.toString().getBytes(DEFAULT_CHARSET); 125 return new TransformedResource(resource, newContent); 126 }); 127 } 128 129 private List<ContentChunkInfo> parseContent(String cssContent) { 130 SortedSet<ContentChunkInfo> links = new TreeSet<>(); 131 this.linkParsers.forEach(parser -> parser.parse(cssContent, links)); 132 if (links.isEmpty()) { 133 return Collections.emptyList(); 134 } 135 int index = 0; 136 List<ContentChunkInfo> result = new ArrayList<>(); 137 for (ContentChunkInfo link : links) { 138 result.add(new ContentChunkInfo(index, link.getStart(), false)); 139 result.add(link); 140 index = link.getEnd(); 141 } 142 if (index < cssContent.length()) { 143 result.add(new ContentChunkInfo(index, cssContent.length(), false)); 144 } 145 return result; 146 } 147 148 private boolean hasScheme(String link) { 149 int schemeIndex = link.indexOf(':'); 150 return (schemeIndex > 0 && !link.substring(0, schemeIndex).contains("/")) || link.indexOf("//") == 0; 151 } 152 153 154 /** 155 * Extract content chunks that represent links. 156 */ 157 @FunctionalInterface 158 protected interface LinkParser { 159 160 void parse(String cssContent, SortedSet<ContentChunkInfo> result); 161 162 } 163 164 165 /** 166 * Abstract base class for {@link LinkParser} implementations. 167 */ 168 protected abstract static class AbstractLinkParser implements LinkParser { 169 170 /** Return the keyword to use to search for links, e.g. "@import", "url(" */ 171 protected abstract String getKeyword(); 172 173 @Override 174 public void parse(String content, SortedSet<ContentChunkInfo> result) { 175 int position = 0; 176 while (true) { 177 position = content.indexOf(getKeyword(), position); 178 if (position == -1) { 179 return; 180 } 181 position += getKeyword().length(); 182 while (Character.isWhitespace(content.charAt(position))) { 183 position++; 184 } 185 if (content.charAt(position) == '\'') { 186 position = extractLink(position, '\'', content, result); 187 } 188 else if (content.charAt(position) == '"') { 189 position = extractLink(position, '"', content, result); 190 } 191 else { 192 position = extractUnquotedLink(position, content, result); 193 } 194 } 195 } 196 197 protected int extractLink(int index, char endChar, String content, Set<ContentChunkInfo> result) { 198 int start = index + 1; 199 int end = content.indexOf(endChar, start); 200 result.add(new ContentChunkInfo(start, end, true)); 201 return end + 1; 202 } 203 204 /** 205 * Invoked after a keyword match, after whitespace has been removed, and when 206 * the next char is neither a single nor double quote. 207 */ 208 protected abstract int extractUnquotedLink(int position, String content, 209 Set<ContentChunkInfo> linksToAdd); 210 211 } 212 213 214 private static class ImportLinkParser extends AbstractLinkParser { 215 216 @Override 217 protected String getKeyword() { 218 return "@import"; 219 } 220 221 @Override 222 protected int extractUnquotedLink(int position, String content, Set<ContentChunkInfo> result) { 223 if (content.startsWith("url(", position)) { 224 // Ignore: UrlFunctionLinkParser will handle it. 225 } 226 else if (logger.isTraceEnabled()) { 227 logger.trace("Unexpected syntax for @import link at index " + position); 228 } 229 return position; 230 } 231 } 232 233 234 private static class UrlFunctionLinkParser extends AbstractLinkParser { 235 236 @Override 237 protected String getKeyword() { 238 return "url("; 239 } 240 241 @Override 242 protected int extractUnquotedLink(int position, String content, Set<ContentChunkInfo> result) { 243 // A url() function without unquoted 244 return extractLink(position - 1, ')', content, result); 245 } 246 } 247 248 249 private static class ContentChunkInfo implements Comparable<ContentChunkInfo> { 250 251 private final int start; 252 253 private final int end; 254 255 private final boolean isLink; 256 257 258 ContentChunkInfo(int start, int end, boolean isLink) { 259 this.start = start; 260 this.end = end; 261 this.isLink = isLink; 262 } 263 264 265 public int getStart() { 266 return this.start; 267 } 268 269 public int getEnd() { 270 return this.end; 271 } 272 273 public boolean isLink() { 274 return this.isLink; 275 } 276 277 public String getContent(String fullContent) { 278 return fullContent.substring(this.start, this.end); 279 } 280 281 @Override 282 public int compareTo(ContentChunkInfo other) { 283 return Integer.compare(this.start, other.start); 284 } 285 286 @Override 287 public boolean equals(@Nullable Object other) { 288 if (this == other) { 289 return true; 290 } 291 if (!(other instanceof ContentChunkInfo)) { 292 return false; 293 } 294 ContentChunkInfo otherCci = (ContentChunkInfo) other; 295 return (this.start == otherCci.start && this.end == otherCci.end); 296 } 297 298 @Override 299 public int hashCode() { 300 return this.start * 31 + this.end; 301 } 302 } 303 304}