View Javadoc

1   //
2   //  ========================================================================
3   //  Copyright (c) 1995-2013 Mort Bay Consulting Pty. Ltd.
4   //  ------------------------------------------------------------------------
5   //  All rights reserved. This program and the accompanying materials
6   //  are made available under the terms of the Eclipse Public License v1.0
7   //  and Apache License v2.0 which accompanies this distribution.
8   //
9   //      The Eclipse Public License is available at
10  //      http://www.eclipse.org/legal/epl-v10.html
11  //
12  //      The Apache License v2.0 is available at
13  //      http://www.opensource.org/licenses/apache2.0.php
14  //
15  //  You may elect to redistribute this code under either of these licenses.
16  //  ========================================================================
17  //
18  
19  package org.eclipse.jetty.servlets;
20  
21  import java.io.IOException;
22  import java.util.HashSet;
23  import java.util.Locale;
24  import java.util.Set;
25  import java.util.StringTokenizer;
26  import java.util.regex.Pattern;
27  import java.util.zip.Deflater;
28  import java.util.zip.DeflaterOutputStream;
29  import java.util.zip.GZIPOutputStream;
30  
31  import javax.servlet.FilterChain;
32  import javax.servlet.FilterConfig;
33  import javax.servlet.ServletContext;
34  import javax.servlet.ServletException;
35  import javax.servlet.ServletRequest;
36  import javax.servlet.ServletResponse;
37  import javax.servlet.ServletResponseWrapper;
38  import javax.servlet.http.HttpServletRequest;
39  import javax.servlet.http.HttpServletResponse;
40  import javax.servlet.http.HttpServletResponseWrapper;
41  
42  import org.eclipse.jetty.continuation.Continuation;
43  import org.eclipse.jetty.continuation.ContinuationListener;
44  import org.eclipse.jetty.continuation.ContinuationSupport;
45  import org.eclipse.jetty.http.HttpMethods;
46  import org.eclipse.jetty.http.gzip.CompressedResponseWrapper;
47  import org.eclipse.jetty.http.gzip.AbstractCompressedStream;
48  import org.eclipse.jetty.util.StringUtil;
49  import org.eclipse.jetty.util.log.Log;
50  import org.eclipse.jetty.util.log.Logger;
51  
52  /* ------------------------------------------------------------ */
53  /** GZIP Filter
54   * This filter will gzip or deflate the content of a response if: <ul>
55   * <li>The filter is mapped to a matching path</li>
56   * <li>accept-encoding header is set to either gzip, deflate or a combination of those</li>
57   * <li>The response status code is >=200 and <300
58   * <li>The content length is unknown or more than the <code>minGzipSize</code> initParameter or the minGzipSize is 0(default)</li>
59   * <li>The content-type is in the comma separated list of mimeTypes set in the <code>mimeTypes</code> initParameter or
60   * if no mimeTypes are defined the content-type is not "application/gzip"</li>
61   * <li>No content-encoding is specified by the resource</li>
62   * </ul>
63   * 
64   * <p>
65   * If both gzip and deflate are specified in the accept-encoding header, then gzip will be used.
66   * </p>
67   * <p>
68   * Compressing the content can greatly improve the network bandwidth usage, but at a cost of memory and
69   * CPU cycles. If this filter is mapped for static content, then use of efficient direct NIO may be 
70   * prevented, thus use of the gzip mechanism of the {@link org.eclipse.jetty.servlet.DefaultServlet} is 
71   * advised instead.
72   * </p>
73   * <p>
74   * This filter extends {@link UserAgentFilter} and if the the initParameter <code>excludedAgents</code> 
75   * is set to a comma separated list of user agents, then these agents will be excluded from gzip content.
76   * </p>
77   * <p>Init Parameters:</p>
78   * <PRE>
79   * bufferSize                 The output buffer size. Defaults to 8192. Be careful as values <= 0 will lead to an 
80   *                            {@link IllegalArgumentException}. 
81   *                            See: {@link java.util.zip.GZIPOutputStream#GZIPOutputStream(java.io.OutputStream, int)}
82   *                            and: {@link java.util.zip.DeflaterOutputStream#DeflaterOutputStream(java.io.OutputStream, Deflater, int)}
83   *                      
84   * minGzipSize                Content will only be compressed if content length is either unknown or greater
85   *                            than <code>minGzipSize</code>.
86   *                      
87   * deflateCompressionLevel    The compression level used for deflate compression. (0-9).
88   *                            See: {@link java.util.zip.Deflater#Deflater(int, boolean)}
89   *                            
90   * deflateNoWrap              The noWrap setting for deflate compression. Defaults to true. (true/false)
91   *                            See: {@link java.util.zip.Deflater#Deflater(int, boolean)}
92   *
93   * methods                    Comma separated list of HTTP methods to compress. If not set, only GET requests are compressed.
94   * 
95   * mimeTypes                  Comma separated list of mime types to compress. See description above.
96   * 
97   * excludedAgents             Comma separated list of user agents to exclude from compression. Does a 
98   *                            {@link String#contains(CharSequence)} to check if the excluded agent occurs
99   *                            in the user-agent header. If it does -> no compression
100  *                            
101  * excludeAgentPatterns       Same as excludedAgents, but accepts regex patterns for more complex matching.
102  * 
103  * excludePaths               Comma separated list of paths to exclude from compression. 
104  *                            Does a {@link String#startsWith(String)} comparison to check if the path matches.
105  *                            If it does match -> no compression. To match subpaths use <code>excludePathPatterns</code>
106  *                            instead.
107  * 
108  * excludePathPatterns        Same as excludePath, but accepts regex patterns for more complex matching.
109  * 
110  * vary                       Set to the value of the Vary header sent with responses that could be compressed.  By default it is 
111  *                            set to 'Vary: Accept-Encoding, User-Agent' since IE6 is excluded by default from the excludedAgents. 
112  *                            If user-agents are not to be excluded, then this can be set to 'Vary: Accept-Encoding'.  Note also 
113  *                            that shared caches may cache copies of a resource that is varied by User-Agent - one per variation of 
114  *                            the User-Agent, unless the cache does some normalization of the UA string.
115  * </PRE>
116  */
117 public class GzipFilter extends UserAgentFilter
118 {
119     private static final Logger LOG = Log.getLogger(GzipFilter.class);
120     public final static String GZIP="gzip";
121     public final static String ETAG_GZIP="--gzip\"";
122     public final static String DEFLATE="deflate";
123     public final static String ETAG_DEFLATE="--deflate\"";
124     public final static String ETAG="o.e.j.s.GzipFilter.ETag";
125 
126     protected ServletContext _context;
127     protected Set<String> _mimeTypes;
128     protected int _bufferSize=8192;
129     protected int _minGzipSize=256;
130     protected int _deflateCompressionLevel=Deflater.DEFAULT_COMPRESSION;
131     protected boolean _deflateNoWrap = true;
132 
133     protected final Set<String> _methods=new HashSet<String>();
134     protected Set<String> _excludedAgents;
135     protected Set<Pattern> _excludedAgentPatterns;
136     protected Set<String> _excludedPaths;
137     protected Set<Pattern> _excludedPathPatterns;
138     protected String _vary="Accept-Encoding, User-Agent";
139     
140     private static final int STATE_SEPARATOR = 0;
141     private static final int STATE_Q = 1;
142     private static final int STATE_QVALUE = 2;
143     private static final int STATE_DEFAULT = 3;
144 
145     
146     /* ------------------------------------------------------------ */
147     /**
148      * @see org.eclipse.jetty.servlets.UserAgentFilter#init(javax.servlet.FilterConfig)
149      */
150     @Override
151     public void init(FilterConfig filterConfig) throws ServletException
152     {
153         super.init(filterConfig);
154         
155         _context=filterConfig.getServletContext();
156         
157         String tmp=filterConfig.getInitParameter("bufferSize");
158         if (tmp!=null)
159             _bufferSize=Integer.parseInt(tmp);
160 
161         tmp=filterConfig.getInitParameter("minGzipSize");
162         if (tmp!=null)
163             _minGzipSize=Integer.parseInt(tmp);
164         
165         tmp=filterConfig.getInitParameter("deflateCompressionLevel");
166         if (tmp!=null)
167             _deflateCompressionLevel=Integer.parseInt(tmp);
168         
169         tmp=filterConfig.getInitParameter("deflateNoWrap");
170         if (tmp!=null)
171             _deflateNoWrap=Boolean.parseBoolean(tmp);
172         
173         tmp=filterConfig.getInitParameter("methods");
174         if (tmp!=null)
175         {
176             StringTokenizer tok = new StringTokenizer(tmp,",",false);
177             while (tok.hasMoreTokens())
178                 _methods.add(tok.nextToken().trim().toUpperCase());
179         }
180         else
181             _methods.add(HttpMethods.GET);
182         
183         tmp=filterConfig.getInitParameter("mimeTypes");
184         if (tmp!=null)
185         {
186             _mimeTypes=new HashSet<String>();
187             StringTokenizer tok = new StringTokenizer(tmp,",",false);
188             while (tok.hasMoreTokens())
189                 _mimeTypes.add(tok.nextToken());
190         }
191         tmp=filterConfig.getInitParameter("excludedAgents");
192         if (tmp!=null)
193         {
194             _excludedAgents=new HashSet<String>();
195             StringTokenizer tok = new StringTokenizer(tmp,",",false);
196             while (tok.hasMoreTokens())
197                _excludedAgents.add(tok.nextToken());
198         }
199         
200                 tmp=filterConfig.getInitParameter("excludeAgentPatterns");
201         if (tmp!=null)
202         {
203             _excludedAgentPatterns=new HashSet<Pattern>();
204             StringTokenizer tok = new StringTokenizer(tmp,",",false);
205             while (tok.hasMoreTokens())
206                 _excludedAgentPatterns.add(Pattern.compile(tok.nextToken()));            
207         }        
208         
209         tmp=filterConfig.getInitParameter("excludePaths");
210         if (tmp!=null)
211         {
212             _excludedPaths=new HashSet<String>();
213             StringTokenizer tok = new StringTokenizer(tmp,",",false);
214             while (tok.hasMoreTokens())
215                 _excludedPaths.add(tok.nextToken());            
216         }
217         
218         tmp=filterConfig.getInitParameter("excludePathPatterns");
219         if (tmp!=null)
220         {
221             _excludedPathPatterns=new HashSet<Pattern>();
222             StringTokenizer tok = new StringTokenizer(tmp,",",false);
223             while (tok.hasMoreTokens())
224                 _excludedPathPatterns.add(Pattern.compile(tok.nextToken()));            
225         }       
226         
227         tmp=filterConfig.getInitParameter("vary");
228         if (tmp!=null)
229             _vary=tmp;
230     }
231 
232     /* ------------------------------------------------------------ */
233     /**
234      * @see org.eclipse.jetty.servlets.UserAgentFilter#destroy()
235      */
236     @Override
237     public void destroy()
238     {
239     }
240     
241     /* ------------------------------------------------------------ */
242     /**
243      * @see org.eclipse.jetty.servlets.UserAgentFilter#doFilter(javax.servlet.ServletRequest, javax.servlet.ServletResponse, javax.servlet.FilterChain)
244      */
245     @Override
246     public void doFilter(ServletRequest req, ServletResponse res, FilterChain chain) 
247         throws IOException, ServletException
248     {
249         HttpServletRequest request=(HttpServletRequest)req;
250         HttpServletResponse response=(HttpServletResponse)res;
251 
252         // If not a supported method or it is an Excluded URI - no Vary because no matter what client, this URI is always excluded
253         String requestURI = request.getRequestURI();
254         if (!_methods.contains(request.getMethod()) || isExcludedPath(requestURI))
255         {
256             super.doFilter(request,response,chain);
257             return;
258         }
259         
260         // Exclude non compressible mime-types known from URI extension. - no Vary because no matter what client, this URI is always excluded
261         if (_mimeTypes!=null && _mimeTypes.size()>0)
262         {
263             String mimeType = _context.getMimeType(request.getRequestURI());
264             
265             if (mimeType!=null && !_mimeTypes.contains(mimeType))
266             {
267                 // handle normally without setting vary header
268                 super.doFilter(request,response,chain);
269                 return;
270             }
271         }
272         
273         // Excluded User-Agents
274         String ua = getUserAgent(request);
275         boolean ua_excluded=ua!=null&&isExcludedAgent(ua);
276         
277         // Acceptable compression type
278         String compressionType = ua_excluded?null:selectCompression(request.getHeader("accept-encoding"));
279         
280         // Special handling for etags
281         String etag = request.getHeader("If-None-Match"); 
282         if (etag!=null)
283         {
284             int dd=etag.indexOf("--");
285             if (dd>0)
286                 request.setAttribute(ETAG,etag.substring(0,dd)+(etag.endsWith("\"")?"\"":""));
287         }
288 
289         CompressedResponseWrapper wrappedResponse = createWrappedResponse(request,response,compressionType);
290 
291         boolean exceptional=true;
292         try
293         {
294             super.doFilter(request,wrappedResponse,chain);
295             exceptional=false;
296         }
297         finally
298         {
299             Continuation continuation = ContinuationSupport.getContinuation(request);
300             if (continuation.isSuspended() && continuation.isResponseWrapped())   
301             {
302                 continuation.addContinuationListener(new ContinuationListenerWaitingForWrappedResponseToFinish(wrappedResponse));
303             }
304             else if (exceptional && !response.isCommitted())
305             {
306                 wrappedResponse.resetBuffer();
307                 wrappedResponse.noCompression();
308             }
309             else
310                 wrappedResponse.finish();
311         }
312     }
313 
314     /* ------------------------------------------------------------ */
315     private String selectCompression(String encodingHeader)
316     {
317         // TODO, this could be a little more robust.
318         // prefer gzip over deflate
319         String compression = null;
320         if (encodingHeader!=null)
321         {
322             
323             String[] encodings = getEncodings(encodingHeader);
324             if (encodings != null)
325             {
326                 for (int i=0; i< encodings.length; i++)
327                 {
328                     if (encodings[i].toLowerCase(Locale.ENGLISH).contains(GZIP))
329                     {
330                         if (isEncodingAcceptable(encodings[i]))
331                         {
332                             compression = GZIP;
333                             break; //prefer Gzip over deflate
334                         }
335                     }
336 
337                     if (encodings[i].toLowerCase(Locale.ENGLISH).contains(DEFLATE))
338                     {
339                         if (isEncodingAcceptable(encodings[i]))
340                         {
341                             compression = DEFLATE; //Keep checking in case gzip is acceptable
342                         }
343                     }
344                 }
345             }
346         }
347         return compression;
348     }
349     
350     
351     private String[] getEncodings (String encodingHeader)
352     {
353         if (encodingHeader == null)
354             return null;
355         return encodingHeader.split(",");
356     }
357     
358     private boolean isEncodingAcceptable(String encoding)
359     {    
360         int state = STATE_DEFAULT;
361         int qvalueIdx = -1;
362         for (int i=0;i<encoding.length();i++)
363         {
364             char c = encoding.charAt(i);
365             switch (state)
366             {
367                 case STATE_DEFAULT:
368                 {
369                     if (';' == c)
370                         state = STATE_SEPARATOR;
371                     break;
372                 }
373                 case STATE_SEPARATOR:
374                 {
375                     if ('q' == c || 'Q' == c)
376                         state = STATE_Q;
377                     break;
378                 }
379                 case STATE_Q:
380                 {
381                     if ('=' == c)
382                         state = STATE_QVALUE;
383                     break;
384                 }
385                 case STATE_QVALUE:
386                 {
387                     if (qvalueIdx < 0 && '0' == c || '1' == c)
388                         qvalueIdx = i;
389                     break;
390                 }
391             }
392         }
393         
394         if (qvalueIdx < 0)
395             return true;
396                
397         if ("0".equals(encoding.substring(qvalueIdx).trim()))
398             return false;
399         return true;
400     }
401     
402     
403     protected CompressedResponseWrapper createWrappedResponse(HttpServletRequest request, HttpServletResponse response, final String compressionType)
404     {
405         CompressedResponseWrapper wrappedResponse = null;
406         if (compressionType==null)
407         {
408             wrappedResponse = new CompressedResponseWrapper(request,response)
409             {
410                 @Override
411                 protected AbstractCompressedStream newCompressedStream(HttpServletRequest request,HttpServletResponse response) throws IOException
412                 {
413                     return new AbstractCompressedStream(null,request,this,_vary)
414                     {
415                         @Override
416                         protected DeflaterOutputStream createStream() throws IOException
417                         {
418                             return null;
419                         }
420                     };
421                 }
422             };
423         }
424         else if (compressionType.equals(GZIP))
425         {
426             wrappedResponse = new CompressedResponseWrapper(request,response)
427             {
428                 @Override
429                 protected AbstractCompressedStream newCompressedStream(HttpServletRequest request,HttpServletResponse response) throws IOException
430                 {
431                     return new AbstractCompressedStream(compressionType,request,this,_vary)
432                     {
433                         @Override
434                         protected DeflaterOutputStream createStream() throws IOException
435                         {
436                             return new GZIPOutputStream(_response.getOutputStream(),_bufferSize);
437                         }
438                     };
439                 }
440             };
441         }
442         else if (compressionType.equals(DEFLATE))
443         {
444             wrappedResponse = new CompressedResponseWrapper(request,response)
445             {
446                 @Override
447                 protected AbstractCompressedStream newCompressedStream(HttpServletRequest request,HttpServletResponse response) throws IOException
448                 {
449                     return new AbstractCompressedStream(compressionType,request,this,_vary)
450                     {
451                         @Override
452                         protected DeflaterOutputStream createStream() throws IOException
453                         {
454                             return new DeflaterOutputStream(_response.getOutputStream(),new Deflater(_deflateCompressionLevel,_deflateNoWrap));
455                         }
456                     };
457                 }
458             };
459         } 
460         else
461         {
462             throw new IllegalStateException(compressionType + " not supported");
463         }
464         configureWrappedResponse(wrappedResponse);
465         return wrappedResponse;
466     }
467 
468     protected void configureWrappedResponse(CompressedResponseWrapper wrappedResponse)
469     {
470         wrappedResponse.setMimeTypes(_mimeTypes);
471         wrappedResponse.setBufferSize(_bufferSize);
472         wrappedResponse.setMinCompressSize(_minGzipSize);
473     }
474      
475     private class ContinuationListenerWaitingForWrappedResponseToFinish implements ContinuationListener
476     {    
477         private CompressedResponseWrapper wrappedResponse;
478 
479         public ContinuationListenerWaitingForWrappedResponseToFinish(CompressedResponseWrapper wrappedResponse)
480         {
481             this.wrappedResponse = wrappedResponse;
482         }
483 
484         public void onComplete(Continuation continuation)
485         {
486             try
487             {
488                 wrappedResponse.finish();
489             }
490             catch (IOException e)
491             {
492                 LOG.warn(e);
493             }
494         }
495 
496         public void onTimeout(Continuation continuation)
497         {
498         }
499     }
500     
501     /**
502      * Checks to see if the userAgent is excluded
503      * 
504      * @param ua
505      *            the user agent
506      * @return boolean true if excluded
507      */
508     private boolean isExcludedAgent(String ua)
509     {
510         if (ua == null)
511             return false;
512 
513         if (_excludedAgents != null)
514         {
515             if (_excludedAgents.contains(ua))
516             {
517                 return true;
518             }
519         }
520         if (_excludedAgentPatterns != null)
521         {
522             for (Pattern pattern : _excludedAgentPatterns)
523             {
524                 if (pattern.matcher(ua).matches())
525                 {
526                     return true;
527                 }
528             }
529         }
530 
531         return false;
532     }
533 
534     /**
535      * Checks to see if the path is excluded
536      * 
537      * @param requestURI
538      *            the request uri
539      * @return boolean true if excluded
540      */
541     private boolean isExcludedPath(String requestURI)
542     {
543         if (requestURI == null)
544             return false;
545         if (_excludedPaths != null)
546         {
547             for (String excludedPath : _excludedPaths)
548             {
549                 if (requestURI.startsWith(excludedPath))
550                 {
551                     return true;
552                 }
553             }
554         }
555         if (_excludedPathPatterns != null)
556         {
557             for (Pattern pattern : _excludedPathPatterns)
558             {
559                 if (pattern.matcher(requestURI).matches())
560                 {
561                     return true;
562                 }
563             }
564         }
565         return false;
566     }
567 }