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.IOException; 020import java.io.StringWriter; 021import java.nio.charset.Charset; 022import java.nio.charset.StandardCharsets; 023import java.util.ArrayList; 024import java.util.List; 025import java.util.SortedSet; 026import java.util.TreeSet; 027 028import javax.servlet.http.HttpServletRequest; 029 030import org.apache.commons.logging.Log; 031import org.apache.commons.logging.LogFactory; 032 033import org.springframework.core.io.Resource; 034import org.springframework.lang.Nullable; 035import org.springframework.util.FileCopyUtils; 036import org.springframework.util.StringUtils; 037 038/** 039 * A {@link ResourceTransformer} implementation that modifies links in a CSS 040 * file to match the public URL paths that should be exposed to clients (e.g. 041 * with an MD5 content-based hash inserted in the URL). 042 * 043 * <p>The implementation looks for links in CSS {@code @import} statements and 044 * also inside CSS {@code url()} functions. All links are then passed through the 045 * {@link ResourceResolverChain} and resolved relative to the location of the 046 * containing CSS file. If successfully resolved, the link is modified, otherwise 047 * the original link is preserved. 048 * 049 * @author Rossen Stoyanchev 050 * @since 4.1 051 */ 052public class CssLinkResourceTransformer extends ResourceTransformerSupport { 053 054 private static final Charset DEFAULT_CHARSET = StandardCharsets.UTF_8; 055 056 private static final Log logger = LogFactory.getLog(CssLinkResourceTransformer.class); 057 058 private final List<LinkParser> linkParsers = new ArrayList<>(2); 059 060 061 public CssLinkResourceTransformer() { 062 this.linkParsers.add(new ImportStatementLinkParser()); 063 this.linkParsers.add(new UrlFunctionLinkParser()); 064 } 065 066 067 @SuppressWarnings("deprecation") 068 @Override 069 public Resource transform(HttpServletRequest request, Resource resource, ResourceTransformerChain transformerChain) 070 throws IOException { 071 072 resource = transformerChain.transform(request, resource); 073 074 String filename = resource.getFilename(); 075 if (!"css".equals(StringUtils.getFilenameExtension(filename)) || 076 resource instanceof EncodedResourceResolver.EncodedResource || 077 resource instanceof GzipResourceResolver.GzippedResource) { 078 return resource; 079 } 080 081 byte[] bytes = FileCopyUtils.copyToByteArray(resource.getInputStream()); 082 String content = new String(bytes, DEFAULT_CHARSET); 083 084 SortedSet<ContentChunkInfo> links = new TreeSet<>(); 085 for (LinkParser parser : this.linkParsers) { 086 parser.parse(content, links); 087 } 088 089 if (links.isEmpty()) { 090 return resource; 091 } 092 093 int index = 0; 094 StringWriter writer = new StringWriter(); 095 for (ContentChunkInfo linkContentChunkInfo : links) { 096 writer.write(content.substring(index, linkContentChunkInfo.getStart())); 097 String link = content.substring(linkContentChunkInfo.getStart(), linkContentChunkInfo.getEnd()); 098 String newLink = null; 099 if (!hasScheme(link)) { 100 String absolutePath = toAbsolutePath(link, request); 101 newLink = resolveUrlPath(absolutePath, request, resource, transformerChain); 102 } 103 writer.write(newLink != null ? newLink : link); 104 index = linkContentChunkInfo.getEnd(); 105 } 106 writer.write(content.substring(index)); 107 108 return new TransformedResource(resource, writer.toString().getBytes(DEFAULT_CHARSET)); 109 } 110 111 private boolean hasScheme(String link) { 112 int schemeIndex = link.indexOf(':'); 113 return ((schemeIndex > 0 && !link.substring(0, schemeIndex).contains("/")) || link.indexOf("//") == 0); 114 } 115 116 117 /** 118 * Extract content chunks that represent links. 119 */ 120 @FunctionalInterface 121 protected interface LinkParser { 122 123 void parse(String content, SortedSet<ContentChunkInfo> result); 124 125 } 126 127 128 /** 129 * Abstract base class for {@link LinkParser} implementations. 130 */ 131 protected abstract static class AbstractLinkParser implements LinkParser { 132 133 /** Return the keyword to use to search for links, e.g. "@import", "url(" */ 134 protected abstract String getKeyword(); 135 136 @Override 137 public void parse(String content, SortedSet<ContentChunkInfo> result) { 138 int position = 0; 139 while (true) { 140 position = content.indexOf(getKeyword(), position); 141 if (position == -1) { 142 return; 143 } 144 position += getKeyword().length(); 145 while (Character.isWhitespace(content.charAt(position))) { 146 position++; 147 } 148 if (content.charAt(position) == '\'') { 149 position = extractLink(position, "'", content, result); 150 } 151 else if (content.charAt(position) == '"') { 152 position = extractLink(position, "\"", content, result); 153 } 154 else { 155 position = extractLink(position, content, result); 156 } 157 } 158 } 159 160 protected int extractLink(int index, String endKey, String content, SortedSet<ContentChunkInfo> linksToAdd) { 161 int start = index + 1; 162 int end = content.indexOf(endKey, start); 163 linksToAdd.add(new ContentChunkInfo(start, end)); 164 return end + endKey.length(); 165 } 166 167 /** 168 * Invoked after a keyword match, after whitespace has been removed, and when 169 * the next char is neither a single nor double quote. 170 */ 171 protected abstract int extractLink(int index, String content, SortedSet<ContentChunkInfo> linksToAdd); 172 } 173 174 175 private static class ImportStatementLinkParser extends AbstractLinkParser { 176 177 @Override 178 protected String getKeyword() { 179 return "@import"; 180 } 181 182 @Override 183 protected int extractLink(int index, String content, SortedSet<ContentChunkInfo> linksToAdd) { 184 if (content.startsWith("url(", index)) { 185 // Ignore: UrlFunctionLinkParser will handle it. 186 } 187 else if (logger.isTraceEnabled()) { 188 logger.trace("Unexpected syntax for @import link at index " + index); 189 } 190 return index; 191 } 192 } 193 194 195 private static class UrlFunctionLinkParser extends AbstractLinkParser { 196 197 @Override 198 protected String getKeyword() { 199 return "url("; 200 } 201 202 @Override 203 protected int extractLink(int index, String content, SortedSet<ContentChunkInfo> linksToAdd) { 204 // A url() function without unquoted 205 return extractLink(index - 1, ")", content, linksToAdd); 206 } 207 } 208 209 210 private static class ContentChunkInfo implements Comparable<ContentChunkInfo> { 211 212 private final int start; 213 214 private final int end; 215 216 ContentChunkInfo(int start, int end) { 217 this.start = start; 218 this.end = end; 219 } 220 221 public int getStart() { 222 return this.start; 223 } 224 225 public int getEnd() { 226 return this.end; 227 } 228 229 @Override 230 public int compareTo(ContentChunkInfo other) { 231 return Integer.compare(this.start, other.start); 232 } 233 234 @Override 235 public boolean equals(@Nullable Object other) { 236 if (this == other) { 237 return true; 238 } 239 if (!(other instanceof ContentChunkInfo)) { 240 return false; 241 } 242 ContentChunkInfo otherCci = (ContentChunkInfo) other; 243 return (this.start == otherCci.start && this.end == otherCci.end); 244 } 245 246 @Override 247 public int hashCode() { 248 return this.start * 31 + this.end; 249 } 250 } 251 252}