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 = cycle.getParameter(PATH); 214 String md5Digest = cycle.getParameter(DIGEST); 215 boolean checkDigest = !_unprotectedMatcher.containsResource(path); 216 217 URLConnection resourceConnection; 218 219 try 220 { 221 if (checkDigest && !_digestSource.getDigestForResource(path).equals(md5Digest)) 222 { 223 _response.sendError(HttpServletResponse.SC_FORBIDDEN, AssetMessages.md5Mismatch(path)); 224 return; 225 } 226 227 // If they were vended an asset in the past then it must be up-to date. 228 // Asset URIs change if the underlying file is modified. (unless unprotected) 229 230 if (checkDigest && _request.getHeader("If-Modified-Since") != null) 231 { 232 _response.setStatus(HttpServletResponse.SC_NOT_MODIFIED); 233 return; 234 } 235 236 URL resourceURL = _classResolver.getResource(translatePath(path)); 237 238 if (resourceURL == null) 239 { 240 _response.setStatus(HttpServletResponse.SC_NOT_FOUND); 241 _log.warn(AssetMessages.noSuchResource(path)); 242 return; 243 } 244 245 resourceConnection = resourceURL.openConnection(); 246 247 // check caching for unprotected resources 248 249 if (!checkDigest && cachedResource(resourceConnection)) 250 return; 251 252 writeAssetContent(cycle, path, resourceConnection); 253 } 254 catch (IOException eof) 255 { 256 // ignored / expected exceptions happen when browser prematurely abandons connections - IE does this a lot 257 } 258 catch (Throwable ex) 259 { 260 _exceptionReporter.reportRequestException(AssetMessages.exceptionReportTitle(path), ex); 261 } 262 } 263 264 /** 265 * Utility that helps to resolve css file relative resources included 266 * in a css temlpate via "url('../images/foo.gif')" or fix paths containing 267 * relative resource ".." style notation. 268 * 269 * @param path The incoming path to check for relativity. 270 * @return The path unchanged if not containing a css relative path, otherwise 271 * returns the path without the css filename in it so the resource is resolvable 272 * directly from the path. 273 */ 274 String translatePath(String path) 275 { 276 if (path == null) 277 return null; 278 279 String ret = FilenameUtils.normalize(path); 280 ret = FilenameUtils.separatorsToUnix(ret); 281 282 return ret; 283 } 284 285 /** 286 * Checks if the resource contained within the specified URL 287 * has a modified time greater than the request header value 288 * of <code>If-Modified-Since</code>. If it doesn't then the 289 * response status is set to {@link HttpServletResponse#SC_NOT_MODIFIED}. 290 * 291 * @param resourceURL Resource being checked 292 * @return True if resource should be cached and response header was set. 293 * @since 4.1 294 */ 295 296 boolean cachedResource(URLConnection resourceURL) 297 { 298 // even if it doesn't exist in header the value will be -1, 299 // which means we need to write out the contents of the resource 300 301 long modifiedSince = _request.getDateHeader("If-Modified-Since"); 302 303 if (modifiedSince <= 0) 304 return false; 305 306 if (_log.isDebugEnabled()) 307 _log.debug("cachedResource(" + resourceURL.getURL() + ") modified-since header is: " + modifiedSince); 308 309 if (resourceURL.getLastModified() > modifiedSince) 310 return false; 311 312 _response.setStatus(HttpServletResponse.SC_NOT_MODIFIED); 313 314 return true; 315 } 316 317 /** 318 * Writes the asset specified by <code>resourceConnection</code> out to the response stream. 319 * 320 * @param cycle 321 * The current request. 322 * @param resourcePath 323 * The path of the resource. 324 * @param resourceConnection 325 * A connection for the resource. 326 * @throws IOException On error. 327 */ 328 329 private void writeAssetContent(IRequestCycle cycle, String resourcePath, URLConnection resourceConnection) 330 throws IOException 331 { 332 // Getting the content type and length is very dependant 333 // on support from the application server (represented 334 // here by the servletContext). 335 336 String contentType = getMimeType(resourcePath); 337 338 long lastModified = resourceConnection.getLastModified(); 339 if (lastModified <= 0) 340 lastModified = _startupTime; 341 342 _response.setDateHeader("Last-Modified", lastModified); 343 344 // write out expiration/cache info 345 346 _response.setDateHeader("Expires", _expireTime); 347 _response.setHeader("Cache-Control", "public, max-age=" + (MONTH_SECONDS * 3)); 348 349 // Set the content type. If the servlet container doesn't 350 // provide it, try and guess it by the extension. 351 352 if (contentType == null || contentType.length() == 0) 353 contentType = getMimeType(resourcePath); 354 355 byte[] data = getAssetData(cycle, resourcePath, resourceConnection, contentType); 356 357 // See ETag definition - http://www.w3.org/Protocols/rfc2616/rfc2616-sec14.html#sec14.19 358 359 _response.setHeader("ETag", "W/\"" + data.length + "-" + lastModified + "\""); 360 361 // force image(or other) caching when detected, esp helps with ie related things 362 // see http://mir.aculo.us/2005/08/28/internet-explorer-and-ajax-image-caching-woes 363 364 _response.setContentLength(data.length); 365 366 OutputStream output = _response.getOutputStream(new ContentType(contentType)); 367 368 output.write(data); 369 } 370 371 byte[] getAssetData(IRequestCycle cycle, String resourcePath, 372 URLConnection resourceConnection, String contentType) 373 throws IOException 374 { 375 InputStream input = null; 376 377 try { 378 379 CachedAsset cache; 380 byte[] data = null; 381 382 // check cache first 383 384 if (_cache.get(resourcePath) != null) 385 { 386 cache = (CachedAsset)_cache.get(resourcePath); 387 388 if (cache.getLastModified() < resourceConnection.getLastModified()) 389 cache.clear(resourceConnection.getLastModified()); 390 391 data = cache.getData(); 392 } else 393 { 394 cache = new CachedAsset(resourcePath, resourceConnection.getLastModified(), null, null); 395 396 _cache.put(resourcePath, cache); 397 } 398 399 if (data == null) 400 { 401 input = resourceConnection.getInputStream(); 402 data = IOUtils.toByteArray(input); 403 404 cache.setData(data); 405 } 406 407 // compress javascript responses when possible 408 409 if (GzipUtil.shouldCompressContentType(contentType) && GzipUtil.isGzipCapable(_request)) 410 { 411 if (cache.getGzipData() == null) 412 { 413 ByteArrayOutputStream bo = new ByteArrayOutputStream(); 414 GZIPOutputStream gzip = new GZIPOutputStream(bo); 415 416 gzip.write(data); 417 gzip.close(); 418 419 data = bo.toByteArray(); 420 cache.setGzipData(data); 421 } else 422 data = cache.getGzipData(); 423 424 _response.setHeader("Content-Encoding", "gzip"); 425 } 426 427 return data; 428 429 } finally { 430 431 if (input != null) { 432 IOUtils.closeQuietly(input); 433 } 434 } 435 } 436 437 /** @since 4.0 */ 438 439 public void setExceptionReporter(RequestExceptionReporter exceptionReporter) 440 { 441 _exceptionReporter = exceptionReporter; 442 } 443 444 /** @since 4.0 */ 445 public void setLinkFactory(LinkFactory linkFactory) 446 { 447 _linkFactory = linkFactory; 448 } 449 450 /** @since 4.0 */ 451 public void setClassResolver(ClassResolver classResolver) 452 { 453 _classResolver = classResolver; 454 } 455 456 /** @since 4.0 */ 457 public void setContext(WebContext context) 458 { 459 _context = context; 460 } 461 462 /** @since 4.0 */ 463 public void setResponse(WebResponse response) 464 { 465 _response = response; 466 } 467 468 /** @since 4.0 */ 469 public void setDigestSource(ResourceDigestSource md5Source) 470 { 471 _digestSource = md5Source; 472 } 473 474 /** @since 4.0 */ 475 public void setRequest(WebRequest request) 476 { 477 _request = request; 478 } 479 480 /** @since 4.1 */ 481 public void setUnprotectedMatcher(ResourceMatcher matcher) 482 { 483 _unprotectedMatcher = matcher; 484 } 485 486 public void setLog(Log log) 487 { 488 _log = log; 489 } 490 }