| /* |
| * Licensed to the Apache Software Foundation (ASF) under one or more |
| * contributor license agreements. See the NOTICE file distributed with |
| * this work for additional information regarding copyright ownership. |
| * The ASF licenses this file to You under the Apache License, Version 2.0 |
| * (the "License"); you may not use this file except in compliance with |
| * the License. You may obtain a copy of the License at |
| * |
| * http://www.apache.org/licenses/LICENSE-2.0 |
| * |
| * Unless required by applicable law or agreed to in writing, software |
| * distributed under the License is distributed on an "AS IS" BASIS, |
| * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. |
| * See the License for the specific language governing permissions and |
| * limitations under the License. |
| */ |
| package org.apache.coyote; |
| |
| import java.io.IOException; |
| import java.io.StringReader; |
| import java.util.ArrayList; |
| import java.util.Arrays; |
| import java.util.Enumeration; |
| import java.util.HashSet; |
| import java.util.List; |
| import java.util.Set; |
| import java.util.StringTokenizer; |
| import java.util.regex.Pattern; |
| |
| import org.apache.juli.logging.Log; |
| import org.apache.juli.logging.LogFactory; |
| import org.apache.tomcat.util.buf.MessageBytes; |
| import org.apache.tomcat.util.http.MimeHeaders; |
| import org.apache.tomcat.util.http.ResponseUtil; |
| import org.apache.tomcat.util.http.parser.AcceptEncoding; |
| import org.apache.tomcat.util.http.parser.TE; |
| import org.apache.tomcat.util.http.parser.TokenList; |
| import org.apache.tomcat.util.res.StringManager; |
| |
| /** |
| * Configuration for HTTP response compression settings. |
| */ |
| public class CompressionConfig { |
| |
| /** |
| * Creates a new compression configuration with default settings. |
| */ |
| public CompressionConfig() { |
| } |
| |
| private static final Log log = LogFactory.getLog(CompressionConfig.class); |
| private static final StringManager sm = StringManager.getManager(CompressionConfig.class); |
| |
| private int compressionLevel = 0; |
| private Pattern noCompressionUserAgents = null; |
| private String compressibleMimeType = "text/html,text/xml,text/plain,text/css," + |
| "text/javascript,application/javascript,application/json,application/xml"; |
| private String[] compressibleMimeTypes = null; |
| private int compressionMinSize = 2048; |
| private Set<String> noCompressionEncodings = |
| new HashSet<>(Arrays.asList("br", "compress", "dcb", "dcz", "deflate", "gzip", "pack200-gzip", "zstd")); |
| |
| |
| /** |
| * Returns the list of content encodings that indicate already-compressed content. |
| * |
| * @return comma-separated list of encoding names |
| */ |
| public String getNoCompressionEncodings() { |
| return String.join(",", noCompressionEncodings); |
| } |
| |
| |
| /** |
| * Set the list of content encodings that indicate already-compressed content. |
| * When content is already encoded with one of these encodings, compression will not be applied |
| * to prevent double compression. |
| * |
| * @param encodings Comma-separated list of encoding names (e.g., "gzip,br.dflate") |
| */ |
| public void setNoCompressionEncodings(String encodings) { |
| Set<String> newEncodings = new HashSet<>(); |
| if (encodings != null && !encodings.isEmpty()) { |
| StringTokenizer tokens = new StringTokenizer(encodings, ","); |
| while (tokens.hasMoreTokens()) { |
| String token = tokens.nextToken().trim(); |
| if(!token.isEmpty()) { |
| newEncodings.add(token); |
| } |
| } |
| } |
| this.noCompressionEncodings = newEncodings; |
| } |
| |
| |
| /** |
| * Set compression level. |
| * |
| * @param compression One of <code>on</code>, <code>force</code>, <code>off</code> or the minimum compression size |
| * in bytes which implies <code>on</code> |
| */ |
| public void setCompression(String compression) { |
| switch (compression) { |
| case "on" -> this.compressionLevel = 1; |
| case "force" -> this.compressionLevel = 2; |
| case "off" -> this.compressionLevel = 0; |
| default -> { |
| try { |
| // Try to parse compression as an int, which would give the |
| // minimum compression size |
| setCompressionMinSize(Integer.parseInt(compression)); |
| this.compressionLevel = 1; |
| } catch (Exception e) { |
| this.compressionLevel = 0; |
| } |
| } |
| } |
| } |
| |
| |
| /** |
| * Return compression level. |
| * |
| * @return The current compression level in string form (off/on/force) |
| */ |
| public String getCompression() { |
| return switch (compressionLevel) { |
| case 1 -> "on"; |
| case 2 -> "force"; |
| default -> "off"; |
| }; |
| } |
| |
| |
| /** |
| * Returns the internal numeric compression level. |
| * |
| * @return 0 for off, 1 for on, 2 for force |
| */ |
| public int getCompressionLevel() { |
| return compressionLevel; |
| } |
| |
| |
| /** |
| * Obtain the String form of the regular expression that defines the user agents to not use gzip with. |
| * |
| * @return The regular expression as a String |
| */ |
| public String getNoCompressionUserAgents() { |
| if (noCompressionUserAgents == null) { |
| return null; |
| } else { |
| return noCompressionUserAgents.toString(); |
| } |
| } |
| |
| |
| /** |
| * Returns the compiled regular expression pattern for user agents that should not |
| * receive compressed responses. |
| * |
| * @return the compiled pattern, or {@code null} if not configured |
| */ |
| public Pattern getNoCompressionUserAgentsPattern() { |
| return noCompressionUserAgents; |
| } |
| |
| |
| /** |
| * Set no compression user agent pattern. Regular expression as supported by {@link Pattern}. e.g.: |
| * <code>gorilla|desesplorer|tigrus</code>. |
| * |
| * @param noCompressionUserAgents The regular expression for user agent strings for which compression should not be |
| * applied |
| */ |
| public void setNoCompressionUserAgents(String noCompressionUserAgents) { |
| if (noCompressionUserAgents == null || noCompressionUserAgents.isEmpty()) { |
| this.noCompressionUserAgents = null; |
| } else { |
| this.noCompressionUserAgents = Pattern.compile(noCompressionUserAgents); |
| } |
| } |
| |
| |
| /** |
| * Returns the comma-separated list of MIME types eligible for compression. |
| * |
| * @return comma-separated MIME type string |
| */ |
| public String getCompressibleMimeType() { |
| return compressibleMimeType; |
| } |
| |
| |
| /** |
| * Sets the comma-separated list of MIME types eligible for compression. |
| * |
| * @param valueS comma-separated MIME type string |
| */ |
| public void setCompressibleMimeType(String valueS) { |
| compressibleMimeType = valueS; |
| compressibleMimeTypes = null; |
| } |
| |
| |
| /** |
| * Returns the array of MIME types eligible for compression. |
| * |
| * @return array of MIME type strings |
| */ |
| public String[] getCompressibleMimeTypes() { |
| String[] result = compressibleMimeTypes; |
| if (result != null) { |
| return result; |
| } |
| List<String> values = new ArrayList<>(); |
| StringTokenizer tokens = new StringTokenizer(compressibleMimeType, ","); |
| while (tokens.hasMoreTokens()) { |
| String token = tokens.nextToken().trim(); |
| if (!token.isEmpty()) { |
| values.add(token); |
| } |
| } |
| result = values.toArray(new String[0]); |
| compressibleMimeTypes = result; |
| return result; |
| } |
| |
| |
| /** |
| * Returns the minimum response size in bytes required before compression is applied. |
| * |
| * @return the minimum size in bytes |
| */ |
| public int getCompressionMinSize() { |
| return compressionMinSize; |
| } |
| |
| |
| /** |
| * Set Minimum size to trigger compression. |
| * |
| * @param compressionMinSize The minimum content length required for compression in bytes |
| */ |
| public void setCompressionMinSize(int compressionMinSize) { |
| this.compressionMinSize = compressionMinSize; |
| } |
| |
| |
| /** |
| * Determines if compression should be enabled for the given response and if it is, sets any necessary headers to |
| * mark it as such. |
| * |
| * @param request The request that triggered the response |
| * @param response The response to consider compressing |
| * |
| * @return {@code true} if compression was enabled for the given response, otherwise {@code false} |
| */ |
| public boolean useCompression(Request request, Response response) { |
| // Check if compression is enabled |
| if (compressionLevel == 0) { |
| return false; |
| } |
| |
| boolean useTransferEncoding = false; |
| boolean useContentEncoding = true; |
| |
| MimeHeaders responseHeaders = response.getMimeHeaders(); |
| |
| // Check if content is not already compressed |
| MessageBytes contentEncodingMB = responseHeaders.getValue("Content-Encoding"); |
| if (contentEncodingMB != null) { |
| // Content-Encoding values are ordered but order is not important |
| // for this check so use a Set rather than a List |
| Set<String> tokens = new HashSet<>(); |
| try { |
| TokenList.parseTokenList(responseHeaders.values("Content-Encoding"), tokens); |
| } catch (IOException ioe) { |
| // Because we are using StringReader, any exception here is a |
| // Tomcat bug. |
| log.warn(sm.getString("compressionConfig.ContentEncodingParseFail"), ioe); |
| return false; |
| } |
| if (tokens.contains("identity")) { |
| // If identity, do not do content modifications |
| useContentEncoding = false; |
| } else if (noCompressionEncodings.stream().anyMatch(tokens::contains)) { |
| // Content should not be compressed twice |
| return false; |
| } |
| } |
| |
| // If force mode, the length and MIME type checks are skipped |
| if (compressionLevel != 2) { |
| // Check if the response is of sufficient length to trigger the compression |
| long contentLength = response.getContentLengthLong(); |
| if (contentLength != -1 && contentLength < compressionMinSize) { |
| return false; |
| } |
| |
| // Check for compatible MIME-TYPE |
| String[] compressibleMimeTypes = getCompressibleMimeTypes(); |
| if (compressibleMimeTypes != null && |
| !startsWithStringArray(compressibleMimeTypes, response.getContentType())) { |
| return false; |
| } |
| } |
| |
| Enumeration<String> headerValues = request.getMimeHeaders().values("TE"); |
| boolean foundGzip = false; |
| // TE and accept-encoding seem to have equivalent syntax |
| while (!foundGzip && headerValues.hasMoreElements()) { |
| List<TE> tes; |
| try { |
| tes = TE.parse(new StringReader(headerValues.nextElement())); |
| } catch (IOException ioe) { |
| // If there is a problem reading the header, disable compression |
| return false; |
| } |
| |
| for (TE te : tes) { |
| if ("gzip".equalsIgnoreCase(te.getEncoding())) { |
| useTransferEncoding = true; |
| foundGzip = true; |
| break; |
| } |
| } |
| } |
| |
| // Check if the resource has a strong ETag |
| String eTag = responseHeaders.getHeader("ETag"); |
| if (!useTransferEncoding && eTag != null && !eTag.trim().startsWith("W/")) { |
| // Has an ETag that doesn't start with "W/..." so it must be a |
| // strong ETag |
| return false; |
| } |
| |
| if (useContentEncoding && !useTransferEncoding) { |
| // If processing reaches this far, the response might be compressed. |
| // Therefore, set the Vary header to keep proxies happy |
| ResponseUtil.addVaryFieldName(responseHeaders, "accept-encoding"); |
| |
| // Check if user-agent supports gzip encoding |
| // Only interested in whether gzip encoding is supported. Other |
| // encodings and weights can be ignored. |
| headerValues = request.getMimeHeaders().values("accept-encoding"); |
| while (!foundGzip && headerValues.hasMoreElements()) { |
| List<AcceptEncoding> acceptEncodings; |
| try { |
| acceptEncodings = AcceptEncoding.parse(new StringReader(headerValues.nextElement())); |
| } catch (IOException ioe) { |
| // If there is a problem reading the header, disable compression |
| return false; |
| } |
| |
| for (AcceptEncoding acceptEncoding : acceptEncodings) { |
| if ("gzip".equalsIgnoreCase(acceptEncoding.getEncoding())) { |
| foundGzip = true; |
| break; |
| } |
| } |
| } |
| } |
| |
| if (!foundGzip) { |
| return false; |
| } |
| |
| // If force mode, the browser checks are skipped |
| if (compressionLevel != 2) { |
| // Check for incompatible Browser |
| Pattern noCompressionUserAgents = this.noCompressionUserAgents; |
| if (noCompressionUserAgents != null) { |
| MessageBytes userAgentValueMB = request.getMimeHeaders().getValue("user-agent"); |
| if (userAgentValueMB != null) { |
| String userAgentValue = userAgentValueMB.toString(); |
| if (noCompressionUserAgents.matcher(userAgentValue).matches()) { |
| return false; |
| } |
| } |
| } |
| } |
| |
| // All checks have passed. Compression is enabled. |
| |
| // Compressed content length is unknown so mark it as such. |
| response.setContentLength(-1); |
| if (useTransferEncoding) { |
| // Configure the transfer encoding for compressed content |
| responseHeaders.addValue("Transfer-Encoding").setString("gzip"); |
| } else { |
| // Configure the content encoding for compressed content |
| responseHeaders.addValue("Content-Encoding").setString("gzip"); |
| } |
| |
| return true; |
| } |
| |
| |
| /** |
| * Checks if any entry in the string array starts with the specified value |
| * |
| * @param sArray the StringArray |
| * @param value string |
| */ |
| private static boolean startsWithStringArray(String[] sArray, String value) { |
| if (value == null) { |
| return false; |
| } |
| for (String s : sArray) { |
| if (value.startsWith(s)) { |
| return true; |
| } |
| } |
| return false; |
| } |
| } |