/* * Copyright 2002-2017 the original author or authors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package org.springframework.web.reactive.resource; import java.io.IOException; import java.io.StringWriter; import java.nio.charset.Charset; import java.nio.charset.StandardCharsets; import java.util.ArrayList; import java.util.Collections; import java.util.HashSet; import java.util.List; import java.util.Set; import org.apache.commons.logging.Log; import org.apache.commons.logging.LogFactory; import reactor.core.Exceptions; import reactor.core.publisher.Flux; import reactor.core.publisher.Mono; import org.springframework.core.io.Resource; import org.springframework.util.FileCopyUtils; import org.springframework.util.StringUtils; import org.springframework.web.server.ServerWebExchange; /** * A {@link ResourceTransformer} implementation that modifies links in a CSS * file to match the public URL paths that should be exposed to clients (e.g. * with an MD5 content-based hash inserted in the URL). * * <p>The implementation looks for links in CSS {@code @import} statements and * also inside CSS {@code url()} functions. All links are then passed through the * {@link ResourceResolverChain} and resolved relative to the location of the * containing CSS file. If successfully resolved, the link is modified, otherwise * the original link is preserved. * * @author Rossen Stoyanchev * @since 5.0 */ public class CssLinkResourceTransformer extends ResourceTransformerSupport { private static final Charset DEFAULT_CHARSET = StandardCharsets.UTF_8; private static final Log logger = LogFactory.getLog(CssLinkResourceTransformer.class); private final List<LinkParser> linkParsers = new ArrayList<>(2); public CssLinkResourceTransformer() { this.linkParsers.add(new ImportLinkParser()); this.linkParsers.add(new UrlFunctionLinkParser()); } @Override public Mono<Resource> transform(ServerWebExchange exchange, Resource resource, ResourceTransformerChain transformerChain) { return transformerChain.transform(exchange, resource) .flatMap(newResource -> { String filename = newResource.getFilename(); if (!"css".equals(StringUtils.getFilenameExtension(filename)) || resource instanceof GzipResourceResolver.GzippedResource) { return Mono.just(newResource); } if (logger.isTraceEnabled()) { logger.trace("Transforming resource: " + newResource); } byte[] bytes = new byte[0]; try { bytes = FileCopyUtils.copyToByteArray(newResource.getInputStream()); } catch (IOException ex) { return Mono.error(Exceptions.propagate(ex)); } String fullContent = new String(bytes, DEFAULT_CHARSET); List<Segment> segments = parseContent(fullContent); if (segments.isEmpty()) { if (logger.isTraceEnabled()) { logger.trace("No links found."); } return Mono.just(newResource); } return Flux.fromIterable(segments) .concatMap(segment -> { String segmentContent = segment.getContent(fullContent); if (segment.isLink() && !hasScheme(segmentContent)) { String link = toAbsolutePath(segmentContent, exchange); return resolveUrlPath(link, exchange, newResource, transformerChain) .defaultIfEmpty(segmentContent); } else { return Mono.just(segmentContent); } }) .reduce(new StringWriter(), (writer, chunk) -> { writer.write(chunk); return writer; }) .flatMap(writer -> { byte[] newContent = writer.toString().getBytes(DEFAULT_CHARSET); return Mono.just(new TransformedResource(resource, newContent)); }); }); } private List<Segment> parseContent(String fullContent) { List<Segment> links = new ArrayList<>(); for (LinkParser parser : this.linkParsers) { links.addAll(parser.parseLinks(fullContent)); } if (links.isEmpty()) { return Collections.emptyList(); } Collections.sort(links); int index = 0; List<Segment> allSegments = new ArrayList<>(links); for (Segment link : links) { allSegments.add(new Segment(index, link.getStart(), false)); index = link.getEnd(); } if (index < fullContent.length()) { allSegments.add(new Segment(index, fullContent.length(), false)); } Collections.sort(allSegments); return allSegments; } private boolean hasScheme(String link) { int schemeIndex = link.indexOf(":"); return (schemeIndex > 0 && !link.substring(0, schemeIndex).contains("/")) || link.indexOf("//") == 0; } @FunctionalInterface protected interface LinkParser { Set<Segment> parseLinks(String fullContent); } protected static abstract class AbstractLinkParser implements LinkParser { /** Return the keyword to use to search for links. */ protected abstract String getKeyword(); @Override public Set<Segment> parseLinks(String fullContent) { Set<Segment> linksToAdd = new HashSet<>(8); int index = 0; do { index = fullContent.indexOf(getKeyword(), index); if (index == -1) { break; } index = skipWhitespace(fullContent, index + getKeyword().length()); if (fullContent.charAt(index) == '\'') { index = addLink(index, "'", fullContent, linksToAdd); } else if (fullContent.charAt(index) == '"') { index = addLink(index, "\"", fullContent, linksToAdd); } else { index = extractLink(index, fullContent, linksToAdd); } } while (true); return linksToAdd; } private int skipWhitespace(String content, int index) { while (true) { if (Character.isWhitespace(content.charAt(index))) { index++; continue; } return index; } } protected int addLink(int index, String endKey, String content, Set<Segment> linksToAdd) { int start = index + 1; int end = content.indexOf(endKey, start); linksToAdd.add(new Segment(start, end, true)); return end + endKey.length(); } /** * Invoked after a keyword match, after whitespaces removed, and when * the next char is neither a single nor double quote. */ protected abstract int extractLink(int index, String content, Set<Segment> linksToAdd); } private static class ImportLinkParser extends AbstractLinkParser { @Override protected String getKeyword() { return "@import"; } @Override protected int extractLink(int index, String content, Set<Segment> linksToAdd) { if (content.substring(index, index + 4).equals("url(")) { // Ignore, UrlLinkParser will take care } else if (logger.isErrorEnabled()) { logger.error("Unexpected syntax for @import link at index " + index); } return index; } } private static class UrlFunctionLinkParser extends AbstractLinkParser { @Override protected String getKeyword() { return "url("; } @Override protected int extractLink(int index, String content, Set<Segment> linksToAdd) { // A url() function without unquoted return addLink(index - 1, ")", content, linksToAdd); } } private static class Segment implements Comparable<Segment> { private final int start; private final int end; private final boolean link; public Segment(int start, int end, boolean isLink) { this.start = start; this.end = end; this.link = isLink; } public int getStart() { return this.start; } public int getEnd() { return this.end; } public boolean isLink() { return this.link; } public String getContent(String fullContent) { return fullContent.substring(this.start, this.end); } @Override public int compareTo(Segment other) { return (this.start < other.start ? -1 : (this.start == other.start ? 0 : 1)); } @Override public boolean equals(Object obj) { if (this == obj) { return true; } if (obj != null && obj instanceof Segment) { Segment other = (Segment) obj; return (this.start == other.start && this.end == other.end); } return false; } @Override public int hashCode() { return this.start * 31 + this.end; } } }