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}