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