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