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}