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    }