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 java.io.BufferedInputStream;
018 import java.io.File;
019 import java.io.IOException;
020 import java.io.InputStream;
021 import java.io.OutputStream;
022 import java.net.URL;
023 import java.net.URLConnection;
024 import java.text.DateFormat;
025 import java.text.ParseException;
026 import java.text.SimpleDateFormat;
027 import java.util.HashMap;
028 import java.util.Locale;
029 import java.util.Map;
030 import java.util.TreeMap;
031
032 import javax.servlet.http.HttpServletResponse;
033
034 import org.apache.commons.io.FilenameUtils;
035 import org.apache.commons.logging.Log;
036 import org.apache.hivemind.ClassResolver;
037 import org.apache.hivemind.util.Defense;
038 import org.apache.hivemind.util.IOUtils;
039 import org.apache.tapestry.IRequestCycle;
040 import org.apache.tapestry.Tapestry;
041 import org.apache.tapestry.engine.IEngineService;
042 import org.apache.tapestry.engine.ILink;
043 import org.apache.tapestry.error.RequestExceptionReporter;
044 import org.apache.tapestry.event.ResetEventListener;
045 import org.apache.tapestry.services.LinkFactory;
046 import org.apache.tapestry.services.ServiceConstants;
047 import org.apache.tapestry.util.ContentType;
048 import org.apache.tapestry.web.WebContext;
049 import org.apache.tapestry.web.WebRequest;
050 import org.apache.tapestry.web.WebResponse;
051
052 /**
053 * A service for building URLs to and accessing {@link org.apache.tapestry.IAsset}s. Most of the
054 * work is deferred to the {@link org.apache.tapestry.IAsset}instance.
055 * <p>
056 * The retrieval part is directly linked to {@link PrivateAsset}. The service responds to a URL
057 * that encodes the path of a resource within the classpath. The {@link #service(IRequestCycle)}
058 * method reads the resource and streams it out.
059 * <p>
060 * TBD: Security issues. Should only be able to retrieve a resource that was previously registerred
061 * in some way ... otherwise, hackers will be able to suck out the .class files of the application!
062 *
063 * @author Howard Lewis Ship
064 */
065
066 public class AssetService implements IEngineService, ResetEventListener
067 {
068 /**
069 * Query parameter that stores the path to the resource (with a leading slash).
070 *
071 * @since 4.0
072 */
073
074 public static final String PATH = "path";
075
076 /**
077 * Query parameter that stores the digest for the file; this is used to authenticate that the
078 * client is allowed to access the file.
079 *
080 * @since 4.0
081 */
082
083 public static final String DIGEST = "digest";
084
085 /**
086 * Defaults MIME types, by extension, used when the servlet container doesn't provide MIME
087 * types. ServletExec Debugger, for example, fails to provide these.
088 */
089
090 private static final Map _mimeTypes;
091
092 static
093 {
094 _mimeTypes = new HashMap(17);
095 _mimeTypes.put("css", "text/css");
096 _mimeTypes.put("gif", "image/gif");
097 _mimeTypes.put("jpg", "image/jpeg");
098 _mimeTypes.put("jpeg", "image/jpeg");
099 _mimeTypes.put("htm", "text/html");
100 _mimeTypes.put("html", "text/html");
101 }
102
103 private static final int BUFFER_SIZE = 10240;
104
105 private static final DateFormat CACHED_FORMAT = new SimpleDateFormat("EEE, d MMM yyyy HH:mm:ss Z", Locale.ENGLISH);
106
107 private Log _log;
108
109 /** @since 4.0 */
110 private ClassResolver _classResolver;
111
112 /** @since 4.0 */
113 private LinkFactory _linkFactory;
114
115 /** @since 4.0 */
116 private WebContext _context;
117
118 /** @since 4.0 */
119
120 private WebRequest _request;
121
122 /** @since 4.0 */
123 private WebResponse _response;
124
125 /** @since 4.0 */
126 private ResourceDigestSource _digestSource;
127
128 /** @since 4.1 */
129 private ResourceMatcher _unprotectedMatcher;
130
131 /**
132 * Startup time for this service; used to set the Last-Modified response header.
133 *
134 * @since 4.0
135 */
136
137 private final long _startupTime = System.currentTimeMillis();
138
139 /**
140 * Time vended assets expire. Since a change in asset content is a change in asset URI, we want
141 * them to not expire ... but a year will do.
142 */
143
144 private final long _expireTime = _startupTime + 365 * 24 * 60 * 60 * 1000L;
145
146 /** @since 4.0 */
147
148 private RequestExceptionReporter _exceptionReporter;
149
150 /** Used to prevent caching of resources when in disabled caching mode. */
151
152 private long _lastResetTime = -1;
153
154 /**
155 * {@inheritDoc}
156 */
157 public void resetEventDidOccur()
158 {
159 _lastResetTime = System.currentTimeMillis();
160 }
161
162 /**
163 * Builds a {@link ILink}for a {@link PrivateAsset}.
164 * <p>
165 * A single parameter is expected, the resource path of the asset (which is expected to start
166 * with a leading slash).
167 */
168
169 public ILink getLink(boolean post, Object parameter)
170 {
171 Defense.isAssignable(parameter, String.class, "parameter");
172
173 String path = (String) parameter;
174
175 String digest = null;
176
177 if(!_unprotectedMatcher.containsResource(path))
178 digest = _digestSource.getDigestForResource(path);
179
180 Map parameters = new TreeMap(new AssetComparator());
181
182 parameters.put(ServiceConstants.SERVICE, getName());
183 parameters.put(PATH, path);
184
185 if (digest != null)
186 parameters.put(DIGEST, digest);
187
188 // Service is stateless, which is the exception to the rule.
189
190 return _linkFactory.constructLink(this, post, parameters, false);
191 }
192
193 public String getName()
194 {
195 return Tapestry.ASSET_SERVICE;
196 }
197
198 private String getMimeType(String path)
199 {
200 String result = _context.getMimeType(path);
201
202 if (result == null)
203 {
204 int dotx = path.lastIndexOf('.');
205 if (dotx > -1) {
206 String key = path.substring(dotx + 1).toLowerCase();
207 result = (String) _mimeTypes.get(key);
208 }
209
210 if (result == null)
211 result = "text/plain";
212 }
213
214 return result;
215 }
216
217 /**
218 * Retrieves a resource from the classpath and returns it to the client in a binary output
219 * stream.
220 */
221
222 public void service(IRequestCycle cycle) throws IOException
223 {
224 String path = cycle.getParameter(PATH);
225 String md5Digest = cycle.getParameter(DIGEST);
226 boolean checkDigest = !_unprotectedMatcher.containsResource(path);
227
228 try
229 {
230 if (checkDigest
231 && !_digestSource.getDigestForResource(path).equals(md5Digest))
232 {
233 _response.sendError(HttpServletResponse.SC_FORBIDDEN, AssetMessages
234 .md5Mismatch(path));
235 return;
236 }
237
238 // If they were vended an asset in the past then it must be up-to date.
239 // Asset URIs change if the underlying file is modified. (unless unprotected)
240
241 if (checkDigest && _request.getHeader("If-Modified-Since") != null)
242 {
243 _response.setStatus(HttpServletResponse.SC_NOT_MODIFIED);
244 return;
245 }
246
247 URL resourceURL = _classResolver.getResource(translatePath(path));
248
249 if (resourceURL == null) {
250 _response.setStatus(HttpServletResponse.SC_NOT_FOUND);
251 _log.warn(AssetMessages.noSuchResource(path));
252 return;
253 // throw new ApplicationRuntimeException(AssetMessages.noSuchResource(path));
254 }
255
256 //check caching for unprotected resources
257
258 if (!checkDigest && cachedResource(resourceURL))
259 return;
260
261 URLConnection resourceConnection = resourceURL.openConnection();
262
263 writeAssetContent(cycle, path, resourceConnection);
264 }
265 catch (Throwable ex)
266 {
267 _exceptionReporter.reportRequestException(AssetMessages.exceptionReportTitle(path), ex);
268 }
269
270 }
271
272 /**
273 * Utility that helps to resolve css file relative resources included
274 * in a css temlpate via "url('../images/foo.gif')" or fix paths containing
275 * relative resource ".." style notation.
276 *
277 * @param path The incoming path to check for relativity.
278 * @return The path unchanged if not containing a css relative path, otherwise
279 * returns the path without the css filename in it so the resource is resolvable
280 * directly from the path.
281 */
282 String translatePath(String path)
283 {
284 if (path == null)
285 return null;
286
287 String tpath = translateCssPath(path);
288
289 String ret = FilenameUtils.normalize(tpath);
290 ret = FilenameUtils.separatorsToUnix(ret);
291
292 return ret;
293 }
294
295 /**
296 * Fixes any paths containing .css extension relative references.
297 *
298 * @param path The path to fix.
299 * @return The absolute path to the resource referenced in the path. (if any)
300 */
301 private String translateCssPath(String path) {
302
303 // don't parse out actual css files
304 if (path.endsWith(".css"))
305 return path;
306
307 int index = path.lastIndexOf(".css");
308 if (index <= -1)
309 return path;
310
311 // now need to parse out whatever css file was referenced to get the real path
312 int pathEnd = path.lastIndexOf("/", index);
313 if (pathEnd <= -1)
314 return path;
315
316 return path.substring(0, pathEnd + 1) + path.substring(index + 4, path.length());
317 }
318
319 /**
320 * Checks if the resource contained within the specified URL
321 * has a modified time greater than the request header value
322 * of <code>If-Modified-Since</code>. If it doesn't then the
323 * response status is set to {@link HttpServletResponse#SC_NOT_MODIFIED}.
324 *
325 * @param resourceURL Resource being checked
326 * @return True if resource should be cached and response header was set.
327 * @since 4.1
328 */
329
330 boolean cachedResource(URL resourceURL)
331 {
332 File resource = new File(resourceURL.getFile());
333 if (!resource.exists())
334 return false;
335
336 //even if it doesn't exist in header the value will be -1,
337 //which means we need to write out the contents of the resource
338
339 String header = _request.getHeader("If-Modified-Since");
340 long modify = -1;
341
342 try {
343 if (header != null)
344 modify = CACHED_FORMAT.parse(header).getTime();
345 } catch (ParseException e) { e.printStackTrace(); }
346
347 if (resource.lastModified() > modify
348 || (_lastResetTime > modify))
349 return false;
350
351 _response.setStatus(HttpServletResponse.SC_NOT_MODIFIED);
352 return true;
353 }
354
355 /** @since 2.2 */
356
357 private void writeAssetContent(IRequestCycle cycle, String resourcePath,
358 URLConnection resourceConnection) throws IOException
359 {
360 InputStream input = null;
361
362 try
363 {
364 // Getting the content type and length is very dependant
365 // on support from the application server (represented
366 // here by the servletContext).
367
368 String contentType = getMimeType(resourcePath);
369 int contentLength = resourceConnection.getContentLength();
370
371 if (contentLength > 0)
372 _response.setContentLength(contentLength);
373
374 _response.setDateHeader("Last-Modified", _startupTime);
375 _response.setDateHeader("Expires", _expireTime);
376
377 // Set the content type. If the servlet container doesn't
378 // provide it, try and guess it by the extension.
379
380 if (contentType == null || contentType.length() == 0)
381 contentType = getMimeType(resourcePath);
382
383 OutputStream output = _response.getOutputStream(new ContentType(contentType));
384
385 input = new BufferedInputStream(resourceConnection.getInputStream());
386
387 byte[] buffer = new byte[BUFFER_SIZE];
388
389 while (true)
390 {
391 int bytesRead = input.read(buffer);
392
393 if (bytesRead < 0)
394 break;
395
396 output.write(buffer, 0, bytesRead);
397 }
398
399 input.close();
400 input = null;
401 }
402 finally
403 {
404 IOUtils.close(input);
405 }
406 }
407
408 /** @since 4.0 */
409
410 public void setExceptionReporter(RequestExceptionReporter exceptionReporter)
411 {
412 _exceptionReporter = exceptionReporter;
413 }
414
415 /** @since 4.0 */
416 public void setLinkFactory(LinkFactory linkFactory)
417 {
418 _linkFactory = linkFactory;
419 }
420
421 /** @since 4.0 */
422 public void setClassResolver(ClassResolver classResolver)
423 {
424 _classResolver = classResolver;
425 }
426
427 /** @since 4.0 */
428 public void setContext(WebContext context)
429 {
430 _context = context;
431 }
432
433 /** @since 4.0 */
434 public void setResponse(WebResponse response)
435 {
436 _response = response;
437 }
438
439 /** @since 4.0 */
440 public void setDigestSource(ResourceDigestSource md5Source)
441 {
442 _digestSource = md5Source;
443 }
444
445 /** @since 4.0 */
446 public void setRequest(WebRequest request)
447 {
448 _request = request;
449 }
450
451 /** @since 4.1 */
452 public void setUnprotectedMatcher(ResourceMatcher matcher)
453 {
454 _unprotectedMatcher = matcher;
455 }
456
457 public void setLog(Log log)
458 {
459 _log = log;
460 }
461 }