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    }