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