| // |
| // ======================================================================== |
| // Copyright (c) 1995-2017 Mort Bay Consulting Pty. Ltd. |
| // ------------------------------------------------------------------------ |
| // All rights reserved. This program and the accompanying materials |
| // are made available under the terms of the Eclipse Public License v1.0 |
| // and Apache License v2.0 which accompanies this distribution. |
| // |
| // The Eclipse Public License is available at |
| // http://www.eclipse.org/legal/epl-v10.html |
| // |
| // The Apache License v2.0 is available at |
| // http://www.opensource.org/licenses/apache2.0.php |
| // |
| // You may elect to redistribute this code under either of these licenses. |
| // ======================================================================== |
| // |
| |
| package org.eclipse.jetty.servlets; |
| |
| import java.io.File; |
| import java.io.IOException; |
| import java.io.OutputStream; |
| import java.util.HashSet; |
| import java.util.Locale; |
| import java.util.Set; |
| import java.util.StringTokenizer; |
| import java.util.regex.Pattern; |
| import java.util.zip.Deflater; |
| |
| import javax.servlet.AsyncEvent; |
| import javax.servlet.AsyncListener; |
| import javax.servlet.FilterChain; |
| import javax.servlet.FilterConfig; |
| import javax.servlet.ServletContext; |
| import javax.servlet.ServletException; |
| import javax.servlet.ServletRequest; |
| import javax.servlet.ServletResponse; |
| import javax.servlet.http.HttpServletRequest; |
| import javax.servlet.http.HttpServletResponse; |
| |
| import org.eclipse.jetty.http.HttpMethod; |
| import org.eclipse.jetty.http.MimeTypes; |
| import org.eclipse.jetty.servlets.gzip.AbstractCompressedStream; |
| import org.eclipse.jetty.servlets.gzip.CompressedResponseWrapper; |
| import org.eclipse.jetty.servlets.gzip.DeflatedOutputStream; |
| import org.eclipse.jetty.servlets.gzip.GzipOutputStream; |
| import org.eclipse.jetty.util.IncludeExclude; |
| import org.eclipse.jetty.util.URIUtil; |
| import org.eclipse.jetty.util.log.Log; |
| import org.eclipse.jetty.util.log.Logger; |
| |
| /* ------------------------------------------------------------ */ |
| /** GZIP Filter |
| * This filter will gzip or deflate the content of a response if: <ul> |
| * <li>The filter is mapped to a matching path</li> |
| * <li>accept-encoding header is set to either gzip, deflate or a combination of those</li> |
| * <li>The response status code is >=200 and <300 |
| * <li>The content length is unknown or more than the <code>minGzipSize</code> initParameter or the minGzipSize is 0(default)</li> |
| * <li>If a list of mimeTypes is set by the <code>mimeTypes</code> init parameter, then the Content-Type is in the list.</li> |
| * <li>If no mimeType list is set, then the content-type is not in the list defined by <code>excludedMimeTypes</code></li> |
| * <li>No content-encoding is specified by the resource</li> |
| * </ul> |
| * |
| * <p> |
| * If both gzip and deflate are specified in the accept-encoding header, then gzip will be used. |
| * </p> |
| * <p> |
| * Compressing the content can greatly improve the network bandwidth usage, but at a cost of memory and |
| * CPU cycles. If this filter is mapped for static content, then use of efficient direct NIO may be |
| * prevented, thus use of the gzip mechanism of the {@link org.eclipse.jetty.servlet.DefaultServlet} is |
| * advised instead. |
| * </p> |
| * <p> |
| * This filter extends {@link UserAgentFilter} and if the the initParameter <code>excludedAgents</code> |
| * is set to a comma separated list of user agents, then these agents will be excluded from gzip content. |
| * </p> |
| * <p>Init Parameters:</p> |
| * <dl> |
| * <dt>bufferSize</dt> <dd>The output buffer size. Defaults to 8192. Be careful as values <= 0 will lead to an |
| * {@link IllegalArgumentException}. |
| * See: {@link java.util.zip.GZIPOutputStream#GZIPOutputStream(java.io.OutputStream, int)} |
| * and: {@link java.util.zip.DeflaterOutputStream#DeflaterOutputStream(java.io.OutputStream, Deflater, int)} |
| * </dd> |
| * <dt>minGzipSize</dt> <dd>Content will only be compressed if content length is either unknown or greater |
| * than <code>minGzipSize</code>. |
| * </dd> |
| * <dt>deflateCompressionLevel</dt> <dd>The compression level used for deflate compression. (0-9). |
| * See: {@link java.util.zip.Deflater#Deflater(int, boolean)} |
| * </dd> |
| * <dt>deflateNoWrap</dt> <dd>The noWrap setting for deflate compression. Defaults to true. (true/false) |
| * See: {@link java.util.zip.Deflater#Deflater(int, boolean)} |
| * </dd> |
| * <dt>methods</dt> <dd>Comma separated list of HTTP methods to compress. If not set, only GET requests are compressed. |
| * </dd> |
| * <dt>mimeTypes</dt> <dd>Comma separated list of mime types to compress. If it is not set, then the excludedMimeTypes list is used. |
| * </dd> |
| * <dt>excludedMimeTypes</dt> <dd>Comma separated list of mime types to never compress. If not set, then the default is the commonly known |
| * image, video, audio and compressed types. |
| * </dd> |
| |
| * <dt>excludedAgents</dt> <dd>Comma separated list of user agents to exclude from compression. Does a |
| * {@link String#contains(CharSequence)} to check if the excluded agent occurs |
| * in the user-agent header. If it does -> no compression |
| * </dd> |
| * <dt>excludeAgentPatterns</dt> <dd>Same as excludedAgents, but accepts regex patterns for more complex matching. |
| * </dd> |
| * <dt>excludePaths</dt> <dd>Comma separated list of paths to exclude from compression. |
| * Does a {@link String#startsWith(String)} comparison to check if the path matches. |
| * If it does match -> no compression. To match subpaths use <code>excludePathPatterns</code> |
| * instead. |
| * </dd> |
| * <dt>excludePathPatterns</dt> <dd>Same as excludePath, but accepts regex patterns for more complex matching. |
| * </dd> |
| * <dt>vary</dt> <dd>Set to the value of the Vary header sent with responses that could be compressed. By default it is |
| * set to 'Vary: Accept-Encoding, User-Agent' since IE6 is excluded by default from the excludedAgents. |
| * If user-agents are not to be excluded, then this can be set to 'Vary: Accept-Encoding'. Note also |
| * that shared caches may cache copies of a resource that is varied by User-Agent - one per variation of |
| * the User-Agent, unless the cache does some normalization of the UA string. |
| * </dd> |
| * <dt>checkGzExists</dt> <dd>If set to true, the filter check if a static resource with ".gz" appended exists. If so then |
| * the normal processing is done so that the default servlet can send the pre existing gz content. |
| * </dd> |
| * </dl> |
| */ |
| public class GzipFilter extends UserAgentFilter |
| { |
| private static final Logger LOG = Log.getLogger(GzipFilter.class); |
| public final static String GZIP="gzip"; |
| public final static String ETAG_GZIP="--gzip\""; |
| public final static String DEFLATE="deflate"; |
| public final static String ETAG_DEFLATE="--deflate\""; |
| public final static String ETAG="o.e.j.s.GzipFilter.ETag"; |
| |
| protected ServletContext _context; |
| protected final Set<String> _mimeTypes=new HashSet<>(); |
| protected boolean _excludeMimeTypes; |
| protected int _bufferSize=8192; |
| protected int _minGzipSize=256; |
| protected int _deflateCompressionLevel=Deflater.DEFAULT_COMPRESSION; |
| protected boolean _deflateNoWrap = true; |
| protected boolean _checkGzExists = true; |
| |
| // non-static, as other GzipFilter instances may have different configurations |
| protected final ThreadLocal<Deflater> _deflater = new ThreadLocal<Deflater>(); |
| |
| protected final static ThreadLocal<byte[]> _buffer= new ThreadLocal<byte[]>(); |
| |
| protected final Set<String> _methods=new HashSet<String>(); |
| protected Set<String> _excludedAgents; |
| protected Set<Pattern> _excludedAgentPatterns; |
| protected Set<String> _excludedPaths; |
| protected Set<Pattern> _excludedPathPatterns; |
| protected String _vary="Accept-Encoding, User-Agent"; |
| |
| private static final int STATE_SEPARATOR = 0; |
| private static final int STATE_Q = 1; |
| private static final int STATE_QVALUE = 2; |
| private static final int STATE_DEFAULT = 3; |
| |
| |
| /* ------------------------------------------------------------ */ |
| /** |
| * @see org.eclipse.jetty.servlets.UserAgentFilter#init(javax.servlet.FilterConfig) |
| */ |
| @Override |
| public void init(FilterConfig filterConfig) throws ServletException |
| { |
| super.init(filterConfig); |
| |
| _context=filterConfig.getServletContext(); |
| |
| String tmp=filterConfig.getInitParameter("bufferSize"); |
| if (tmp!=null) |
| _bufferSize=Integer.parseInt(tmp); |
| |
| tmp=filterConfig.getInitParameter("minGzipSize"); |
| if (tmp!=null) |
| _minGzipSize=Integer.parseInt(tmp); |
| |
| tmp=filterConfig.getInitParameter("deflateCompressionLevel"); |
| if (tmp!=null) |
| _deflateCompressionLevel=Integer.parseInt(tmp); |
| |
| tmp=filterConfig.getInitParameter("deflateNoWrap"); |
| if (tmp!=null) |
| _deflateNoWrap=Boolean.parseBoolean(tmp); |
| |
| tmp=filterConfig.getInitParameter("checkGzExists"); |
| if (tmp!=null) |
| _checkGzExists=Boolean.parseBoolean(tmp); |
| |
| tmp=filterConfig.getInitParameter("methods"); |
| if (tmp!=null) |
| { |
| StringTokenizer tok = new StringTokenizer(tmp,",",false); |
| while (tok.hasMoreTokens()) |
| _methods.add(tok.nextToken().trim().toUpperCase(Locale.ENGLISH)); |
| } |
| else |
| _methods.add(HttpMethod.GET.asString()); |
| |
| tmp=filterConfig.getInitParameter("mimeTypes"); |
| if (tmp==null) |
| { |
| _excludeMimeTypes=true; |
| tmp=filterConfig.getInitParameter("excludedMimeTypes"); |
| if (tmp==null) |
| { |
| for (String type:MimeTypes.getKnownMimeTypes()) |
| { |
| if (type.equals("image/svg+xml")) //always compressable (unless .svgz file) |
| continue; |
| if (type.startsWith("image/")|| |
| type.startsWith("audio/")|| |
| type.startsWith("video/")) |
| _mimeTypes.add(type); |
| } |
| _mimeTypes.add("application/compress"); |
| _mimeTypes.add("application/zip"); |
| _mimeTypes.add("application/gzip"); |
| } |
| else |
| { |
| StringTokenizer tok = new StringTokenizer(tmp,",",false); |
| while (tok.hasMoreTokens()) |
| _mimeTypes.add(tok.nextToken().trim()); |
| } |
| } |
| else |
| { |
| StringTokenizer tok = new StringTokenizer(tmp,",",false); |
| while (tok.hasMoreTokens()) |
| _mimeTypes.add(tok.nextToken().trim()); |
| } |
| tmp=filterConfig.getInitParameter("excludedAgents"); |
| if (tmp!=null) |
| { |
| _excludedAgents=new HashSet<String>(); |
| StringTokenizer tok = new StringTokenizer(tmp,",",false); |
| while (tok.hasMoreTokens()) |
| _excludedAgents.add(tok.nextToken().trim()); |
| } |
| |
| tmp=filterConfig.getInitParameter("excludeAgentPatterns"); |
| if (tmp!=null) |
| { |
| _excludedAgentPatterns=new HashSet<Pattern>(); |
| StringTokenizer tok = new StringTokenizer(tmp,",",false); |
| while (tok.hasMoreTokens()) |
| _excludedAgentPatterns.add(Pattern.compile(tok.nextToken().trim())); |
| } |
| |
| tmp=filterConfig.getInitParameter("excludePaths"); |
| if (tmp!=null) |
| { |
| _excludedPaths=new HashSet<String>(); |
| StringTokenizer tok = new StringTokenizer(tmp,",",false); |
| while (tok.hasMoreTokens()) |
| _excludedPaths.add(tok.nextToken().trim()); |
| } |
| |
| tmp=filterConfig.getInitParameter("excludePathPatterns"); |
| if (tmp!=null) |
| { |
| _excludedPathPatterns=new HashSet<Pattern>(); |
| StringTokenizer tok = new StringTokenizer(tmp,",",false); |
| while (tok.hasMoreTokens()) |
| _excludedPathPatterns.add(Pattern.compile(tok.nextToken().trim())); |
| } |
| |
| tmp=filterConfig.getInitParameter("vary"); |
| if (tmp!=null) |
| _vary=tmp; |
| } |
| |
| /* ------------------------------------------------------------ */ |
| /** |
| * @see org.eclipse.jetty.servlets.UserAgentFilter#destroy() |
| */ |
| @Override |
| public void destroy() |
| { |
| } |
| |
| /* ------------------------------------------------------------ */ |
| /** |
| * @see org.eclipse.jetty.servlets.UserAgentFilter#doFilter(javax.servlet.ServletRequest, javax.servlet.ServletResponse, javax.servlet.FilterChain) |
| */ |
| @Override |
| public void doFilter(ServletRequest req, ServletResponse res, FilterChain chain) |
| throws IOException, ServletException |
| { |
| HttpServletRequest request=(HttpServletRequest)req; |
| HttpServletResponse response=(HttpServletResponse)res; |
| |
| // If not a supported method or it is an Excluded URI - no Vary because no matter what client, this URI is always excluded |
| String requestURI = request.getRequestURI(); |
| if (!_methods.contains(request.getMethod()) || isExcludedPath(requestURI)) |
| { |
| super.doFilter(request,response,chain); |
| return; |
| } |
| |
| // Exclude non compressible mime-types known from URI extension. - no Vary because no matter what client, this URI is always excluded |
| if (_mimeTypes.size()>0 && _excludeMimeTypes) |
| { |
| String mimeType = _context.getMimeType(request.getRequestURI()); |
| |
| if (mimeType!=null) |
| { |
| mimeType = MimeTypes.getContentTypeWithoutCharset(mimeType); |
| if (_mimeTypes.contains(mimeType)) |
| { |
| // handle normally without setting vary header |
| super.doFilter(request,response,chain); |
| return; |
| } |
| } |
| } |
| |
| //If the Content-Encoding is already set, then we won't compress |
| if (response.getHeader("Content-Encoding") != null) |
| { |
| super.doFilter(request,response,chain); |
| return; |
| } |
| |
| if (_checkGzExists && request.getServletContext()!=null) |
| { |
| String path=request.getServletContext().getRealPath(URIUtil.addPaths(request.getServletPath(),request.getPathInfo())); |
| if (path!=null) |
| { |
| File gz=new File(path+".gz"); |
| if (gz.exists()) |
| { |
| // allow default servlet to handle |
| super.doFilter(request,response,chain); |
| return; |
| } |
| } |
| } |
| |
| // Excluded User-Agents |
| String ua = getUserAgent(request); |
| boolean ua_excluded=ua!=null&&isExcludedAgent(ua); |
| |
| // Acceptable compression type |
| String compressionType = ua_excluded?null:selectCompression(request.getHeader("accept-encoding")); |
| |
| // Special handling for etags |
| String etag = request.getHeader("If-None-Match"); |
| if (etag!=null) |
| { |
| int dd=etag.indexOf("--"); |
| if (dd>0) |
| request.setAttribute(ETAG,etag.substring(0,dd)+(etag.endsWith("\"")?"\"":"")); |
| } |
| |
| CompressedResponseWrapper wrappedResponse = createWrappedResponse(request,response,compressionType); |
| |
| boolean exceptional=true; |
| try |
| { |
| super.doFilter(request,wrappedResponse,chain); |
| exceptional=false; |
| } |
| finally |
| { |
| if (request.isAsyncStarted()) |
| { |
| request.getAsyncContext().addListener(new FinishOnCompleteListener(wrappedResponse)); |
| } |
| else if (exceptional && !response.isCommitted()) |
| { |
| wrappedResponse.resetBuffer(); |
| wrappedResponse.noCompression(); |
| } |
| else |
| wrappedResponse.finish(); |
| } |
| } |
| |
| /* ------------------------------------------------------------ */ |
| private String selectCompression(String encodingHeader) |
| { |
| // TODO, this could be a little more robust. |
| // prefer gzip over deflate |
| String compression = null; |
| if (encodingHeader!=null) |
| { |
| |
| String[] encodings = getEncodings(encodingHeader); |
| if (encodings != null) |
| { |
| for (int i=0; i< encodings.length; i++) |
| { |
| if (encodings[i].toLowerCase(Locale.ENGLISH).contains(GZIP)) |
| { |
| if (isEncodingAcceptable(encodings[i])) |
| { |
| compression = GZIP; |
| break; //prefer Gzip over deflate |
| } |
| } |
| |
| if (encodings[i].toLowerCase(Locale.ENGLISH).contains(DEFLATE)) |
| { |
| if (isEncodingAcceptable(encodings[i])) |
| { |
| compression = DEFLATE; //Keep checking in case gzip is acceptable |
| } |
| } |
| } |
| } |
| } |
| return compression; |
| } |
| |
| |
| private String[] getEncodings (String encodingHeader) |
| { |
| if (encodingHeader == null) |
| return null; |
| return encodingHeader.split(","); |
| } |
| |
| private boolean isEncodingAcceptable(String encoding) |
| { |
| int state = STATE_DEFAULT; |
| int qvalueIdx = -1; |
| for (int i=0;i<encoding.length();i++) |
| { |
| char c = encoding.charAt(i); |
| switch (state) |
| { |
| case STATE_DEFAULT: |
| { |
| if (';' == c) |
| state = STATE_SEPARATOR; |
| break; |
| } |
| case STATE_SEPARATOR: |
| { |
| if ('q' == c || 'Q' == c) |
| state = STATE_Q; |
| break; |
| } |
| case STATE_Q: |
| { |
| if ('=' == c) |
| state = STATE_QVALUE; |
| break; |
| } |
| case STATE_QVALUE: |
| { |
| if (qvalueIdx < 0 && '0' == c || '1' == c) |
| qvalueIdx = i; |
| break; |
| } |
| } |
| } |
| |
| if (qvalueIdx < 0) |
| return true; |
| |
| if ("0".equals(encoding.substring(qvalueIdx).trim())) |
| return false; |
| return true; |
| } |
| |
| |
| protected CompressedResponseWrapper createWrappedResponse(HttpServletRequest request, HttpServletResponse response, final String compressionType) |
| { |
| CompressedResponseWrapper wrappedResponse = null; |
| wrappedResponse = new CompressedResponseWrapper(request,response) |
| { |
| @Override |
| protected AbstractCompressedStream newCompressedStream(HttpServletRequest request, HttpServletResponse response) throws IOException |
| { |
| return new AbstractCompressedStream(compressionType,request,this,_vary) |
| { |
| private Deflater _allocatedDeflater; |
| private byte[] _allocatedBuffer; |
| |
| @Override |
| protected OutputStream createStream() throws IOException |
| { |
| if (compressionType == null) |
| { |
| return null; |
| } |
| |
| // acquire deflater instance |
| _allocatedDeflater = _deflater.get(); |
| if (_allocatedDeflater==null) |
| _allocatedDeflater = new Deflater(_deflateCompressionLevel,_deflateNoWrap); |
| else |
| { |
| _deflater.set(null); |
| _allocatedDeflater.reset(); |
| } |
| |
| // acquire buffer |
| _allocatedBuffer = _buffer.get(); |
| if (_allocatedBuffer==null) |
| _allocatedBuffer = new byte[_bufferSize]; |
| else |
| _buffer.set(null); |
| |
| switch (compressionType) |
| { |
| case GZIP: |
| return new GzipOutputStream(_response.getOutputStream(),_allocatedDeflater,_allocatedBuffer); |
| case DEFLATE: |
| return new DeflatedOutputStream(_response.getOutputStream(),_allocatedDeflater,_allocatedBuffer); |
| } |
| throw new IllegalStateException(compressionType + " not supported"); |
| } |
| |
| @Override |
| public void finish() throws IOException |
| { |
| super.finish(); |
| if (_allocatedDeflater != null && _deflater.get() == null) |
| { |
| _deflater.set(_allocatedDeflater); |
| } |
| if (_allocatedBuffer != null && _buffer.get() == null) |
| { |
| _buffer.set(_allocatedBuffer); |
| } |
| } |
| }; |
| } |
| }; |
| configureWrappedResponse(wrappedResponse); |
| return wrappedResponse; |
| } |
| |
| protected void configureWrappedResponse(CompressedResponseWrapper wrappedResponse) |
| { |
| IncludeExclude<String> mimeTypeExclusions = new IncludeExclude<>(); |
| if(_excludeMimeTypes) |
| mimeTypeExclusions.getExcluded().addAll(_mimeTypes); |
| else |
| mimeTypeExclusions.getIncluded().addAll(_mimeTypes); |
| |
| wrappedResponse.setMimeTypes(mimeTypeExclusions); |
| wrappedResponse.setBufferSize(_bufferSize); |
| wrappedResponse.setMinCompressSize(_minGzipSize); |
| } |
| |
| private class FinishOnCompleteListener implements AsyncListener |
| { |
| private CompressedResponseWrapper wrappedResponse; |
| |
| public FinishOnCompleteListener(CompressedResponseWrapper wrappedResponse) |
| { |
| this.wrappedResponse = wrappedResponse; |
| } |
| |
| @Override |
| public void onComplete(AsyncEvent event) throws IOException |
| { |
| try |
| { |
| wrappedResponse.finish(); |
| } |
| catch (IOException e) |
| { |
| LOG.warn(e); |
| } |
| } |
| |
| @Override |
| public void onTimeout(AsyncEvent event) throws IOException |
| { |
| } |
| |
| @Override |
| public void onError(AsyncEvent event) throws IOException |
| { |
| } |
| |
| @Override |
| public void onStartAsync(AsyncEvent event) throws IOException |
| { |
| } |
| } |
| |
| /** |
| * Checks to see if the userAgent is excluded |
| * |
| * @param ua |
| * the user agent |
| * @return boolean true if excluded |
| */ |
| private boolean isExcludedAgent(String ua) |
| { |
| if (ua == null) |
| return false; |
| |
| if (_excludedAgents != null) |
| { |
| if (_excludedAgents.contains(ua)) |
| { |
| return true; |
| } |
| } |
| if (_excludedAgentPatterns != null) |
| { |
| for (Pattern pattern : _excludedAgentPatterns) |
| { |
| if (pattern.matcher(ua).matches()) |
| { |
| return true; |
| } |
| } |
| } |
| |
| return false; |
| } |
| |
| /** |
| * Checks to see if the path is excluded |
| * |
| * @param requestURI |
| * the request uri |
| * @return boolean true if excluded |
| */ |
| private boolean isExcludedPath(String requestURI) |
| { |
| if (requestURI == null) |
| return false; |
| if (_excludedPaths != null) |
| { |
| for (String excludedPath : _excludedPaths) |
| { |
| if (requestURI.startsWith(excludedPath)) |
| { |
| return true; |
| } |
| } |
| } |
| if (_excludedPathPatterns != null) |
| { |
| for (Pattern pattern : _excludedPathPatterns) |
| { |
| if (pattern.matcher(requestURI).matches()) |
| { |
| return true; |
| } |
| } |
| } |
| return false; |
| } |
| } |