View Javadoc

1   //
2   //  ========================================================================
3   //  Copyright (c) 1995-2014 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.List;
25  import java.util.Locale;
26  import java.util.Set;
27  import java.util.StringTokenizer;
28  import java.util.regex.Pattern;
29  import java.util.zip.Deflater;
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.http.HttpServletRequest;
38  import javax.servlet.http.HttpServletResponse;
39  
40  import org.eclipse.jetty.http.HttpField;
41  import org.eclipse.jetty.http.HttpFields;
42  import org.eclipse.jetty.http.HttpGenerator;
43  import org.eclipse.jetty.http.HttpHeader;
44  import org.eclipse.jetty.http.HttpMethod;
45  import org.eclipse.jetty.http.MimeTypes;
46  import org.eclipse.jetty.server.HttpChannel;
47  import org.eclipse.jetty.server.HttpOutput;
48  import org.eclipse.jetty.server.Request;
49  import org.eclipse.jetty.servlets.gzip.GzipFactory;
50  import org.eclipse.jetty.servlets.gzip.GzipHttpOutput;
51  import org.eclipse.jetty.util.URIUtil;
52  import org.eclipse.jetty.util.log.Log;
53  import org.eclipse.jetty.util.log.Logger;
54  
55  /* ------------------------------------------------------------ */
56  /** Async GZIP Filter
57   * This filter is a gzip filter using jetty internal mechanism to apply gzip compression
58   * to output that is compatible with async IO and does not need to wrap the response nor output stream.
59   * The filter will gzip the content of a response if: <ul>
60   * <li>The filter is mapped to a matching path</li>
61   * <li>accept-encoding header is set to either gzip, deflate or a combination of those</li>
62   * <li>The response status code is >=200 and <300
63   * <li>The content length is unknown or more than the <code>minGzipSize</code> initParameter or the minGzipSize is 0(default)</li>
64   * <li>If a list of mimeTypes is set by the <code>mimeTypes</code> init parameter, then the Content-Type is in the list.</li>
65   * <li>If no mimeType list is set, then the content-type is not in the list defined by <code>excludedMimeTypes</code></li>
66   * <li>No content-encoding is specified by the resource</li>
67   * </ul>
68   *
69   * <p>
70   * Compressing the content can greatly improve the network bandwidth usage, but at a cost of memory and
71   * CPU cycles. If this filter is mapped for static content, then use of efficient direct NIO may be
72   * prevented, thus use of the gzip mechanism of the {@link org.eclipse.jetty.servlet.DefaultServlet} is
73   * advised instead.
74   * </p>
75   * <p>
76   * This filter extends {@link UserAgentFilter} and if the the initParameter <code>excludedAgents</code>
77   * is set to a comma separated list of user agents, then these agents will be excluded from gzip content.
78   * </p>
79   * <p>Init Parameters:</p>
80   * <dl>
81   * <dt>bufferSize</dt>       <dd>The output buffer size. Defaults to 8192. Be careful as values <= 0 will lead to an
82   *                            {@link IllegalArgumentException}.
83   *                            See: {@link java.util.zip.GZIPOutputStream#GZIPOutputStream(java.io.OutputStream, int)}
84   *                            and: {@link java.util.zip.DeflaterOutputStream#DeflaterOutputStream(java.io.OutputStream, Deflater, int)}
85   * </dd>
86   * <dt>minGzipSize</dt>       <dd>Content will only be compressed if content length is either unknown or greater
87   *                            than <code>minGzipSize</code>.
88   * </dd>
89   * <dt>deflateCompressionLevel</dt>       <dd>The compression level used for deflate compression. (0-9).
90   *                            See: {@link java.util.zip.Deflater#Deflater(int, boolean)}
91   * </dd>
92   * <dt>deflateNoWrap</dt>       <dd>The noWrap setting for deflate compression. Defaults to true. (true/false)
93   *                            See: {@link java.util.zip.Deflater#Deflater(int, boolean)}
94   * </dd>
95   * <dt>methods</dt>       <dd>Comma separated list of HTTP methods to compress. If not set, only GET requests are compressed.
96   *  </dd>
97   * <dt>mimeTypes</dt>       <dd>Comma separated list of mime types to compress. If it is not set, then the excludedMimeTypes list is used.
98   * </dd>
99   * <dt>excludedMimeTypes</dt>       <dd>Comma separated list of mime types to never compress. If not set, then the default is the commonly known
100  * image, video, audio and compressed types.
101  * </dd>
102 
103  * <dt>excludedAgents</dt>       <dd>Comma separated list of user agents to exclude from compression. Does a
104  *                            {@link String#contains(CharSequence)} to check if the excluded agent occurs
105  *                            in the user-agent header. If it does -> no compression
106  * </dd>
107  * <dt>excludeAgentPatterns</dt>       <dd>Same as excludedAgents, but accepts regex patterns for more complex matching.
108  * </dd>
109  * <dt>excludePaths</dt>       <dd>Comma separated list of paths to exclude from compression.
110  *                            Does a {@link String#startsWith(String)} comparison to check if the path matches.
111  *                            If it does match -> no compression. To match subpaths use <code>excludePathPatterns</code>
112  *                            instead.
113  * </dd>
114  * <dt>excludePathPatterns</dt>       <dd>Same as excludePath, but accepts regex patterns for more complex matching.
115  * </dd>
116  * <dt>vary</dt>       <dd>Set to the value of the Vary header sent with responses that could be compressed.  By default it is 
117  *                            set to 'Vary: Accept-Encoding, User-Agent' since IE6 is excluded by default from the excludedAgents. 
118  *                            If user-agents are not to be excluded, then this can be set to 'Vary: Accept-Encoding'.  Note also 
119  *                            that shared caches may cache copies of a resource that is varied by User-Agent - one per variation of 
120  *                            the User-Agent, unless the cache does some normalization of the UA string.
121  * </dd>                         
122  * <dt>checkGzExists</dt>       <dd>If set to true, the filter check if a static resource with ".gz" appended exists.  If so then
123  *                            the normal processing is done so that the default servlet can send  the pre existing gz content.
124  *  </dd>
125  *  </dl>
126  */
127 public class AsyncGzipFilter extends UserAgentFilter implements GzipFactory
128 {
129     private static final Logger LOG = Log.getLogger(GzipFilter.class);
130     public final static String GZIP = "gzip";
131     public static final String DEFLATE = "deflate";
132     public final static String ETAG = "o.e.j.s.GzipFilter.ETag";
133     public final static int DEFAULT_MIN_GZIP_SIZE=256;
134 
135     protected ServletContext _context;
136     protected final Set<String> _mimeTypes=new HashSet<>();
137     protected boolean _excludeMimeTypes;
138     protected int _bufferSize=8192;
139     protected int _minGzipSize=DEFAULT_MIN_GZIP_SIZE;
140     protected int _deflateCompressionLevel=Deflater.DEFAULT_COMPRESSION;
141     protected boolean _deflateNoWrap = true;
142     protected boolean _checkGzExists = true;
143     
144     // non-static, as other GzipFilter instances may have different configurations
145     protected final ThreadLocal<Deflater> _deflater = new ThreadLocal<Deflater>();
146 
147     protected final static ThreadLocal<byte[]> _buffer= new ThreadLocal<byte[]>();
148 
149     protected final Set<String> _methods=new HashSet<String>();
150     protected Set<String> _excludedAgents;
151     protected Set<Pattern> _excludedAgentPatterns;
152     protected Set<String> _excludedPaths;
153     protected Set<Pattern> _excludedPathPatterns;
154     protected HttpField _vary=new HttpGenerator.CachedHttpField(HttpHeader.VARY,HttpHeader.ACCEPT_ENCODING+", "+HttpHeader.USER_AGENT);
155 
156     /* ------------------------------------------------------------ */
157     /**
158      * @see org.eclipse.jetty.servlets.UserAgentFilter#init(javax.servlet.FilterConfig)
159      */
160     @Override
161     public void init(FilterConfig filterConfig) throws ServletException
162     {
163         super.init(filterConfig);
164 
165         _context=filterConfig.getServletContext();
166         
167         String tmp=filterConfig.getInitParameter("bufferSize");
168         if (tmp!=null)
169             _bufferSize=Integer.parseInt(tmp);
170         LOG.debug("{} bufferSize={}",this,_bufferSize);
171 
172         tmp=filterConfig.getInitParameter("minGzipSize");
173         if (tmp!=null)
174             _minGzipSize=Integer.parseInt(tmp);
175         LOG.debug("{} minGzipSize={}",this,_minGzipSize);
176 
177         tmp=filterConfig.getInitParameter("deflateCompressionLevel");
178         if (tmp!=null)
179             _deflateCompressionLevel=Integer.parseInt(tmp);
180         LOG.debug("{} deflateCompressionLevel={}",this,_deflateCompressionLevel);
181 
182         tmp=filterConfig.getInitParameter("deflateNoWrap");
183         if (tmp!=null)
184             _deflateNoWrap=Boolean.parseBoolean(tmp);
185         LOG.debug("{} deflateNoWrap={}",this,_deflateNoWrap);
186 
187         tmp=filterConfig.getInitParameter("checkGzExists");
188         if (tmp!=null)
189             _checkGzExists=Boolean.parseBoolean(tmp);
190         LOG.debug("{} checkGzExists={}",this,_checkGzExists);
191         
192         tmp=filterConfig.getInitParameter("methods");
193         if (tmp!=null)
194         {
195             StringTokenizer tok = new StringTokenizer(tmp,",",false);
196             while (tok.hasMoreTokens())
197                 _methods.add(tok.nextToken().trim().toUpperCase(Locale.ENGLISH));
198         }
199         else
200             _methods.add(HttpMethod.GET.asString());
201         LOG.debug("{} methods={}",this,_methods);
202         
203         tmp=filterConfig.getInitParameter("mimeTypes");
204         if (tmp==null)
205         {
206             _excludeMimeTypes=true;
207             tmp=filterConfig.getInitParameter("excludedMimeTypes");
208             if (tmp==null)
209             {
210                 for (String type:MimeTypes.getKnownMimeTypes())
211                 {
212                     if (type.startsWith("image/")||
213                         type.startsWith("audio/")||
214                         type.startsWith("video/"))
215                         _mimeTypes.add(type);
216                     _mimeTypes.add("application/compress");
217                     _mimeTypes.add("application/zip");
218                     _mimeTypes.add("application/gzip");
219                 }
220             }
221             else
222             {
223                 StringTokenizer tok = new StringTokenizer(tmp,",",false);
224                 while (tok.hasMoreTokens())
225                     _mimeTypes.add(tok.nextToken().trim());
226             }
227         }
228         else
229         {
230             StringTokenizer tok = new StringTokenizer(tmp,",",false);
231             while (tok.hasMoreTokens())
232                 _mimeTypes.add(tok.nextToken().trim());
233         }
234         LOG.debug("{} mimeTypes={}",this,_mimeTypes);
235         LOG.debug("{} excludeMimeTypes={}",this,_excludeMimeTypes);
236         tmp=filterConfig.getInitParameter("excludedAgents");
237         if (tmp!=null)
238         {
239             _excludedAgents=new HashSet<String>();
240             StringTokenizer tok = new StringTokenizer(tmp,",",false);
241             while (tok.hasMoreTokens())
242                _excludedAgents.add(tok.nextToken().trim());
243         }
244         LOG.debug("{} excludedAgents={}",this,_excludedAgents);
245 
246         tmp=filterConfig.getInitParameter("excludeAgentPatterns");
247         if (tmp!=null)
248         {
249             _excludedAgentPatterns=new HashSet<Pattern>();
250             StringTokenizer tok = new StringTokenizer(tmp,",",false);
251             while (tok.hasMoreTokens())
252                 _excludedAgentPatterns.add(Pattern.compile(tok.nextToken().trim()));
253         }
254         LOG.debug("{} excludedAgentPatterns={}",this,_excludedAgentPatterns);
255 
256         tmp=filterConfig.getInitParameter("excludePaths");
257         if (tmp!=null)
258         {
259             _excludedPaths=new HashSet<String>();
260             StringTokenizer tok = new StringTokenizer(tmp,",",false);
261             while (tok.hasMoreTokens())
262                 _excludedPaths.add(tok.nextToken().trim());
263         }
264         LOG.debug("{} excludedPaths={}",this,_excludedPaths);
265 
266         tmp=filterConfig.getInitParameter("excludePathPatterns");
267         if (tmp!=null)
268         {
269             _excludedPathPatterns=new HashSet<Pattern>();
270             StringTokenizer tok = new StringTokenizer(tmp,",",false);
271             while (tok.hasMoreTokens())
272                 _excludedPathPatterns.add(Pattern.compile(tok.nextToken().trim()));
273         }
274         LOG.debug("{} excludedPathPatterns={}",this,_excludedPathPatterns);
275         
276         tmp=filterConfig.getInitParameter("vary");
277         if (tmp!=null)
278             _vary=new HttpGenerator.CachedHttpField(HttpHeader.VARY,tmp);
279         LOG.debug("{} vary={}",this,_vary);
280     }
281 
282     /* ------------------------------------------------------------ */
283     /**
284      * @see org.eclipse.jetty.servlets.UserAgentFilter#destroy()
285      */
286     @Override
287     public void destroy()
288     {
289     }
290 
291     /* ------------------------------------------------------------ */
292     /**
293      * @see org.eclipse.jetty.servlets.UserAgentFilter#doFilter(javax.servlet.ServletRequest, javax.servlet.ServletResponse, javax.servlet.FilterChain)
294      */
295     @Override
296     public void doFilter(ServletRequest req, ServletResponse res, FilterChain chain)
297         throws IOException, ServletException
298     {
299         LOG.debug("{} doFilter {}",this,req);
300         HttpServletRequest request=(HttpServletRequest)req;
301         HttpServletResponse response=(HttpServletResponse)res;
302 
303         // If not a supported method or it is an Excluded URI or an excluded UA - no Vary because no matter what client, this URI is always excluded
304         String requestURI = request.getRequestURI();
305         if (!_methods.contains(request.getMethod()))
306         {
307             LOG.debug("{} excluded by method {}",this,request);
308             super.doFilter(request,response,chain);
309             return;
310         }
311         
312         if (isExcludedPath(requestURI))
313         {
314             LOG.debug("{} excluded by path {}",this,request);
315             super.doFilter(request,response,chain);
316             return;
317         }
318         
319         // Exclude non compressible mime-types known from URI extension. - no Vary because no matter what client, this URI is always excluded
320         if (_mimeTypes.size()>0)
321         {
322             String mimeType = _context.getMimeType(request.getRequestURI());
323             
324             if (mimeType!=null && _mimeTypes.contains(mimeType)==_excludeMimeTypes)
325             {
326                 LOG.debug("{} excluded by path suffix {}",this,request);
327                 // handle normally without setting vary header
328                 super.doFilter(request,response,chain);
329                 return;
330             }
331         }
332 
333         if (_checkGzExists && request.getServletContext()!=null)
334         {
335             String path=request.getServletContext().getRealPath(URIUtil.addPaths(request.getServletPath(),request.getPathInfo()));
336             if (path!=null)
337             {
338                 File gz=new File(path+".gz");
339                 if (gz.exists())
340                 {
341                     LOG.debug("{} gzip exists {}",this,request);
342                     // allow default servlet to handle
343                     super.doFilter(request,response,chain);
344                     return;
345                 }
346             }
347         }
348         
349         // Special handling for etags
350         String etag = request.getHeader("If-None-Match"); 
351         if (etag!=null)
352         {
353             int dd=etag.indexOf("--");
354             if (dd>0)
355                 request.setAttribute(ETAG,etag.substring(0,dd)+(etag.endsWith("\"")?"\"":""));
356         }
357 
358         HttpChannel<?> channel = HttpChannel.getCurrentHttpChannel();
359         HttpOutput out = channel.getResponse().getHttpOutput();
360         if (!(out instanceof GzipHttpOutput))
361         {
362             if (out.getClass()!=HttpOutput.class)
363                 throw new IllegalStateException();
364             channel.getResponse().setHttpOutput(out = new GzipHttpOutput(channel));
365         }
366         
367         GzipHttpOutput cout=(GzipHttpOutput)out;
368         
369         try
370         {
371             cout.mightCompress(this);
372             super.doFilter(request,response,chain);
373         }
374         catch(Throwable e)
375         {
376             LOG.debug("{} excepted {}",this,request,e);
377             if (!response.isCommitted())
378             {
379                 cout.resetBuffer();
380                 cout.noCompressionIfPossible();
381             }
382             throw e;
383         }
384     }
385 
386 
387     /**
388      * Checks to see if the userAgent is excluded
389      *
390      * @param ua
391      *            the user agent
392      * @return boolean true if excluded
393      */
394     private boolean isExcludedAgent(String ua)
395     {
396         if (ua == null)
397             return false;
398 
399         if (_excludedAgents != null)
400         {
401             if (_excludedAgents.contains(ua))
402             {
403                 return true;
404             }
405         }
406         if (_excludedAgentPatterns != null)
407         {
408             for (Pattern pattern : _excludedAgentPatterns)
409             {
410                 if (pattern.matcher(ua).matches())
411                 {
412                     return true;
413                 }
414             }
415         }
416 
417         return false;
418     }
419 
420     /**
421      * Checks to see if the path is excluded
422      *
423      * @param requestURI
424      *            the request uri
425      * @return boolean true if excluded
426      */
427     private boolean isExcludedPath(String requestURI)
428     {
429         if (requestURI == null)
430             return false;
431         if (_excludedPaths != null)
432         {
433             for (String excludedPath : _excludedPaths)
434             {
435                 if (requestURI.startsWith(excludedPath))
436                 {
437                     return true;
438                 }
439             }
440         }
441         if (_excludedPathPatterns != null)
442         {
443             for (Pattern pattern : _excludedPathPatterns)
444             {
445                 if (pattern.matcher(requestURI).matches())
446                 {
447                     return true;
448                 }
449             }
450         }
451         return false;
452     }
453 
454     @Override
455     public HttpField getVaryField()
456     {
457         return _vary;
458     }
459 
460     @Override
461     public Deflater getDeflater(Request request, long content_length)
462     {
463         String ua = getUserAgent(request);
464         if (ua!=null && isExcludedAgent(ua))
465         {
466             LOG.debug("{} excluded user agent {}",this,request);
467             return null;
468         }
469         
470         if (content_length>=0 && content_length<_minGzipSize)
471         {
472             LOG.debug("{} excluded minGzipSize {}",this,request);
473             return null;
474         }
475         
476         String accept = request.getHttpFields().get(HttpHeader.ACCEPT_ENCODING);
477         if (accept==null)
478         {
479             LOG.debug("{} excluded !accept {}",this,request);
480             return null;
481         }
482         
483         boolean gzip=false;
484         if (GZIP.equals(accept) || accept.startsWith("gzip,"))
485             gzip=true;
486         else
487         {
488             List<String> list=HttpFields.qualityList(request.getHttpFields().getValues(HttpHeader.ACCEPT_ENCODING.asString(),","));
489             for (String a:list)
490             {
491                 if (GZIP.equalsIgnoreCase(HttpFields.valueParameters(a,null)))
492                 {
493                     gzip=true;
494                     break;
495                 }
496             }
497         }
498         
499         if (!gzip)
500         {
501             LOG.debug("{} excluded not gzip accept {}",this,request);
502             return null;
503         }
504         
505         Deflater df = _deflater.get();
506         if (df==null)
507             df=new Deflater(_deflateCompressionLevel,_deflateNoWrap);        
508         else
509             _deflater.set(null);
510         
511         return df;
512     }
513 
514     @Override
515     public void recycle(Deflater deflater)
516     {
517         deflater.reset();
518         if (_deflater.get()==null)
519             _deflater.set(deflater);
520         
521     }
522     
523     @Override
524     public boolean isExcludedMimeType(String mimetype)
525     {
526         return _mimeTypes.contains(mimetype) == _excludeMimeTypes;
527     }
528 
529     @Override
530     public int getBufferSize()
531     {
532         return _bufferSize;
533     }
534     
535     
536 }