    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}