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 }