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