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}