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_GZIP="--gzip";
133     public final static String ETAG = "o.e.j.s.GzipFilter.ETag";
134     public final static int DEFAULT_MIN_GZIP_SIZE=256;
135 
136     protected ServletContext _context;
137     protected final Set<String> _mimeTypes=new HashSet<>();
138     protected boolean _excludeMimeTypes;
139     protected int _bufferSize=32*1024;
140     protected int _minGzipSize=DEFAULT_MIN_GZIP_SIZE;
141     protected int _deflateCompressionLevel=Deflater.DEFAULT_COMPRESSION;
142     protected boolean _deflateNoWrap = true;
143     protected boolean _checkGzExists = true;
144     
145     // non-static, as other GzipFilter instances may have different configurations
146     protected final ThreadLocal<Deflater> _deflater = new ThreadLocal<Deflater>();
147 
148     protected final static ThreadLocal<byte[]> _buffer= new ThreadLocal<byte[]>();
149 
150     protected final Set<String> _methods=new HashSet<String>();
151     protected Set<String> _excludedAgents;
152     protected Set<Pattern> _excludedAgentPatterns;
153     protected Set<String> _excludedPaths;
154     protected Set<Pattern> _excludedPathPatterns;
155     protected HttpField _vary=new HttpGenerator.CachedHttpField(HttpHeader.VARY,HttpHeader.ACCEPT_ENCODING+", "+HttpHeader.USER_AGENT);
156 
157     /* ------------------------------------------------------------ */
158     /**
159      * @see org.eclipse.jetty.servlets.UserAgentFilter#init(javax.servlet.FilterConfig)
160      */
161     @Override
162     public void init(FilterConfig filterConfig) throws ServletException
163     {
164         super.init(filterConfig);
165 
166         _context=filterConfig.getServletContext();
167         
168         String tmp=filterConfig.getInitParameter("bufferSize");
169         if (tmp!=null)
170             _bufferSize=Integer.parseInt(tmp);
171         LOG.debug("{} bufferSize={}",this,_bufferSize);
172 
173         tmp=filterConfig.getInitParameter("minGzipSize");
174         if (tmp!=null)
175             _minGzipSize=Integer.parseInt(tmp);
176         LOG.debug("{} minGzipSize={}",this,_minGzipSize);
177 
178         tmp=filterConfig.getInitParameter("deflateCompressionLevel");
179         if (tmp!=null)
180             _deflateCompressionLevel=Integer.parseInt(tmp);
181         LOG.debug("{} deflateCompressionLevel={}",this,_deflateCompressionLevel);
182 
183         tmp=filterConfig.getInitParameter("deflateNoWrap");
184         if (tmp!=null)
185             _deflateNoWrap=Boolean.parseBoolean(tmp);
186         LOG.debug("{} deflateNoWrap={}",this,_deflateNoWrap);
187 
188         tmp=filterConfig.getInitParameter("checkGzExists");
189         if (tmp!=null)
190             _checkGzExists=Boolean.parseBoolean(tmp);
191         LOG.debug("{} checkGzExists={}",this,_checkGzExists);
192         
193         tmp=filterConfig.getInitParameter("methods");
194         if (tmp!=null)
195         {
196             StringTokenizer tok = new StringTokenizer(tmp,",",false);
197             while (tok.hasMoreTokens())
198                 _methods.add(tok.nextToken().trim().toUpperCase(Locale.ENGLISH));
199         }
200         else
201             _methods.add(HttpMethod.GET.asString());
202         LOG.debug("{} methods={}",this,_methods);
203         
204         tmp=filterConfig.getInitParameter("mimeTypes");
205         if (tmp==null)
206         {
207             _excludeMimeTypes=true;
208             tmp=filterConfig.getInitParameter("excludedMimeTypes");
209             if (tmp==null)
210             {
211                 for (String type:MimeTypes.getKnownMimeTypes())
212                 {
213                     if (type.equals("image/svg+xml")) //always compressable (unless .svgz file)
214                         continue;
215                     if (type.startsWith("image/")||
216                         type.startsWith("audio/")||
217                         type.startsWith("video/"))
218                         _mimeTypes.add(type);
219                 }
220                 _mimeTypes.add("application/compress");
221                 _mimeTypes.add("application/zip");
222                 _mimeTypes.add("application/gzip");
223             }
224             else
225             {
226                 StringTokenizer tok = new StringTokenizer(tmp,",",false);
227                 while (tok.hasMoreTokens())
228                     _mimeTypes.add(tok.nextToken().trim());
229             }
230         }
231         else
232         {
233             StringTokenizer tok = new StringTokenizer(tmp,",",false);
234             while (tok.hasMoreTokens())
235                 _mimeTypes.add(tok.nextToken().trim());
236         }
237         LOG.debug("{} mimeTypes={}",this,_mimeTypes);
238         LOG.debug("{} excludeMimeTypes={}",this,_excludeMimeTypes);
239         tmp=filterConfig.getInitParameter("excludedAgents");
240         if (tmp!=null)
241         {
242             _excludedAgents=new HashSet<String>();
243             StringTokenizer tok = new StringTokenizer(tmp,",",false);
244             while (tok.hasMoreTokens())
245                _excludedAgents.add(tok.nextToken().trim());
246         }
247         LOG.debug("{} excludedAgents={}",this,_excludedAgents);
248 
249         tmp=filterConfig.getInitParameter("excludeAgentPatterns");
250         if (tmp!=null)
251         {
252             _excludedAgentPatterns=new HashSet<Pattern>();
253             StringTokenizer tok = new StringTokenizer(tmp,",",false);
254             while (tok.hasMoreTokens())
255                 _excludedAgentPatterns.add(Pattern.compile(tok.nextToken().trim()));
256         }
257         LOG.debug("{} excludedAgentPatterns={}",this,_excludedAgentPatterns);
258 
259         tmp=filterConfig.getInitParameter("excludePaths");
260         if (tmp!=null)
261         {
262             _excludedPaths=new HashSet<String>();
263             StringTokenizer tok = new StringTokenizer(tmp,",",false);
264             while (tok.hasMoreTokens())
265                 _excludedPaths.add(tok.nextToken().trim());
266         }
267         LOG.debug("{} excludedPaths={}",this,_excludedPaths);
268 
269         tmp=filterConfig.getInitParameter("excludePathPatterns");
270         if (tmp!=null)
271         {
272             _excludedPathPatterns=new HashSet<Pattern>();
273             StringTokenizer tok = new StringTokenizer(tmp,",",false);
274             while (tok.hasMoreTokens())
275                 _excludedPathPatterns.add(Pattern.compile(tok.nextToken().trim()));
276         }
277         LOG.debug("{} excludedPathPatterns={}",this,_excludedPathPatterns);
278         
279         tmp=filterConfig.getInitParameter("vary");
280         if (tmp!=null)
281             _vary=new HttpGenerator.CachedHttpField(HttpHeader.VARY,tmp);
282         LOG.debug("{} vary={}",this,_vary);
283     }
284 
285     /* ------------------------------------------------------------ */
286     /**
287      * @see org.eclipse.jetty.servlets.UserAgentFilter#destroy()
288      */
289     @Override
290     public void destroy()
291     {
292     }
293 
294     /* ------------------------------------------------------------ */
295     /**
296      * @see org.eclipse.jetty.servlets.UserAgentFilter#doFilter(javax.servlet.ServletRequest, javax.servlet.ServletResponse, javax.servlet.FilterChain)
297      */
298     @Override
299     public void doFilter(ServletRequest req, ServletResponse res, FilterChain chain)
300         throws IOException, ServletException
301     {
302         LOG.debug("{} doFilter {}",this,req);
303         HttpServletRequest request=(HttpServletRequest)req;
304         HttpServletResponse response=(HttpServletResponse)res;
305 
306         // 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
307         String requestURI = request.getRequestURI();
308         if (!_methods.contains(request.getMethod()))
309         {
310             LOG.debug("{} excluded by method {}",this,request);
311             super.doFilter(request,response,chain);
312             return;
313         }
314         
315         if (isExcludedPath(requestURI))
316         {
317             LOG.debug("{} excluded by path {}",this,request);
318             super.doFilter(request,response,chain);
319             return;
320         }
321 
322         // Exclude non compressible mime-types known from URI extension. - no Vary because no matter what client, this URI is always excluded
323         if (_mimeTypes.size()>0 && _excludeMimeTypes)
324         {
325             String mimeType = _context.getMimeType(request.getRequestURI());
326 
327             if (mimeType!=null)
328             {
329                 mimeType = MimeTypes.getContentTypeWithoutCharset(mimeType);
330                 if (_mimeTypes.contains(mimeType))
331                 {
332                     LOG.debug("{} excluded by path suffix {}",this,request);
333                     // handle normally without setting vary header
334                     super.doFilter(request,response,chain);
335                     return;
336                 }
337             }
338         }
339 
340         //If the Content-Encoding is already set, then we won't compress
341         if (response.getHeader("Content-Encoding") != null)
342         {
343             super.doFilter(request,response,chain);
344             return;
345         }
346         
347         if (_checkGzExists && request.getServletContext()!=null)
348         {
349             String path=request.getServletContext().getRealPath(URIUtil.addPaths(request.getServletPath(),request.getPathInfo()));
350             if (path!=null)
351             {
352                 File gz=new File(path+".gz");
353                 if (gz.exists())
354                 {
355                     LOG.debug("{} gzip exists {}",this,request);
356                     // allow default servlet to handle
357                     super.doFilter(request,response,chain);
358                     return;
359                 }
360             }
361         }
362         
363         // Special handling for etags
364         String etag = request.getHeader("If-None-Match"); 
365         if (etag!=null)
366         {
367             if (etag.contains(ETAG_GZIP))
368                 request.setAttribute(ETAG,etag.replace(ETAG_GZIP,""));
369         }
370 
371         HttpChannel<?> channel = HttpChannel.getCurrentHttpChannel();
372         HttpOutput out = channel.getResponse().getHttpOutput();
373         if (!(out instanceof GzipHttpOutput))
374         {
375             if (out.getClass()!=HttpOutput.class)
376                 throw new IllegalStateException();
377             channel.getResponse().setHttpOutput(out = new GzipHttpOutput(channel));
378         }
379         
380         GzipHttpOutput cout=(GzipHttpOutput)out;
381         
382         try
383         {
384             cout.mightCompress(this);
385             super.doFilter(request,response,chain);
386         }
387         catch(Throwable e)
388         {
389             LOG.debug("{} excepted {}",this,request,e);
390             if (!response.isCommitted())
391             {
392                 cout.resetBuffer();
393                 cout.noCompressionIfPossible();
394             }
395             throw e;
396         }
397     }
398 
399 
400     /**
401      * Checks to see if the userAgent is excluded
402      *
403      * @param ua
404      *            the user agent
405      * @return boolean true if excluded
406      */
407     private boolean isExcludedAgent(String ua)
408     {
409         if (ua == null)
410             return false;
411 
412         if (_excludedAgents != null)
413         {
414             if (_excludedAgents.contains(ua))
415             {
416                 return true;
417             }
418         }
419         if (_excludedAgentPatterns != null)
420         {
421             for (Pattern pattern : _excludedAgentPatterns)
422             {
423                 if (pattern.matcher(ua).matches())
424                 {
425                     return true;
426                 }
427             }
428         }
429 
430         return false;
431     }
432 
433     /**
434      * Checks to see if the path is excluded
435      *
436      * @param requestURI
437      *            the request uri
438      * @return boolean true if excluded
439      */
440     private boolean isExcludedPath(String requestURI)
441     {
442         if (requestURI == null)
443             return false;
444         if (_excludedPaths != null)
445         {
446             for (String excludedPath : _excludedPaths)
447             {
448                 if (requestURI.startsWith(excludedPath))
449                 {
450                     return true;
451                 }
452             }
453         }
454         if (_excludedPathPatterns != null)
455         {
456             for (Pattern pattern : _excludedPathPatterns)
457             {
458                 if (pattern.matcher(requestURI).matches())
459                 {
460                     return true;
461                 }
462             }
463         }
464         return false;
465     }
466 
467     @Override
468     public HttpField getVaryField()
469     {
470         return _vary;
471     }
472 
473     @Override
474     public Deflater getDeflater(Request request, long content_length)
475     {
476         String ua = getUserAgent(request);
477         if (ua!=null && isExcludedAgent(ua))
478         {
479             LOG.debug("{} excluded user agent {}",this,request);
480             return null;
481         }
482         
483         if (content_length>=0 && content_length<_minGzipSize)
484         {
485             LOG.debug("{} excluded minGzipSize {}",this,request);
486             return null;
487         }
488         
489         String accept = request.getHttpFields().get(HttpHeader.ACCEPT_ENCODING);
490         if (accept==null)
491         {
492             LOG.debug("{} excluded !accept {}",this,request);
493             return null;
494         }
495         
496         boolean gzip=false;
497         if (GZIP.equals(accept) || accept.startsWith("gzip,"))
498             gzip=true;
499         else
500         {
501             List<String> list=HttpFields.qualityList(request.getHttpFields().getValues(HttpHeader.ACCEPT_ENCODING.asString(),","));
502             for (String a:list)
503             {
504                 if (GZIP.equalsIgnoreCase(HttpFields.valueParameters(a,null)))
505                 {
506                     gzip=true;
507                     break;
508                 }
509             }
510         }
511         
512         if (!gzip)
513         {
514             LOG.debug("{} excluded not gzip accept {}",this,request);
515             return null;
516         }
517         
518         Deflater df = _deflater.get();
519         if (df==null)
520             df=new Deflater(_deflateCompressionLevel,_deflateNoWrap);        
521         else
522             _deflater.set(null);
523         
524         return df;
525     }
526 
527     @Override
528     public void recycle(Deflater deflater)
529     {
530         deflater.reset();
531         if (_deflater.get()==null)
532             _deflater.set(deflater);
533         
534     }
535     
536     @Override
537     public boolean isExcludedMimeType(String mimetype)
538     {
539         return _mimeTypes.contains(mimetype) == _excludeMimeTypes;
540     }
541 
542     @Override
543     public int getBufferSize()
544     {
545         return _bufferSize;
546     }
547     
548     
549 }