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