001// Licensed under the Apache License, Version 2.0 (the "License");
002// you may not use this file except in compliance with the License.
003// You may obtain a copy of the License at
004//
005// http://www.apache.org/licenses/LICENSE-2.0
006//
007// Unless required by applicable law or agreed to in writing, software
008// distributed under the License is distributed on an "AS IS" BASIS,
009// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
010// See the License for the specific language governing permissions and
011// limitations under the License.
012
013package org.apache.tapestry5.internal.services.assets;
014
015import org.apache.tapestry5.Asset;
016import org.apache.tapestry5.ContentType;
017import org.apache.tapestry5.SymbolConstants;
018import org.apache.tapestry5.ioc.IOOperation;
019import org.apache.tapestry5.ioc.OperationTracker;
020import org.apache.tapestry5.ioc.Resource;
021import org.apache.tapestry5.services.AssetSource;
022import org.apache.tapestry5.services.assets.*;
023import org.slf4j.Logger;
024import org.slf4j.LoggerFactory;
025
026import java.io.IOException;
027import java.io.InputStream;
028import java.io.InputStreamReader;
029import java.util.regex.Matcher;
030import java.util.regex.Pattern;
031
032/**
033 * Rewrites the {@code url()} attributes inside a CSS (MIME type "text/css")) resource.
034 * Each {@code url} is expanded to a complete path; this allows for CSS aggregation, where the location of the
035 * CSS file will change (which would ordinarily break relative URLs), and for changing the relative directories of
036 * the CSS file and the image assets it may refer to (useful for incorporating a hash of the resource's content into
037 * the exposed URL).
038 *
039 *
040 * One potential problem with URL rewriting is the way that URLs for referenced resources are generated; we are
041 * somewhat banking on the fact that referenced resources are non-compressable images.
042 *
043 * @see SymbolConstants#STRICT_CSS_URL_REWRITING
044 * @since 5.4
045 */
046public class CSSURLRewriter extends DelegatingSRS
047{
048    // Group 1 is the optional single or double quote (note the use of backtracking to match it)
049    // Group 2 is the text inside the quotes, or inside the parens if no quotes
050    // Group 3 is any query parmameters (see issue TAP5-2106)
051    private final Pattern urlPattern = Pattern.compile(
052            "url" +
053                    "\\(" +                 // opening paren
054                    "\\s*" +
055                    "(['\"]?)" +            // group 1: optional single or double quote
056                    "(.+?)" +               // group 2: the main part of the URL, up to the first '#' or '?'
057                    "([\\#\\?].*?)?" +      // group 3: Optional '#' or '?' to end of string
058                    "\\1" +                 // optional closing single/double quote
059                    "\\s*" +
060                    "\\)");                 // matching close paren
061
062    // Does it start with a '/' or what looks like a scheme ("http:")?
063    private final Pattern completeURLPattern = Pattern.compile("^[#/]|(\\p{Alpha}\\w*:)");
064
065    private final OperationTracker tracker;
066
067    private final AssetSource assetSource;
068
069    private final AssetChecksumGenerator checksumGenerator;
070
071    private final Logger logger = LoggerFactory.getLogger(CSSURLRewriter.class);
072
073    private final boolean strictCssUrlRewriting;
074
075    private final ContentType CSS_CONTENT_TYPE = new ContentType("text/css");
076
077    public CSSURLRewriter(StreamableResourceSource delegate, OperationTracker tracker, AssetSource assetSource,
078                          AssetChecksumGenerator checksumGenerator, boolean strictCssUrlRewriting)
079    {
080        super(delegate);
081        this.tracker = tracker;
082        this.assetSource = assetSource;
083        this.checksumGenerator = checksumGenerator;
084        this.strictCssUrlRewriting = strictCssUrlRewriting;
085    }
086
087    @Override
088    public StreamableResource getStreamableResource(Resource baseResource, StreamableResourceProcessing processing, ResourceDependencies dependencies) throws IOException
089    {
090        StreamableResource base = delegate.getStreamableResource(baseResource, processing, dependencies);
091
092        if (base.getContentType().equals(CSS_CONTENT_TYPE))
093        {
094            return filter(base, baseResource);
095        }
096
097        return base;
098    }
099
100    private StreamableResource filter(final StreamableResource base, final Resource baseResource) throws IOException
101    {
102        return tracker.perform("Rewriting relative URLs in " + baseResource,
103                new IOOperation<StreamableResource>()
104                {
105                    public StreamableResource perform() throws IOException
106                    {
107                        String baseString = readAsString(base);
108
109                        String filtered = replaceURLs(baseString, baseResource);
110
111                        if (filtered == null)
112                        {
113                            // No URLs were replaced so no need to create a new StreamableResource
114                            return base;
115                        }
116
117                        BytestreamCache cache = new BytestreamCache(filtered.getBytes("UTF-8"));
118
119                        return new StreamableResourceImpl(base.getDescription(),
120                                CSS_CONTENT_TYPE,
121                                CompressionStatus.COMPRESSABLE,
122                                base.getLastModified(),
123                                cache, checksumGenerator, base.getResponseCustomizer());
124                    }
125                });
126    }
127
128    /**
129     * Replaces any relative URLs in the content for the resource and returns the content with
130     * the URLs expanded.
131     *
132     * @param input
133     *         content of the resource
134     * @param baseResource
135     *         resource used to resolve relative URLs
136     * @return replacement content, or null if no relative URLs in the content
137     */
138    private String replaceURLs(String input, Resource baseResource)
139    {
140        boolean didReplace = false;
141
142        StringBuffer output = new StringBuffer(input.length());
143
144        Matcher matcher = urlPattern.matcher(input);
145
146        while (matcher.find())
147        {
148            String url = matcher.group(2); // the string inside the quotes
149
150            // When the URL starts with a slash or a scheme (e.g. http: or data:) , there's no need
151            // to rewrite it (this is actually rare in Tapestry as you want to use relative URLs to
152            // leverage the asset pipeline.
153            Matcher completeURLMatcher = completeURLPattern.matcher(url);
154            boolean matchFound = completeURLMatcher.find();
155            boolean isAssetUrl = matchFound && "asset:".equals(completeURLMatcher.group(1));
156            if (matchFound && !isAssetUrl)
157            {
158                String queryParameters = matcher.group(3);
159
160                if (queryParameters != null)
161                {
162                    url = url + queryParameters;
163                }
164
165                // This may normalize single quotes, or missing quotes, to double quotes, but is not
166                // considered a real change, since all such variations are valid.
167                appendReplacement(matcher, output, url);
168                continue;
169            }
170
171            if (isAssetUrl)
172            {
173                // strip away the "asset:" prefix
174                url = url.substring(6);
175            }
176
177            Asset asset = assetSource.getAsset(baseResource, url, null);
178
179            if (asset != null)
180            {
181                String assetURL = asset.toClientURL();
182
183                String queryParameters = matcher.group(3);
184                if (queryParameters != null)
185                {
186                    assetURL += queryParameters;
187                }
188
189                appendReplacement(matcher, output, assetURL);
190
191                didReplace = true;
192
193            } else
194            {
195                final String message = String.format("URL %s, referenced in file %s, doesn't exist.", url, baseResource.toURL(), baseResource);
196                if (strictCssUrlRewriting)
197                {
198                    throw new RuntimeException(message);
199                } else if (logger.isWarnEnabled())
200                {
201                    logger.warn(message);
202                }
203            }
204
205        }
206
207        if (!didReplace)
208        {
209            return null;
210        }
211
212        matcher.appendTail(output);
213
214        return output.toString();
215    }
216
217    private void appendReplacement(Matcher matcher, StringBuffer output, String assetURL)
218    {
219        matcher.appendReplacement(output, String.format("url(\"%s\")", assetURL));
220    }
221
222
223    // TODO: I'm thinking there's an (internal) service that should be created to make this more reusable.
224    private String readAsString(StreamableResource resource) throws IOException
225    {
226        StringBuffer result = new StringBuffer(resource.getSize());
227        char[] buffer = new char[5000];
228
229        InputStream is = resource.openStream();
230
231        InputStreamReader reader = new InputStreamReader(is, "UTF-8");
232
233        try
234        {
235
236            while (true)
237            {
238                int length = reader.read(buffer);
239
240                if (length < 0)
241                {
242                    break;
243                }
244
245                result.append(buffer, 0, length);
246            }
247        } finally
248        {
249            reader.close();
250            is.close();
251        }
252
253        return result.toString();
254    }
255}