001 // Copyright 2004, 2005 The Apache Software Foundation 002 // 003 // Licensed under the Apache License, Version 2.0 (the "License"); 004 // you may not use this file except in compliance with the License. 005 // You may obtain a copy of the License at 006 // 007 // http://www.apache.org/licenses/LICENSE-2.0 008 // 009 // Unless required by applicable law or agreed to in writing, software 010 // distributed under the License is distributed on an "AS IS" BASIS, 011 // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 012 // See the License for the specific language governing permissions and 013 // limitations under the License. 014 015 package org.apache.tapestry.asset; 016 017 import org.apache.commons.io.FilenameUtils; 018 import org.apache.commons.io.IOUtils; 019 import org.apache.commons.logging.Log; 020 import org.apache.hivemind.ClassResolver; 021 import org.apache.hivemind.util.Defense; 022 import org.apache.tapestry.IRequestCycle; 023 import org.apache.tapestry.Tapestry; 024 import org.apache.tapestry.engine.IEngineService; 025 import org.apache.tapestry.engine.ILink; 026 import org.apache.tapestry.error.RequestExceptionReporter; 027 import org.apache.tapestry.services.LinkFactory; 028 import org.apache.tapestry.services.ServiceConstants; 029 import org.apache.tapestry.util.ContentType; 030 import org.apache.tapestry.util.io.GzipUtil; 031 import org.apache.tapestry.web.WebContext; 032 import org.apache.tapestry.web.WebRequest; 033 import org.apache.tapestry.web.WebResponse; 034 035 import javax.servlet.http.HttpServletResponse; 036 import java.io.ByteArrayOutputStream; 037 import java.io.IOException; 038 import java.io.InputStream; 039 import java.io.OutputStream; 040 import java.net.URL; 041 import java.net.URLConnection; 042 import java.util.HashMap; 043 import java.util.Map; 044 import java.util.TreeMap; 045 import java.util.zip.GZIPOutputStream; 046 047 /** 048 * A service for building URLs to and accessing {@link org.apache.tapestry.IAsset}s. Most of the 049 * work is deferred to the {@link org.apache.tapestry.IAsset}instance. 050 * <p> 051 * The retrieval part is directly linked to {@link PrivateAsset}. The service responds to a URL 052 * that encodes the path of a resource within the classpath. The {@link #service(IRequestCycle)} 053 * method reads the resource and streams it out. 054 * <p> 055 * TBD: Security issues. Should only be able to retrieve a resource that was previously registerred 056 * in some way ... otherwise, hackers will be able to suck out the .class files of the application! 057 * 058 * @author Howard Lewis Ship 059 */ 060 061 public class AssetService implements IEngineService 062 { 063 /** 064 * Query parameter that stores the path to the resource (with a leading slash). 065 * 066 * @since 4.0 067 */ 068 069 public static final String PATH = "path"; 070 071 /** 072 * Query parameter that stores the digest for the file; this is used to authenticate that the 073 * client is allowed to access the file. 074 * 075 * @since 4.0 076 */ 077 078 public static final String DIGEST = "digest"; 079 080 /** 081 * Defaults MIME types, by extension, used when the servlet container doesn't provide MIME 082 * types. ServletExec Debugger, for example, fails to provide these. 083 */ 084 085 private static final Map _mimeTypes; 086 087 static 088 { 089 _mimeTypes = new HashMap(17); 090 _mimeTypes.put("css", "text/css"); 091 _mimeTypes.put("gif", "image/gif"); 092 _mimeTypes.put("jpg", "image/jpeg"); 093 _mimeTypes.put("jpeg", "image/jpeg"); 094 _mimeTypes.put("png", "image/png"); 095 _mimeTypes.put("htm", "text/html"); 096 _mimeTypes.put("html", "text/html"); 097 } 098 099 /** Represents a month of time in seconds. */ 100 static final long MONTH_SECONDS = 60 * 60 * 24 * 30; 101 102 private Log _log; 103 104 /** @since 4.0 */ 105 private ClassResolver _classResolver; 106 107 /** @since 4.0 */ 108 private LinkFactory _linkFactory; 109 110 /** @since 4.0 */ 111 private WebContext _context; 112 113 /** @since 4.0 */ 114 115 private WebRequest _request; 116 117 /** @since 4.0 */ 118 private WebResponse _response; 119 120 /** @since 4.0 */ 121 private ResourceDigestSource _digestSource; 122 123 /** @since 4.1 */ 124 private ResourceMatcher _unprotectedMatcher; 125 126 /** 127 * Startup time for this service; used to set the Last-Modified response header. 128 * 129 * @since 4.0 130 */ 131 132 private final long _startupTime = System.currentTimeMillis(); 133 134 /** 135 * Time vended assets expire. Since a change in asset content is a change in asset URI, we want 136 * them to not expire ... but a year will do. 137 */ 138 139 final long _expireTime = _startupTime + 365 * 24 * 60 * 60 * 1000L; 140 141 /** @since 4.0 */ 142 143 private RequestExceptionReporter _exceptionReporter; 144 145 /** 146 * Cache of static content resources. 147 */ 148 private final Map _cache = new HashMap(); 149 150 /** 151 * Builds a {@link ILink}for a {@link PrivateAsset}. 152 * <p> 153 * A single parameter is expected, the resource path of the asset (which is expected to start 154 * with a leading slash). 155 */ 156 157 public ILink getLink(boolean post, Object parameter) 158 { 159 Defense.isAssignable(parameter, String.class, "parameter"); 160 161 String path = (String) parameter; 162 String digest = null; 163 164 if(!_unprotectedMatcher.containsResource(path)) 165 digest = _digestSource.getDigestForResource(path); 166 167 Map parameters = new TreeMap(new AssetComparator()); 168 169 parameters.put(ServiceConstants.SERVICE, getName()); 170 parameters.put(PATH, path); 171 172 if (digest != null) 173 parameters.put(DIGEST, digest); 174 175 // Service is stateless, which is the exception to the rule. 176 177 return _linkFactory.constructLink(this, post, parameters, false); 178 } 179 180 public String getName() 181 { 182 return Tapestry.ASSET_SERVICE; 183 } 184 185 private String getMimeType(String path) 186 { 187 String result = _context.getMimeType(path); 188 189 if (result == null) 190 { 191 int dotx = path.lastIndexOf('.'); 192 if (dotx > -1) 193 { 194 String key = path.substring(dotx + 1).toLowerCase(); 195 result = (String) _mimeTypes.get(key); 196 } 197 198 if (result == null) 199 result = "text/plain"; 200 } 201 202 return result; 203 } 204 205 /** 206 * Retrieves a resource from the classpath and returns it to the client in a binary output 207 * stream. 208 */ 209 210 public void service(IRequestCycle cycle) 211 throws IOException 212 { 213 String path = translatePath(cycle.getParameter(PATH)); 214 String md5Digest = cycle.getParameter(DIGEST); 215 boolean checkDigest = !_unprotectedMatcher.containsResource(path); 216 217 URLConnection resourceConnection; 218 219 try 220 { 221 URL resourceURL = _classResolver.getResource(path); 222 223 if (resourceURL == null) 224 { 225 _response.setStatus(HttpServletResponse.SC_NOT_FOUND); 226 _log.info(AssetMessages.noSuchResource(path)); 227 return; 228 } 229 230 if (checkDigest && !_digestSource.getDigestForResource(path).equals(md5Digest)) 231 { 232 _response.setStatus(HttpServletResponse.SC_NOT_FOUND); 233 _log.info(AssetMessages.md5Mismatch(path)); 234 return; 235 } 236 237 // If they were vended an asset in the past then it must be up-to date. 238 // Asset URIs change if the underlying file is modified. (unless unprotected) 239 240 if (checkDigest && _request.getHeader("If-Modified-Since") != null) 241 { 242 _response.setStatus(HttpServletResponse.SC_NOT_MODIFIED); 243 return; 244 } 245 246 resourceConnection = resourceURL.openConnection(); 247 248 // check caching for unprotected resources 249 250 if (!checkDigest && cachedResource(resourceConnection)) 251 return; 252 253 writeAssetContent(cycle, path, resourceConnection); 254 } 255 catch (IOException eof) 256 { 257 // ignored / expected exceptions happen when browser prematurely abandons connections - IE does this a lot 258 } 259 catch (Throwable ex) 260 { 261 _response.setStatus(HttpServletResponse.SC_NOT_FOUND); 262 _log.warn(AssetMessages.exceptionReportTitle(path), ex); 263 //_exceptionReporter.reportRequestException(AssetMessages.exceptionReportTitle(path), ex); 264 } 265 } 266 267 /** 268 * Utility that helps to resolve css file relative resources included 269 * in a css temlpate via "url('../images/foo.gif')" or fix paths containing 270 * relative resource ".." style notation. 271 * 272 * @param path The incoming path to check for relativity. 273 * @return The path unchanged if not containing a css relative path, otherwise 274 * returns the path without the css filename in it so the resource is resolvable 275 * directly from the path. 276 */ 277 String translatePath(String path) 278 { 279 if (path == null) 280 return null; 281 282 String ret = FilenameUtils.normalize(path); 283 ret = FilenameUtils.separatorsToUnix(ret); 284 285 return ret; 286 } 287 288 /** 289 * Checks if the resource contained within the specified URL 290 * has a modified time greater than the request header value 291 * of <code>If-Modified-Since</code>. If it doesn't then the 292 * response status is set to {@link HttpServletResponse#SC_NOT_MODIFIED}. 293 * 294 * @param resourceURL Resource being checked 295 * @return True if resource should be cached and response header was set. 296 * @since 4.1 297 */ 298 299 boolean cachedResource(URLConnection resourceURL) 300 { 301 // even if it doesn't exist in header the value will be -1, 302 // which means we need to write out the contents of the resource 303 304 long modifiedSince = _request.getDateHeader("If-Modified-Since"); 305 306 if (modifiedSince <= 0) 307 return false; 308 309 if (_log.isDebugEnabled()) 310 _log.debug("cachedResource(" + resourceURL.getURL() + ") modified-since header is: " + modifiedSince); 311 312 if (resourceURL.getLastModified() > modifiedSince) 313 return false; 314 315 _response.setStatus(HttpServletResponse.SC_NOT_MODIFIED); 316 317 return true; 318 } 319 320 /** 321 * Writes the asset specified by <code>resourceConnection</code> out to the response stream. 322 * 323 * @param cycle 324 * The current request. 325 * @param resourcePath 326 * The path of the resource. 327 * @param resourceConnection 328 * A connection for the resource. 329 * @throws IOException On error. 330 */ 331 332 private void writeAssetContent(IRequestCycle cycle, String resourcePath, URLConnection resourceConnection) 333 throws IOException 334 { 335 // Getting the content type and length is very dependant 336 // on support from the application server (represented 337 // here by the servletContext). 338 339 String contentType = getMimeType(resourcePath); 340 341 long lastModified = resourceConnection.getLastModified(); 342 if (lastModified <= 0) 343 lastModified = _startupTime; 344 345 _response.setDateHeader("Last-Modified", lastModified); 346 347 // write out expiration/cache info 348 349 _response.setDateHeader("Expires", _expireTime); 350 _response.setHeader("Cache-Control", "public, max-age=" + (MONTH_SECONDS * 3)); 351 352 // Set the content type. If the servlet container doesn't 353 // provide it, try and guess it by the extension. 354 355 if (contentType == null || contentType.length() == 0) 356 contentType = getMimeType(resourcePath); 357 358 byte[] data = getAssetData(cycle, resourcePath, resourceConnection, contentType); 359 360 // See ETag definition - http://www.w3.org/Protocols/rfc2616/rfc2616-sec14.html#sec14.19 361 362 _response.setHeader("ETag", "W/\"" + data.length + "-" + lastModified + "\""); 363 364 // force image(or other) caching when detected, esp helps with ie related things 365 // see http://mir.aculo.us/2005/08/28/internet-explorer-and-ajax-image-caching-woes 366 367 _response.setContentLength(data.length); 368 369 OutputStream output = _response.getOutputStream(new ContentType(contentType)); 370 371 output.write(data); 372 } 373 374 byte[] getAssetData(IRequestCycle cycle, String resourcePath, 375 URLConnection resourceConnection, String contentType) 376 throws IOException 377 { 378 InputStream input = null; 379 380 try { 381 382 CachedAsset cache; 383 byte[] data = null; 384 385 // check cache first 386 387 if (_cache.get(resourcePath) != null) 388 { 389 cache = (CachedAsset)_cache.get(resourcePath); 390 391 if (cache.getLastModified() < resourceConnection.getLastModified()) 392 cache.clear(resourceConnection.getLastModified()); 393 394 data = cache.getData(); 395 } else 396 { 397 cache = new CachedAsset(resourcePath, resourceConnection.getLastModified(), null, null); 398 399 _cache.put(resourcePath, cache); 400 } 401 402 if (data == null) 403 { 404 input = resourceConnection.getInputStream(); 405 data = IOUtils.toByteArray(input); 406 407 cache.setData(data); 408 } 409 410 // compress javascript responses when possible 411 412 if (GzipUtil.shouldCompressContentType(contentType) && GzipUtil.isGzipCapable(_request)) 413 { 414 if (cache.getGzipData() == null) 415 { 416 ByteArrayOutputStream bo = new ByteArrayOutputStream(); 417 GZIPOutputStream gzip = new GZIPOutputStream(bo); 418 419 gzip.write(data); 420 gzip.close(); 421 422 data = bo.toByteArray(); 423 cache.setGzipData(data); 424 } else 425 data = cache.getGzipData(); 426 427 _response.setHeader("Content-Encoding", "gzip"); 428 } 429 430 return data; 431 432 } finally { 433 434 if (input != null) { 435 IOUtils.closeQuietly(input); 436 } 437 } 438 } 439 440 /** @since 4.0 */ 441 442 public void setExceptionReporter(RequestExceptionReporter exceptionReporter) 443 { 444 _exceptionReporter = exceptionReporter; 445 } 446 447 /** @since 4.0 */ 448 public void setLinkFactory(LinkFactory linkFactory) 449 { 450 _linkFactory = linkFactory; 451 } 452 453 /** @since 4.0 */ 454 public void setClassResolver(ClassResolver classResolver) 455 { 456 _classResolver = classResolver; 457 } 458 459 /** @since 4.0 */ 460 public void setContext(WebContext context) 461 { 462 _context = context; 463 } 464 465 /** @since 4.0 */ 466 public void setResponse(WebResponse response) 467 { 468 _response = response; 469 } 470 471 /** @since 4.0 */ 472 public void setDigestSource(ResourceDigestSource md5Source) 473 { 474 _digestSource = md5Source; 475 } 476 477 /** @since 4.0 */ 478 public void setRequest(WebRequest request) 479 { 480 _request = request; 481 } 482 483 /** @since 4.1 */ 484 public void setUnprotectedMatcher(ResourceMatcher matcher) 485 { 486 _unprotectedMatcher = matcher; 487 } 488 489 public void setLog(Log log) 490 { 491 _log = log; 492 } 493 }