    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}