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}