View Javadoc

1   //
2   //  ========================================================================
3   //  Copyright (c) 1995-2012 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.ServletException;
34  import javax.servlet.ServletRequest;
35  import javax.servlet.ServletResponse;
36  import javax.servlet.http.HttpServletRequest;
37  import javax.servlet.http.HttpServletResponse;
38  
39  import org.eclipse.jetty.continuation.Continuation;
40  import org.eclipse.jetty.continuation.ContinuationListener;
41  import org.eclipse.jetty.continuation.ContinuationSupport;
42  import org.eclipse.jetty.http.HttpMethods;
43  import org.eclipse.jetty.http.gzip.CompressedResponseWrapper;
44  import org.eclipse.jetty.http.gzip.AbstractCompressedStream;
45  import org.eclipse.jetty.util.log.Log;
46  import org.eclipse.jetty.util.log.Logger;
47  
48  /* ------------------------------------------------------------ */
49  /** GZIP Filter
50   * This filter will gzip or deflate the content of a response if: <ul>
51   * <li>The filter is mapped to a matching path</li>
52   * <li>accept-encoding header is set to either gzip, deflate or a combination of those</li>
53   * <li>The response status code is >=200 and <300
54   * <li>The content length is unknown or more than the <code>minGzipSize</code> initParameter or the minGzipSize is 0(default)</li>
55   * <li>The content-type is in the comma separated list of mimeTypes set in the <code>mimeTypes</code> initParameter or
56   * if no mimeTypes are defined the content-type is not "application/gzip"</li>
57   * <li>No content-encoding is specified by the resource</li>
58   * </ul>
59   * 
60   * <p>
61   * If both gzip and deflate are specified in the accept-encoding header, then gzip will be used.
62   * </p>
63   * <p>
64   * Compressing the content can greatly improve the network bandwidth usage, but at a cost of memory and
65   * CPU cycles. If this filter is mapped for static content, then use of efficient direct NIO may be 
66   * prevented, thus use of the gzip mechanism of the {@link org.eclipse.jetty.servlet.DefaultServlet} is 
67   * advised instead.
68   * </p>
69   * <p>
70   * This filter extends {@link UserAgentFilter} and if the the initParameter <code>excludedAgents</code> 
71   * is set to a comma separated list of user agents, then these agents will be excluded from gzip content.
72   * </p>
73   * <p>Init Parameters:</p>
74   * <PRE>
75   * bufferSize                 The output buffer size. Defaults to 8192. Be careful as values <= 0 will lead to an 
76   *                            {@link IllegalArgumentException}. 
77   *                            See: {@link java.util.zip.GZIPOutputStream#GZIPOutputStream(java.io.OutputStream, int)}
78   *                            and: {@link java.util.zip.DeflaterOutputStream#DeflaterOutputStream(java.io.OutputStream, Deflater, int)}
79   *                      
80   * minGzipSize                Content will only be compressed if content length is either unknown or greater
81   *                            than <code>minGzipSize</code>.
82   *                      
83   * deflateCompressionLevel    The compression level used for deflate compression. (0-9).
84   *                            See: {@link java.util.zip.Deflater#Deflater(int, boolean)}
85   *                            
86   * deflateNoWrap              The noWrap setting for deflate compression. Defaults to true. (true/false)
87   *                            See: {@link java.util.zip.Deflater#Deflater(int, boolean)}
88   *
89   * mimeTypes                  Comma separated list of mime types to compress. See description above.
90   * 
91   * excludedAgents             Comma separated list of user agents to exclude from compression. Does a 
92   *                            {@link String#contains(CharSequence)} to check if the excluded agent occurs
93   *                            in the user-agent header. If it does -> no compression
94   *                            
95   * excludeAgentPatterns       Same as excludedAgents, but accepts regex patterns for more complex matching.
96   * 
97   * excludePaths               Comma separated list of paths to exclude from compression. 
98   *                            Does a {@link String#startsWith(String)} comparison to check if the path matches.
99   *                            If it does match -> no compression. To match subpaths use <code>excludePathPatterns</code>
100  *                            instead.
101  * 
102  * excludePathPatterns        Same as excludePath, but accepts regex patterns for more complex matching.
103  * </PRE>
104  */
105 public class GzipFilter extends UserAgentFilter
106 {
107     private static final Logger LOG = Log.getLogger(GzipFilter.class);
108     public final static String GZIP="gzip";
109     public final static String DEFLATE="deflate";
110     
111 
112     protected Set<String> _mimeTypes;
113     protected int _bufferSize=8192;
114     protected int _minGzipSize=256;
115     protected int _deflateCompressionLevel=Deflater.DEFAULT_COMPRESSION;
116     protected boolean _deflateNoWrap = true;
117     protected Set<String> _excludedAgents;
118     protected Set<Pattern> _excludedAgentPatterns;
119     protected Set<String> _excludedPaths;
120     protected Set<Pattern> _excludedPathPatterns;
121     
122     private static final int STATE_SEPARATOR = 0;
123     private static final int STATE_Q = 1;
124     private static final int STATE_QVALUE = 2;
125     private static final int STATE_DEFAULT = 3;
126 
127     
128     /* ------------------------------------------------------------ */
129     /**
130      * @see org.eclipse.jetty.servlets.UserAgentFilter#init(javax.servlet.FilterConfig)
131      */
132     @Override
133     public void init(FilterConfig filterConfig) throws ServletException
134     {
135         super.init(filterConfig);
136         
137         String tmp=filterConfig.getInitParameter("bufferSize");
138         if (tmp!=null)
139             _bufferSize=Integer.parseInt(tmp);
140 
141         tmp=filterConfig.getInitParameter("minGzipSize");
142         if (tmp!=null)
143             _minGzipSize=Integer.parseInt(tmp);
144         
145         tmp=filterConfig.getInitParameter("deflateCompressionLevel");
146         if (tmp!=null)
147             _deflateCompressionLevel=Integer.parseInt(tmp);
148         
149         tmp=filterConfig.getInitParameter("deflateNoWrap");
150         if (tmp!=null)
151             _deflateNoWrap=Boolean.parseBoolean(tmp);
152         
153         tmp=filterConfig.getInitParameter("mimeTypes");
154         if (tmp!=null)
155         {
156             _mimeTypes=new HashSet<String>();
157             StringTokenizer tok = new StringTokenizer(tmp,",",false);
158             while (tok.hasMoreTokens())
159                 _mimeTypes.add(tok.nextToken());
160         }
161         tmp=filterConfig.getInitParameter("excludedAgents");
162         if (tmp!=null)
163         {
164             _excludedAgents=new HashSet<String>();
165             StringTokenizer tok = new StringTokenizer(tmp,",",false);
166             while (tok.hasMoreTokens())
167                _excludedAgents.add(tok.nextToken());
168         }
169         
170                 tmp=filterConfig.getInitParameter("excludeAgentPatterns");
171         if (tmp!=null)
172         {
173             _excludedAgentPatterns=new HashSet<Pattern>();
174             StringTokenizer tok = new StringTokenizer(tmp,",",false);
175             while (tok.hasMoreTokens())
176                 _excludedAgentPatterns.add(Pattern.compile(tok.nextToken()));            
177         }        
178         
179         tmp=filterConfig.getInitParameter("excludePaths");
180         if (tmp!=null)
181         {
182             _excludedPaths=new HashSet<String>();
183             StringTokenizer tok = new StringTokenizer(tmp,",",false);
184             while (tok.hasMoreTokens())
185                 _excludedPaths.add(tok.nextToken());            
186         }
187         
188         tmp=filterConfig.getInitParameter("excludePathPatterns");
189         if (tmp!=null)
190         {
191             _excludedPathPatterns=new HashSet<Pattern>();
192             StringTokenizer tok = new StringTokenizer(tmp,",",false);
193             while (tok.hasMoreTokens())
194                 _excludedPathPatterns.add(Pattern.compile(tok.nextToken()));            
195         }       
196     }
197 
198     /* ------------------------------------------------------------ */
199     /**
200      * @see org.eclipse.jetty.servlets.UserAgentFilter#destroy()
201      */
202     @Override
203     public void destroy()
204     {
205     }
206     
207     /* ------------------------------------------------------------ */
208     /**
209      * @see org.eclipse.jetty.servlets.UserAgentFilter#doFilter(javax.servlet.ServletRequest, javax.servlet.ServletResponse, javax.servlet.FilterChain)
210      */
211     @Override
212     public void doFilter(ServletRequest req, ServletResponse res, FilterChain chain) 
213         throws IOException, ServletException
214     {
215         HttpServletRequest request=(HttpServletRequest)req;
216         HttpServletResponse response=(HttpServletResponse)res;
217         
218         // Inform caches that responses may vary according to Accept-Encoding
219         response.setHeader("Vary","Accept-Encoding");
220 
221         // Should we vary this response according to Accept-Encoding
222         String compressionType = selectCompression(request.getHeader("accept-encoding"));
223         if (compressionType!=null && !response.containsHeader("Content-Encoding") && !HttpMethods.HEAD.equalsIgnoreCase(request.getMethod()))
224         {
225             String ua = getUserAgent(request);
226             if (isExcludedAgent(ua))
227             {
228                 super.doFilter(request,response,chain);
229                 return;
230             }
231             String requestURI = request.getRequestURI();
232             if (isExcludedPath(requestURI))
233             {
234                 super.doFilter(request,response,chain);
235                 return;
236             }
237             
238             CompressedResponseWrapper wrappedResponse = createWrappedResponse(request,response,compressionType);
239             
240             boolean exceptional=true;
241             try
242             {
243                 super.doFilter(request,wrappedResponse,chain);
244                 exceptional=false;
245             }
246             finally
247             {
248                 Continuation continuation = ContinuationSupport.getContinuation(request);
249                 if (continuation.isSuspended() && continuation.isResponseWrapped())   
250                 {
251                     continuation.addContinuationListener(new ContinuationListenerWaitingForWrappedResponseToFinish(wrappedResponse));
252                 }
253                 else if (exceptional && !response.isCommitted())
254                 {
255                     wrappedResponse.resetBuffer();
256                     wrappedResponse.noCompression();
257                 }
258                 else
259                     wrappedResponse.finish();
260             }
261         }
262         else
263         {
264             super.doFilter(request,response,chain);
265         }
266     }
267 
268     /* ------------------------------------------------------------ */
269     private String selectCompression(String encodingHeader)
270     {
271         // TODO, this could be a little more robust.
272         // prefer gzip over deflate
273         String compression = null;
274         if (encodingHeader!=null)
275         {
276             
277             String[] encodings = getEncodings(encodingHeader);
278             if (encodings != null)
279             {
280                 for (int i=0; i< encodings.length; i++)
281                 {
282                     if (encodings[i].toLowerCase(Locale.ENGLISH).contains(GZIP))
283                     {
284                         if (isEncodingAcceptable(encodings[i]))
285                         {
286                             compression = GZIP;
287                             break; //prefer Gzip over deflate
288                         }
289                     }
290 
291                     if (encodings[i].toLowerCase(Locale.ENGLISH).contains(DEFLATE))
292                     {
293                         if (isEncodingAcceptable(encodings[i]))
294                         {
295                             compression = DEFLATE; //Keep checking in case gzip is acceptable
296                         }
297                     }
298                 }
299             }
300         }
301         return compression;
302     }
303     
304     
305     private String[] getEncodings (String encodingHeader)
306     {
307         if (encodingHeader == null)
308             return null;
309         return encodingHeader.split(",");
310     }
311     
312     private boolean isEncodingAcceptable(String encoding)
313     {    
314         int state = STATE_DEFAULT;
315         int qvalueIdx = -1;
316         for (int i=0;i<encoding.length();i++)
317         {
318             char c = encoding.charAt(i);
319             switch (state)
320             {
321                 case STATE_DEFAULT:
322                 {
323                     if (';' == c)
324                         state = STATE_SEPARATOR;
325                     break;
326                 }
327                 case STATE_SEPARATOR:
328                 {
329                     if ('q' == c || 'Q' == c)
330                         state = STATE_Q;
331                     break;
332                 }
333                 case STATE_Q:
334                 {
335                     if ('=' == c)
336                         state = STATE_QVALUE;
337                     break;
338                 }
339                 case STATE_QVALUE:
340                 {
341                     if (qvalueIdx < 0 && '0' == c || '1' == c)
342                         qvalueIdx = i;
343                     break;
344                 }
345             }
346         }
347         
348         if (qvalueIdx < 0)
349             return true;
350                
351         if ("0".equals(encoding.substring(qvalueIdx).trim()))
352             return false;
353         return true;
354     }
355     
356     
357     protected CompressedResponseWrapper createWrappedResponse(HttpServletRequest request, HttpServletResponse response, final String compressionType)
358     {
359         CompressedResponseWrapper wrappedResponse = null;
360         if (compressionType.equals(GZIP))
361         {
362             wrappedResponse = new CompressedResponseWrapper(request,response)
363             {
364                 @Override
365                 protected AbstractCompressedStream newCompressedStream(HttpServletRequest request,HttpServletResponse response,long contentLength,int bufferSize, int minCompressSize) throws IOException
366                 {
367                     return new AbstractCompressedStream(compressionType,request,response,contentLength,bufferSize,minCompressSize)
368                     {
369                         @Override
370                         protected DeflaterOutputStream createStream() throws IOException
371                         {
372                             return new GZIPOutputStream(_response.getOutputStream(),_bufferSize);
373                         }
374                     };
375                 }
376             };
377         }
378         else if (compressionType.equals(DEFLATE))
379         {
380             wrappedResponse = new CompressedResponseWrapper(request,response)
381             {
382                 @Override
383                 protected AbstractCompressedStream newCompressedStream(HttpServletRequest request,HttpServletResponse response,long contentLength,int bufferSize, int minCompressSize) throws IOException
384                 {
385                     return new AbstractCompressedStream(compressionType,request,response,contentLength,bufferSize,minCompressSize)
386                     {
387                         @Override
388                         protected DeflaterOutputStream createStream() throws IOException
389                         {
390                             return new DeflaterOutputStream(_response.getOutputStream(),new Deflater(_deflateCompressionLevel,_deflateNoWrap));
391                         }
392                     };
393                 }
394             };
395         }
396         else
397         {
398             throw new IllegalStateException(compressionType + " not supported");
399         }
400         configureWrappedResponse(wrappedResponse);
401         return wrappedResponse;
402     }
403 
404     protected void configureWrappedResponse(CompressedResponseWrapper wrappedResponse)
405     {
406         wrappedResponse.setMimeTypes(_mimeTypes);
407         wrappedResponse.setBufferSize(_bufferSize);
408         wrappedResponse.setMinCompressSize(_minGzipSize);
409     }
410      
411     private class ContinuationListenerWaitingForWrappedResponseToFinish implements ContinuationListener{
412         
413         private CompressedResponseWrapper wrappedResponse;
414 
415         public ContinuationListenerWaitingForWrappedResponseToFinish(CompressedResponseWrapper wrappedResponse)
416         {
417             this.wrappedResponse = wrappedResponse;
418         }
419 
420         public void onComplete(Continuation continuation)
421         {
422             try
423             {
424                 wrappedResponse.finish();
425             }
426             catch (IOException e)
427             {
428                 LOG.warn(e);
429             }
430         }
431 
432         public void onTimeout(Continuation continuation)
433         {
434         }
435     }
436     
437     /**
438      * Checks to see if the userAgent is excluded
439      * 
440      * @param ua
441      *            the user agent
442      * @return boolean true if excluded
443      */
444     private boolean isExcludedAgent(String ua)
445     {
446         if (ua == null)
447             return false;
448 
449         if (_excludedAgents != null)
450         {
451             if (_excludedAgents.contains(ua))
452             {
453                 return true;
454             }
455         }
456         if (_excludedAgentPatterns != null)
457         {
458             for (Pattern pattern : _excludedAgentPatterns)
459             {
460                 if (pattern.matcher(ua).matches())
461                 {
462                     return true;
463                 }
464             }
465         }
466 
467         return false;
468     }
469 
470     /**
471      * Checks to see if the path is excluded
472      * 
473      * @param requestURI
474      *            the request uri
475      * @return boolean true if excluded
476      */
477     private boolean isExcludedPath(String requestURI)
478     {
479         if (requestURI == null)
480             return false;
481         if (_excludedPaths != null)
482         {
483             for (String excludedPath : _excludedPaths)
484             {
485                 if (requestURI.startsWith(excludedPath))
486                 {
487                     return true;
488                 }
489             }
490         }
491         if (_excludedPathPatterns != null)
492         {
493             for (Pattern pattern : _excludedPathPatterns)
494             {
495                 if (pattern.matcher(requestURI).matches())
496                 {
497                     return true;
498                 }
499             }
500         }
501         return false;
502     }
503 }