diff --git a/java/org/apache/catalina/connector/Connector.java b/java/org/apache/catalina/connector/Connector.java index 9dda386a089e..e3e6fbcc4183 100644 --- a/java/org/apache/catalina/connector/Connector.java +++ b/java/org/apache/catalina/connector/Connector.java @@ -34,6 +34,7 @@ import org.apache.coyote.ProtocolHandler; import org.apache.coyote.UpgradeProtocol; import org.apache.coyote.http11.AbstractHttp11Protocol; +import org.apache.coyote.http11.filters.OutputFilterFactory; import org.apache.juli.logging.Log; import org.apache.juli.logging.LogFactory; import org.apache.tomcat.jni.AprStatus; @@ -910,6 +911,12 @@ public UpgradeProtocol[] findUpgradeProtocols() { } + public void addOutputFilterFactory(OutputFilterFactory factory) { + if (protocolHandler instanceof AbstractHttp11Protocol) { + ((AbstractHttp11Protocol) protocolHandler).addOutputFilterFactory(factory); + } + } + public String getEncodedReverseSolidusHandling() { return encodedReverseSolidusHandling.getValue(); } diff --git a/java/org/apache/catalina/startup/Catalina.java b/java/org/apache/catalina/startup/Catalina.java index 0585e1574518..5cb59883081d 100644 --- a/java/org/apache/catalina/startup/Catalina.java +++ b/java/org/apache/catalina/startup/Catalina.java @@ -472,6 +472,13 @@ protected Digester createStartDigester() { digester.addSetNext("Server/Service/Connector/UpgradeProtocol", "addUpgradeProtocol", "org.apache.coyote.UpgradeProtocol"); + digester.addObjectCreate("Server/Service/Connector/OutputFilterFactory", null, "className"); + digester.addSetProperties("Server/Service/Connector/OutputFilterFactory"); + digester.addSetNext( + "Server/Service/Connector/OutputFilterFactory", + "addOutputFilterFactory", + "org.apache.coyote.http11.filters.OutputFilterFactory"); + // Add RuleSets for nested elements digester.addRuleSet(new NamingRuleSet("Server/GlobalNamingResources/")); digester.addRuleSet(new EngineRuleSet("Server/Service/")); diff --git a/java/org/apache/coyote/CompressionConfig.java b/java/org/apache/coyote/CompressionConfig.java index 2cf718936b19..8195c6cf73bd 100644 --- a/java/org/apache/coyote/CompressionConfig.java +++ b/java/org/apache/coyote/CompressionConfig.java @@ -18,15 +18,10 @@ 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.*; import java.util.regex.Pattern; +import org.apache.coyote.http11.filters.OutputFilterFactory; import org.apache.juli.logging.Log; import org.apache.juli.logging.LogFactory; import org.apache.tomcat.util.buf.MessageBytes; @@ -62,7 +57,7 @@ public String getNoCompressionEncodings() { * 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") + * @param encodings Comma-separated list of encoding names (e.g., "gzip,br.deflate") */ public void setNoCompressionEncodings(String encodings) { Set newEncodings = new HashSet<>(); @@ -70,7 +65,7 @@ public void setNoCompressionEncodings(String encodings) { StringTokenizer tokens = new StringTokenizer(encodings, ","); while (tokens.hasMoreTokens()) { String token = tokens.nextToken().trim(); - if(!token.isEmpty()) { + if (!token.isEmpty()) { newEncodings.add(token); } } @@ -192,7 +187,6 @@ public int getCompressionMinSize() { return compressionMinSize; } - /** * Set Minimum size to trigger compression. * @@ -202,20 +196,25 @@ 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. + * Determines if compression should be enabled for the given response using the + * registered output filter factories. Performs Accept-Encoding negotiation to + * select the best matching factory. * - * @param request The request that triggered the response - * @param response The response to consider compressing + * @param request The request that triggered the response + * @param response The response to consider compressing + * @param factories The list of available output filter factories (in server priority order) * - * @return {@code true} if compression was enabled for the given response, otherwise {@code false} + * @return The selected factory if compression should be used, or {@code null} if not */ - public boolean useCompression(Request request, Response response) { + public OutputFilterFactory useCompression(Request request, Response response, List factories) { + if (factories == null || factories.isEmpty()) { + return null; + } + // Check if compression is enabled if (compressionLevel == 0) { - return false; + return null; } boolean useTransferEncoding = false; @@ -235,14 +234,14 @@ public boolean useCompression(Request request, Response response) { // Because we are using StringReader, any exception here is a // Tomcat bug. log.warn(sm.getString("compressionConfig.ContentEncodingParseFail"), ioe); - return false; + return null; } 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; + return null; } } @@ -251,75 +250,73 @@ public boolean useCompression(Request request, Response response) { // Check if the response is of sufficient length to trigger the compression long contentLength = response.getContentLengthLong(); if (contentLength != -1 && contentLength < compressionMinSize) { - return false; + return null; } // Check for compatible MIME-TYPE String[] compressibleMimeTypes = getCompressibleMimeTypes(); if (compressibleMimeTypes != null && !startsWithStringArray(compressibleMimeTypes, response.getContentType())) { - return false; + return null; } } + // Try TE header first + OutputFilterFactory teFactory = null; Enumeration headerValues = request.getMimeHeaders().values("TE"); - boolean foundGzip = false; // TE and accept-encoding seem to have equivalent syntax - while (!foundGzip && headerValues.hasMoreElements()) { + while (headerValues.hasMoreElements()) { List tes; try { tes = TE.parse(new StringReader(headerValues.nextElement())); } catch (IOException ioe) { // If there is a problem reading the header, disable compression - return false; + return null; } - for (TE te : tes) { - if ("gzip".equalsIgnoreCase(te.getEncoding())) { - useTransferEncoding = true; - foundGzip = true; - break; - } + teFactory = EncodingNegotiator.negotiateTE(factories, tes); + if(teFactory != null) { + useTransferEncoding = true; + break; } } // Check if the resource has a strong ETag String eTag = responseHeaders.getHeader("ETag"); - if (!useTransferEncoding && eTag != null && !eTag.trim().startsWith("W/")) { + if (teFactory == null && eTag != null && !eTag.trim().startsWith("W/")) { // Has an ETag that doesn't start with "W/..." so it must be a // strong ETag - return false; + return null; } - if (useContentEncoding && !useTransferEncoding) { - // If processing reaches this far, the response might be compressed. - // Therefore, set the Vary header to keep proxies happy + OutputFilterFactory selectedFactory = teFactory; + + if (useContentEncoding && selectedFactory == null) { + // Set Vary header before checking Accept-Encoding ResponseUtil.addVaryFieldName(responseHeaders, "accept-encoding"); - // Check if user-agent supports gzip encoding - // Only interested in whether gzip encoding is supported. Other + // Check if user-agent supports the specified encoding + // Only interested in whether the encoding is supported. Other // encodings and weights can be ignored. headerValues = request.getMimeHeaders().values("accept-encoding"); - while (!foundGzip && headerValues.hasMoreElements()) { + while (headerValues.hasMoreElements()) { List acceptEncodings; try { acceptEncodings = AcceptEncoding.parse(new StringReader(headerValues.nextElement())); } catch (IOException ioe) { // If there is a problem reading the header, disable compression - return false; + return null; } - for (AcceptEncoding acceptEncoding : acceptEncodings) { - if ("gzip".equalsIgnoreCase(acceptEncoding.getEncoding())) { - foundGzip = true; - break; - } + selectedFactory = EncodingNegotiator.negotiateAcceptEncoding(factories, acceptEncodings); + if (selectedFactory != null) { + break; } } } - if (!foundGzip) { - return false; + if (selectedFactory == null) { + return null; } // If force mode, the browser checks are skipped @@ -331,28 +328,28 @@ public boolean useCompression(Request request, Response response) { if (userAgentValueMB != null) { String userAgentValue = userAgentValueMB.toString(); if (noCompressionUserAgents.matcher(userAgentValue).matches()) { - return false; + return null; } } } } // All checks have passed. Compression is enabled. + String encoding = selectedFactory.getEncodingName(); // 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"); + responseHeaders.addValue("Transfer-Encoding").setString(encoding); } else { // Configure the content encoding for compressed content - responseHeaders.addValue("Content-Encoding").setString("gzip"); + responseHeaders.addValue("Content-Encoding").setString(encoding); } - return true; + return selectedFactory; } - /** * Checks if any entry in the string array starts with the specified value * diff --git a/java/org/apache/coyote/EncodingNegotiator.java b/java/org/apache/coyote/EncodingNegotiator.java new file mode 100644 index 000000000000..448e4432ec54 --- /dev/null +++ b/java/org/apache/coyote/EncodingNegotiator.java @@ -0,0 +1,100 @@ +/* + * 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.util.List; +import java.util.Locale; +import java.util.function.Function; +import java.util.function.ToDoubleFunction; + +import org.apache.coyote.http11.filters.OutputFilterFactory; +import org.apache.tomcat.util.http.parser.AcceptEncoding; +import org.apache.tomcat.util.http.parser.TE; + +/** + * Utility class for negotiating transfer/content encodings using TE and + * Accept-Encoding headers. + */ +public class EncodingNegotiator { + + /** + * Selects the best {@link OutputFilterFactory} for the HTTP {@code TE} header. + *

Rules: + * - Prefer the entry with the highest quality (q) value; entries with {@code q <= 0} are ignored. + * - Wildcard ({@code *}) matches any server factory. + * - On quality ties, prefer the factory with the lowest server priority (its index in {@code factories}). + * - Encoding name matching is case-insensitive. + * + * @param factories The available factories in server-priority order (index 0 is highest) + * @param entries The parsed {@code TE} header entries + * @return The selected factory or {@code null} if no match + */ + public static OutputFilterFactory negotiateTE(List factories, List entries) { + return negotiateEncodings(factories, entries, TE::getEncoding, TE::getQuality); + } + + /** + * Selects the best {@link OutputFilterFactory} for the HTTP {@code Accept-Encoding} header. + *

Rules: + * - Prefer the entry with the highest quality (q) value; entries with {@code q <= 0} are ignored. + * - Wildcard ({@code *}) matches any server factory. + * - On quality ties, prefer the factory with the lowest server priority (its index in {@code factories}). + * - Encoding name matching is case-insensitive. + * + * @param factories The available factories in server-priority order (index 0 is highest) + * @param acceptEncodings The parsed {@code Accept-Encoding} entries + * @return The selected factory or {@code null} if no match + */ + public static OutputFilterFactory negotiateAcceptEncoding( + List factories, List acceptEncodings) { + return negotiateEncodings(factories, acceptEncodings, AcceptEncoding::getEncoding, AcceptEncoding::getQuality); + } + + private static OutputFilterFactory negotiateEncodings( + List factories, + List entries, + Function encodingFn, + ToDoubleFunction qualityFn) { + OutputFilterFactory bestFactory = null; + double bestQuality = 0; + int bestServerPriority = Integer.MAX_VALUE; + + for (int i = 0; i < factories.size(); i++) { + OutputFilterFactory factory = factories.get(i); + String factoryEncoding = factory.getEncodingName().toLowerCase(Locale.ENGLISH); + + for (T entry : entries) { + String entryEncoding = encodingFn.apply(entry).toLowerCase(Locale.ENGLISH); + double quality = qualityFn.applyAsDouble(entry); + + if (quality <= 0) { + continue; + } + + if (factoryEncoding.equals(entryEncoding) || "*".equals(entryEncoding)) { + if (quality > bestQuality || (quality == bestQuality && i < bestServerPriority)) { + bestFactory = factory; + bestQuality = quality; + bestServerPriority = i; + } + } + } + } + + return bestFactory; + } +} diff --git a/java/org/apache/coyote/http11/AbstractHttp11Protocol.java b/java/org/apache/coyote/http11/AbstractHttp11Protocol.java index f5ee4821d384..ebd25c6df5d4 100644 --- a/java/org/apache/coyote/http11/AbstractHttp11Protocol.java +++ b/java/org/apache/coyote/http11/AbstractHttp11Protocol.java @@ -40,6 +40,8 @@ import org.apache.coyote.Response; import org.apache.coyote.UpgradeProtocol; import org.apache.coyote.UpgradeToken; +import org.apache.coyote.http11.filters.GzipOutputFilterFactory; +import org.apache.coyote.http11.filters.OutputFilterFactory; import org.apache.coyote.http11.upgrade.InternalHttpUpgradeHandler; import org.apache.coyote.http11.upgrade.UpgradeGroupInfo; import org.apache.coyote.http11.upgrade.UpgradeProcessorExternal; @@ -59,6 +61,8 @@ public abstract class AbstractHttp11Protocol extends AbstractProtocol { protected static final StringManager sm = StringManager.getManager(AbstractHttp11Protocol.class); private final CompressionConfig compressionConfig = new CompressionConfig(); + private final List outputFilterFactories = new ArrayList<>(); + private boolean outputFilterFactoryConfigured = false; private HttpParser httpParser = null; @@ -339,7 +343,6 @@ public void setCompressionMinSize(int compressionMinSize) { compressionConfig.setCompressionMinSize(compressionMinSize); } - public String getNoCompressionEncodings() { return compressionConfig.getNoCompressionEncodings(); } @@ -348,11 +351,43 @@ public void setNoCompressionEncodings(String encodings) { compressionConfig.setNoCompressionEncodings(encodings); } + /** + * Add an output filter factory. Called by Digester when parsing + * {@code } elements in server.xml. + * + * @param factory The factory to add + */ + public void addOutputFilterFactory(OutputFilterFactory factory) { + outputFilterFactories.add(factory); + outputFilterFactoryConfigured = true; + } - public boolean useCompression(Request request, Response response) { - return compressionConfig.useCompression(request, response); + /** + * Get the list of configured output filter factories. + * If none have been explicitly configured, a default + * {@link GzipOutputFilterFactory} is returned. + * + * @return The list of output filter factories, never null + */ + public List getOutputFilterFactories() { + if (!outputFilterFactoryConfigured && outputFilterFactories.isEmpty()) { + outputFilterFactories.add(new GzipOutputFilterFactory()); + } + return outputFilterFactories; } + /** + * Determin if compression should be used for this response. + * + * @param request The request + * @param response The response + * + * @return The factory to use for compression, or {@code null} if + * compression should not be used + */ + public OutputFilterFactory useCompression(Request request, Response response) { + return compressionConfig.useCompression(request, response, getOutputFilterFactories()); + } private Pattern restrictedUserAgents = null; diff --git a/java/org/apache/coyote/http11/Constants.java b/java/org/apache/coyote/http11/Constants.java index 7f1c2a99c4ae..2d6f3d516fa7 100644 --- a/java/org/apache/coyote/http11/Constants.java +++ b/java/org/apache/coyote/http11/Constants.java @@ -107,6 +107,9 @@ public final class Constants { /** * GZIP filter (output). + * + * @deprecated Compression filters are no longer part of the static filter library. + * They are created dynamically by {@link org.apache.coyote.http11.filters.OutputFilterFactory}. */ public static final int GZIP_FILTER = 3; diff --git a/java/org/apache/coyote/http11/Http11OutputBuffer.java b/java/org/apache/coyote/http11/Http11OutputBuffer.java index fdd53fd29c26..68f8d5425fc3 100644 --- a/java/org/apache/coyote/http11/Http11OutputBuffer.java +++ b/java/org/apache/coyote/http11/Http11OutputBuffer.java @@ -132,7 +132,7 @@ public void addFilter(OutputFilter filter) { newFilterLibrary[filterLibrary.length] = filter; filterLibrary = newFilterLibrary; - activeFilters = new OutputFilter[filterLibrary.length]; + activeFilters = new OutputFilter[filterLibrary.length + 1]; } diff --git a/java/org/apache/coyote/http11/Http11Processor.java b/java/org/apache/coyote/http11/Http11Processor.java index 2736d6449a23..d303a82c5c3d 100644 --- a/java/org/apache/coyote/http11/Http11Processor.java +++ b/java/org/apache/coyote/http11/Http11Processor.java @@ -47,6 +47,7 @@ import org.apache.coyote.http11.filters.SavedRequestInputFilter; import org.apache.coyote.http11.filters.VoidInputFilter; import org.apache.coyote.http11.filters.VoidOutputFilter; +import org.apache.coyote.http11.filters.OutputFilterFactory; import org.apache.coyote.http11.upgrade.InternalHttpUpgradeHandler; import org.apache.coyote.http11.upgrade.UpgradeApplicationBufferHandler; import org.apache.juli.logging.Log; @@ -182,10 +183,6 @@ public Http11Processor(AbstractHttp11Protocol protocol, Adapter adapter) { // Create and add buffered input filter inputBuffer.addFilter(new BufferedInputFilter(protocol.getMaxSwallowSize())); - // Create and add the gzip filters. - // inputBuffer.addFilter(new GzipInputFilter()); - outputBuffer.addFilter(new GzipOutputFilter()); - pluggableFilterIndex = inputBuffer.getFilters().length; } @@ -885,9 +882,9 @@ protected final void prepareResponse() throws IOException { } // Check for compression - boolean useCompression = false; + OutputFilterFactory compressionFactory = null; if (entityBody && sendfileData == null) { - useCompression = protocol.useCompression(request, response); + compressionFactory = protocol.useCompression(request, response); } MimeHeaders headers = response.getMimeHeaders(); @@ -933,8 +930,9 @@ protected final void prepareResponse() throws IOException { } } - if (useCompression) { - outputBuffer.addActiveFilter(outputFilters[Constants.GZIP_FILTER]); + if (compressionFactory != null) { + // Add the negotiated compression filter + outputBuffer.addActiveFilter(compressionFactory.createFilter()); } // Add date header unless application has already set one (e.g. in a diff --git a/java/org/apache/coyote/http11/LocalStrings.properties b/java/org/apache/coyote/http11/LocalStrings.properties index 66ac9957714c..5ccd093bbef5 100644 --- a/java/org/apache/coyote/http11/LocalStrings.properties +++ b/java/org/apache/coyote/http11/LocalStrings.properties @@ -18,6 +18,7 @@ abstractHttp11Protocol.alpnWithNoAlpn=The upgrade handler [{0}] for [{1}] only s abstractHttp11Protocol.httpUpgradeConfigured=The [{0}] connector has been configured to support HTTP upgrade to [{1}] abstractHttp11Protocol.upgradeJmxNameFail=Failed to create ObjectName with which to register upgrade protocol in JMX abstractHttp11Protocol.upgradeJmxRegistrationFail=Failed to register upgrade protocol in JMX +abstractHttp11Protocol.invalidOutputFilterFactory=The output filter factory class [{0}] could not be loaded or instantiated. http11processor.fallToDebug=\n\ \ Note: further occurrences of HTTP request parsing errors will be logged at DEBUG level. diff --git a/java/org/apache/coyote/http11/filters/GzipOutputFilter.java b/java/org/apache/coyote/http11/filters/GzipOutputFilter.java index 275a49c2b762..d1e5fb6884a3 100644 --- a/java/org/apache/coyote/http11/filters/GzipOutputFilter.java +++ b/java/org/apache/coyote/http11/filters/GzipOutputFilter.java @@ -19,6 +19,7 @@ import java.io.IOException; import java.io.OutputStream; import java.nio.ByteBuffer; +import java.util.zip.Deflater; import java.util.zip.GZIPOutputStream; import org.apache.coyote.Response; @@ -32,7 +33,7 @@ * Gzip output filter. */ public class GzipOutputFilter implements OutputFilter { - + public static final int DEFAULT_BUFFER_SIZE = 512; protected static final Log log = LogFactory.getLog(GzipOutputFilter.class); private static final StringManager sm = StringManager.getManager(GzipOutputFilter.class); @@ -56,13 +57,24 @@ public class GzipOutputFilter implements OutputFilter { */ protected final OutputStream fakeOutputStream = new FakeOutputStream(); + /** + * Compression level for gzip. Valid values are -1 (default), or 1-9 + */ + private int level = Deflater.DEFAULT_COMPRESSION; + + /** + * Buffer size for gzip compression stream. Default is Deflater default size 512. + */ + private int bufferSize = DEFAULT_BUFFER_SIZE; // --------------------------------------------------- OutputBuffer Methods @Override public int doWrite(ByteBuffer chunk) throws IOException { if (compressionStream == null) { - compressionStream = new GZIPOutputStream(fakeOutputStream, true); + compressionStream = new GZIPOutputStream(fakeOutputStream, bufferSize, true) {{ + this.def.setLevel(level); + }}; } int len = chunk.remaining(); if (chunk.hasArray()) { @@ -121,7 +133,9 @@ public void setBuffer(HttpOutputBuffer buffer) { @Override public void end() throws IOException { if (compressionStream == null) { - compressionStream = new GZIPOutputStream(fakeOutputStream, true); + compressionStream = new GZIPOutputStream(fakeOutputStream, bufferSize, true) {{ + this.def.setLevel(level); + }}; } compressionStream.finish(); compressionStream.close(); @@ -135,7 +149,31 @@ public void recycle() { compressionStream = null; } + /** + * Set the compression level for gzip. + * @param level The compression level. Valid values are -1 (default), or 1-9. + * -1 uses the default compression level. + * 1 gives best speed, 9 gives best compression. + */ + public void setLevel(int level) { + if (level < -1 || level > 9) { + throw new IllegalArgumentException(sm.getString("gzipOutputFilter.invalidLevel", Integer.valueOf(level))); + } + this.level = level; + } + /** + * Set the buffer size for gzip compression stream. + * + * @param bufferSize The buffer size in bytes. Must be positive. + */ + public void setBufferSize(int bufferSize) { + if (bufferSize <= 0) { + throw new IllegalArgumentException( + sm.getString("gzipOutputFilter.invalidBufferSize", Integer.valueOf(bufferSize))); + } + this.bufferSize = bufferSize; + } // ------------------------------------------- FakeOutputStream Inner Class diff --git a/java/org/apache/coyote/http11/filters/GzipOutputFilterFactory.java b/java/org/apache/coyote/http11/filters/GzipOutputFilterFactory.java new file mode 100644 index 000000000000..b984920a5219 --- /dev/null +++ b/java/org/apache/coyote/http11/filters/GzipOutputFilterFactory.java @@ -0,0 +1,91 @@ +/* + * 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.http11.filters; + +import org.apache.coyote.http11.AbstractHttp11Protocol; +import org.apache.coyote.http11.OutputFilter; +import org.apache.tomcat.util.res.StringManager; + +/** + * Factory for creating {@link GzipOutputFilter} instances. + * This is the default output filter factory used by Tomcat. + *

+ * Configuration is held as JavaBean properties on this factory. + * Allowing Digester to set them from server.xml attributes. + */ +public class GzipOutputFilterFactory implements OutputFilterFactory { + + private static final StringManager sm = StringManager.getManager(GzipOutputFilter.class); + + private int level = -1; + private int bufferSize = GzipOutputFilter.DEFAULT_BUFFER_SIZE; + + public int getLevel() { + return level; + } + + /** + * Set the gzip compression level. + * + * @param level The compression level (-1 for default, 1-9 for specific levels) + * + * @throws IllegalArgumentException if the level is out of range + */ + public void setLevel(int level) { + if (level < -1 || level > 9) { + throw new IllegalArgumentException( + sm.getString("gzipOutputFilterFactory.invalidLevel", level)); + } + this.level = level; + } + + public int getBufferSize() { + return bufferSize; + } + + /** + * Set the buffer size for gzip compression. + * + * @param bufferSize The buffer size in bytes (must be positive) + * + * @throws IllegalArgumentException if the buffer size is not positive + */ + public void setBufferSize(int bufferSize) { + if (bufferSize < 0) { + throw new IllegalArgumentException( + sm.getString("gzipOutputFilterFactory.invalidBufferSize", bufferSize)); + } + this.bufferSize = bufferSize; + } + + @Override + public OutputFilter createFilter() { + GzipOutputFilter filter = new GzipOutputFilter(); + + // Apply configuration from protocol + filter.setLevel(level); + filter.setBufferSize(bufferSize); + + return filter; + } + + @Override + public String getEncodingName() { + return "gzip"; + } +} diff --git a/java/org/apache/coyote/http11/filters/LocalStrings.properties b/java/org/apache/coyote/http11/filters/LocalStrings.properties index 3650316b88f9..e0d3ad9303cb 100644 --- a/java/org/apache/coyote/http11/filters/LocalStrings.properties +++ b/java/org/apache/coyote/http11/filters/LocalStrings.properties @@ -30,5 +30,11 @@ chunkedInputFilter.maxExtension=maxExtensionSize exceeded chunkedInputFilter.maxTrailer=maxTrailerSize exceeded gzipOutputFilter.flushFail=Ignored exception while flushing gzip filter +gzipOutputFilter.invalidLevel=The gzip compression level [{0}] is not valid. Valid values are -1 (default) or 1-9. +gzipOutputFilter.invalidBufferSize=The gzip buffer size [{0}] is not valid. Must be a positive integer. + +# Factory messages (mirror gzipOutputFilter messages for factory-level validation) +gzipOutputFilterFactory.invalidLevel=The gzip compression level [{0}] is not valid. Valid values are -1 (default) or 1-9. +gzipOutputFilterFactory.invalidBufferSize=The gzip buffer size [{0}] is not valid. Must be a positive integer. inputFilter.maxSwallow=maxSwallowSize exceeded diff --git a/java/org/apache/coyote/http11/filters/OutputFilterFactory.java b/java/org/apache/coyote/http11/filters/OutputFilterFactory.java new file mode 100644 index 000000000000..0dcb6d1618e2 --- /dev/null +++ b/java/org/apache/coyote/http11/filters/OutputFilterFactory.java @@ -0,0 +1,33 @@ +package org.apache.coyote.http11.filters; + +import org.apache.coyote.http11.AbstractHttp11Protocol; +import org.apache.coyote.http11.OutputFilter; + +/** + * Factory interface for creating output filters. + * Allows pluggable compression and transformation filters. + *

+ * Implementations hold their own configuration as JavaBean properties. + * Which can be set via nested elements in server.xml. + */ +public interface OutputFilterFactory { + + /** + * Create a new output filter instance. + *

+ * The factory is expected to configure the filter using its own + * JavaBean properties rather than relying on external configuration + * + * @return A configured output filter ready for use + */ + OutputFilter createFilter(); + + /** + * Get the encoding name for this filter. + * Used for Content-Encoding or Transfer-Encoding headers and + * for matching against client Accept-Encoding preferences. + * + * @return The encoding name (e.g., "gzip", "br", "deflate", "zstd") + */ + String getEncodingName(); +} diff --git a/java/org/apache/coyote/http2/Http2Protocol.java b/java/org/apache/coyote/http2/Http2Protocol.java index d6ad4b1e7a92..7a909a4b8827 100644 --- a/java/org/apache/coyote/http2/Http2Protocol.java +++ b/java/org/apache/coyote/http2/Http2Protocol.java @@ -30,6 +30,7 @@ import org.apache.coyote.UpgradeProtocol; import org.apache.coyote.UpgradeToken; import org.apache.coyote.http11.AbstractHttp11Protocol; +import org.apache.coyote.http11.filters.OutputFilterFactory; import org.apache.coyote.http11.upgrade.InternalHttpUpgradeHandler; import org.apache.coyote.http11.upgrade.UpgradeProcessorInternal; import org.apache.juli.logging.Log; @@ -366,7 +367,7 @@ public boolean getInitiatePingDisabled() { } - public boolean useCompression(Request request, Response response) { + public OutputFilterFactory useCompression(Request request, Response response) { return http11Protocol.useCompression(request, response); } diff --git a/java/org/apache/coyote/http2/StreamProcessor.java b/java/org/apache/coyote/http2/StreamProcessor.java index 0b9b3110315f..d1f6f878700b 100644 --- a/java/org/apache/coyote/http2/StreamProcessor.java +++ b/java/org/apache/coyote/http2/StreamProcessor.java @@ -39,7 +39,9 @@ import org.apache.coyote.Request; import org.apache.coyote.RequestGroupInfo; import org.apache.coyote.Response; +import org.apache.coyote.http11.AbstractHttp11Protocol; import org.apache.coyote.http11.filters.GzipOutputFilter; +import org.apache.coyote.http11.filters.OutputFilterFactory; import org.apache.juli.logging.Log; import org.apache.juli.logging.LogFactory; import org.apache.tomcat.util.buf.ByteChunk; @@ -208,10 +210,14 @@ static void prepareHeaders(Request coyoteRequest, Response coyoteResponse, boole // Compression can't be used with sendfile // Need to check for compression (and set headers appropriately) before // adding headers below - if (noSendfile && protocol != null && protocol.useCompression(coyoteRequest, coyoteResponse)) { + if (noSendfile && protocol != null) { // Enable compression. Headers will have been set. Need to configure // output filter at this point. - stream.addOutputFilter(new GzipOutputFilter()); + OutputFilterFactory factory = protocol.useCompression(coyoteRequest, coyoteResponse); + + if (factory != null) { + stream.addOutputFilter(factory.createFilter()); + } } // Check to see if a response body is present diff --git a/test/org/apache/coyote/TestCompressionConfig.java b/test/org/apache/coyote/TestCompressionConfig.java index be336ec7e6df..065d8c6791cf 100644 --- a/test/org/apache/coyote/TestCompressionConfig.java +++ b/test/org/apache/coyote/TestCompressionConfig.java @@ -20,7 +20,12 @@ import java.util.Arrays; import java.util.Collection; import java.util.List; +import java.util.zip.Deflater; +import org.apache.coyote.http11.OutputFilter; +import org.apache.coyote.http11.filters.GzipOutputFilter; +import org.apache.coyote.http11.filters.GzipOutputFilterFactory; +import org.apache.coyote.http11.filters.OutputFilterFactory; import org.junit.Assert; import org.junit.Test; import org.junit.runner.RunWith; @@ -93,19 +98,21 @@ public void testUseCompression() throws Exception { response.getMimeHeaders().addValue("ETag").setString(eTag); } - boolean useCompression = compressionConfig.useCompression(request, response); - Assert.assertEquals(compress, Boolean.valueOf(useCompression)); + List factories = new ArrayList<>(); + factories.add(new GzipOutputFilterFactory()); + OutputFilterFactory result = compressionConfig.useCompression(request, response, factories); + Assert.assertEquals(compress.booleanValue(), result != null); if (useTE.booleanValue()) { Assert.assertNull(response.getMimeHeaders().getHeader("Content-Encoding")); - if (useCompression) { + if (result != null) { Assert.assertEquals("gzip", response.getMimeHeaders().getHeader("Transfer-Encoding")); } else { Assert.assertNull(response.getMimeHeaders().getHeader("Transfer-Encoding")); } } else { Assert.assertNull(response.getMimeHeaders().getHeader("Transfer-Encoding")); - if (useCompression) { + if (result != null) { Assert.assertEquals("gzip", response.getMimeHeaders().getHeader("Content-Encoding")); } else { Assert.assertNull(response.getMimeHeaders().getHeader("Content-Encoding")); @@ -126,4 +133,121 @@ public void testNoCompressionEncodings() { Assert.assertTrue(newEncodings.contains("br")); Assert.assertFalse(newEncodings.contains("gzip")); } + + @Test + public void testGzipOutputFilterFactoryJavaBeanProperties() { + GzipOutputFilterFactory factory = new GzipOutputFilterFactory(); + + Assert.assertEquals(-1, factory.getLevel()); + Assert.assertEquals(GzipOutputFilter.DEFAULT_BUFFER_SIZE, factory.getBufferSize()); + Assert.assertEquals("gzip", factory.getEncodingName()); + + factory.setLevel(6); + factory.setBufferSize(1024); + + Assert.assertEquals(6, factory.getLevel()); + Assert.assertEquals(1024, factory.getBufferSize()); + + OutputFilter filter = factory.createFilter(); + Assert.assertNotNull(filter); + Assert.assertTrue(filter instanceof GzipOutputFilter); + } + + @Test + public void testCompressionOff() { + CompressionConfig config = new CompressionConfig(); + // Default compression is "off" + + Request request = new Request(); + Response response = new Response(); + request.getMimeHeaders().addValue("accept-encoding").setString("gzip"); + + List factories = new ArrayList<>(); + factories.add(new GzipOutputFilterFactory()); + OutputFilterFactory result = config.useCompression(request, response, factories); + Assert.assertNull(result); + } + + @Test + public void testAlreadyCompressedContentEncoding() { + CompressionConfig config = new CompressionConfig(); + config.setCompression("force"); + + Request request = new Request(); + Response response = new Response(); + request.getMimeHeaders().addValue("accept-encoding").setString("gzip"); + // Response already has gzip Content-Encoding - should skip compression + response.getMimeHeaders().addValue("content-encoding").setString("gzip"); + + List factories = new ArrayList<>(); + factories.add(new GzipOutputFilterFactory()); + OutputFilterFactory result = config.useCompression(request, response, factories); + Assert.assertNull(result); + } + + @Test + public void testNegotiationViaAcceptEncoding() throws Exception { + CompressionConfig config = new CompressionConfig(); + config.setCompression("force"); + + // Factories: gzip then deflate (server priority) + List factories = new ArrayList<>(); + factories.add(new GzipOutputFilterFactory()); + OutputFilterFactory deflateFactory = new OutputFilterFactory() { + @Override + public OutputFilter createFilter() { + return new GzipOutputFilter(); + } + @Override + public String getEncodingName() { + return "deflate"; + } + }; + factories.add(deflateFactory); + + // Client prefers deflate over gzip + Request request = new Request(); + Response response = new Response(); + request.getMimeHeaders().addValue("accept-encoding").setString("deflate;q=1.0, gzip;q=0.5"); + + OutputFilterFactory result = config.useCompression(request, response, factories); + Assert.assertNotNull(result); + Assert.assertEquals("deflate", result.getEncodingName()); + Assert.assertEquals("deflate", response.getMimeHeaders().getHeader("Content-Encoding")); + Assert.assertNull(response.getMimeHeaders().getHeader("Transfer-Encoding")); + } + + @Test + public void testNegotiationViaTE() throws Exception { + CompressionConfig config = new CompressionConfig(); + config.setCompression("force"); + + // Factories: gzip then deflate (server priority) + List factories = new ArrayList<>(); + factories.add(new GzipOutputFilterFactory()); + OutputFilterFactory deflateFactory = new OutputFilterFactory() { + @Override + public OutputFilter createFilter() { + return new GzipOutputFilter(); + } + @Override + public String getEncodingName() { + return "deflate"; + } + }; + factories.add(deflateFactory); + + // TE header should use Transfer-Encoding, not Content-Encoding + Request request = new Request(); + Response response = new Response(); + request.getMimeHeaders().addValue("TE").setString("deflate;q=1.0, gzip;q=0.5"); + + OutputFilterFactory result = config.useCompression(request, response, factories); + Assert.assertNotNull(result); + Assert.assertEquals("deflate", result.getEncodingName()); + Assert.assertEquals("deflate", response.getMimeHeaders().getHeader("Transfer-Encoding")); + Assert.assertNull(response.getMimeHeaders().getHeader("Content-Encoding")); + } + + } diff --git a/test/org/apache/coyote/TestEncodingNegotiator.java b/test/org/apache/coyote/TestEncodingNegotiator.java new file mode 100644 index 000000000000..1ec26374e97d --- /dev/null +++ b/test/org/apache/coyote/TestEncodingNegotiator.java @@ -0,0 +1,140 @@ +/* + * 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.StringReader; +import java.util.ArrayList; +import java.util.List; + +import org.apache.coyote.http11.OutputFilter; +import org.apache.coyote.http11.filters.GzipOutputFilter; +import org.apache.coyote.http11.filters.GzipOutputFilterFactory; +import org.apache.coyote.http11.filters.OutputFilterFactory; +import org.apache.tomcat.util.http.parser.AcceptEncoding; +import org.apache.tomcat.util.http.parser.TE; +import org.junit.Assert; +import org.junit.Test; + +public class TestEncodingNegotiator { + + private static OutputFilterFactory deflateFactory() { + return new OutputFilterFactory() { + @Override + public OutputFilter createFilter() { + return new GzipOutputFilter(); + } + + @Override + public String getEncodingName() { + return "deflate"; + } + }; + } + + private static OutputFilterFactory brFactory() { + return new OutputFilterFactory() { + @Override + public OutputFilter createFilter() { + return new GzipOutputFilter(); + } + + @Override + public String getEncodingName() { + return "br"; + } + }; + } + + @Test + public void testAcceptEncodingPrefersHigherQ() throws Exception { + List factories = new ArrayList<>(); + factories.add(new GzipOutputFilterFactory()); // server priority 0 + factories.add(deflateFactory()); // server priority 1 + + List aes = AcceptEncoding.parse(new StringReader("gzip;q=0.5, deflate;q=1.0")); + + OutputFilterFactory selected = EncodingNegotiator.negotiateAcceptEncoding(factories, aes); + Assert.assertNotNull(selected); + Assert.assertEquals("deflate", selected.getEncodingName()); + } + + @Test + public void testAcceptEncodingWildcardServerPriority() throws Exception { + List factories = new ArrayList<>(); + factories.add(brFactory()); + factories.add(new GzipOutputFilterFactory()); + + List aes = AcceptEncoding.parse(new StringReader("*")); + + OutputFilterFactory selected = EncodingNegotiator.negotiateAcceptEncoding(factories, aes); + Assert.assertNotNull(selected); + Assert.assertEquals("br", selected.getEncodingName()); + } + + @Test + public void testAcceptEncodingTieServerPriority() throws Exception { + List factories = new ArrayList<>(); + factories.add(new GzipOutputFilterFactory()); // index 0 + factories.add(deflateFactory()); // index 1 + + List aes = AcceptEncoding.parse(new StringReader("gzip, deflate")); + + OutputFilterFactory selected = EncodingNegotiator.negotiateAcceptEncoding(factories, aes); + Assert.assertNotNull(selected); + Assert.assertEquals("gzip", selected.getEncodingName()); + } + + @Test + public void testAcceptEncodingQZeroExcludes() throws Exception { + List factories = new ArrayList<>(); + factories.add(new GzipOutputFilterFactory()); + factories.add(deflateFactory()); + + List aes = AcceptEncoding.parse(new StringReader("gzip;q=0, deflate;q=1")); + + OutputFilterFactory selected = EncodingNegotiator.negotiateAcceptEncoding(factories, aes); + Assert.assertNotNull(selected); + Assert.assertEquals("deflate", selected.getEncodingName()); + } + + @Test + public void testTENegotiationPrefersHigherQ() throws Exception { + List factories = new ArrayList<>(); + factories.add(new GzipOutputFilterFactory()); + factories.add(deflateFactory()); + + List tes = TE.parse(new StringReader("deflate;q=1.0, gzip;q=0.5")); + + OutputFilterFactory selected = EncodingNegotiator.negotiateTE(factories, tes); + Assert.assertNotNull(selected); + Assert.assertEquals("deflate", selected.getEncodingName()); + } + + @Test + public void testTENegotiationWildcardServerPriority() throws Exception { + List factories = new ArrayList<>(); + factories.add(brFactory()); + factories.add(new GzipOutputFilterFactory()); + + List tes = TE.parse(new StringReader("*")); + + OutputFilterFactory selected = EncodingNegotiator.negotiateTE(factories, tes); + Assert.assertNotNull(selected); + Assert.assertEquals("br", selected.getEncodingName()); + } +} + diff --git a/test/org/apache/coyote/http11/TestHttp11Processor.java b/test/org/apache/coyote/http11/TestHttp11Processor.java index fd77108f0da0..72c8f3ec36bd 100644 --- a/test/org/apache/coyote/http11/TestHttp11Processor.java +++ b/test/org/apache/coyote/http11/TestHttp11Processor.java @@ -37,6 +37,7 @@ import java.util.List; import java.util.Map; import java.util.concurrent.CountDownLatch; +import java.util.zip.Deflater; import jakarta.servlet.AsyncContext; import jakarta.servlet.DispatcherType; @@ -48,6 +49,8 @@ import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; +import org.apache.coyote.http11.filters.GzipOutputFilterFactory; +import org.apache.coyote.http11.filters.OutputFilterFactory; import org.junit.Assert; import org.junit.Test; @@ -2149,6 +2152,44 @@ public void testEarlyHintsSendErrorWithMessage() throws Exception { Assert.assertEquals(HttpServletResponse.SC_OK, client.getStatusCode()); } + @Test + public void testDefaultAutoRegistration() { + Http11NioProtocol protocol = new Http11NioProtocol(); + + // No factory configured - getOutputFilterFactories should auto-register GzipOutputFilterFactory + List factories = protocol.getOutputFilterFactories(); + Assert.assertFalse(factories.isEmpty()); + Assert.assertEquals(1, factories.size()); + Assert.assertTrue(factories.get(0) instanceof GzipOutputFilterFactory); + } + + @Test + public void testAddOutputFilterFactory() { + Http11NioProtocol protocol = new Http11NioProtocol(); + + // Explicitly add a factory - should suppress default auto-registration + MockOutputFilterFactory mock = new MockOutputFilterFactory(); + protocol.addOutputFilterFactory(mock); + + List factories = protocol.getOutputFilterFactories(); + Assert.assertEquals(1, factories.size()); + Assert.assertSame(mock, factories.get(0)); + } + + @Test + public void testAddMultipleOutputFilterFactories() { + Http11NioProtocol protocol = new Http11NioProtocol(); + + GzipOutputFilterFactory gzip = new GzipOutputFilterFactory(); + MockOutputFilterFactory mock = new MockOutputFilterFactory(); + protocol.addOutputFilterFactory(gzip); + protocol.addOutputFilterFactory(mock); + + List factories = protocol.getOutputFilterFactories(); + Assert.assertEquals(2, factories.size()); + Assert.assertSame(gzip, factories.get(0)); + Assert.assertSame(mock, factories.get(1)); + } private static class EarlyHintsServlet extends HttpServlet { @@ -2201,4 +2242,17 @@ public void testNoCompressionEncodings() { Assert.assertTrue(newEncodings.contains("br")); Assert.assertFalse(newEncodings.contains("gzip")); } + + public static class MockOutputFilterFactory implements OutputFilterFactory { + + @Override + public OutputFilter createFilter() { + return null; + } + + @Override + public String getEncodingName() { + return "mock"; + } + } } \ No newline at end of file diff --git a/test/org/apache/coyote/http11/filters/TestGzipOutputFilter.java b/test/org/apache/coyote/http11/filters/TestGzipOutputFilter.java index 7873b0e54773..e24951db242a 100644 --- a/test/org/apache/coyote/http11/filters/TestGzipOutputFilter.java +++ b/test/org/apache/coyote/http11/filters/TestGzipOutputFilter.java @@ -81,4 +81,19 @@ public void testFlushingWithGzip() throws Exception { // most of the data should have been flushed out Assert.assertTrue(dataFound.length >= (dataExpected.length - 20)); } + + @Test(expected = IllegalArgumentException.class) + public void testInvalidLevelLow() { + new GzipOutputFilter().setLevel(-2); + } + + @Test(expected = IllegalArgumentException.class) + public void testInvalidLevelHigh() { + new GzipOutputFilter().setLevel(10); + } + + @Test(expected = IllegalArgumentException.class) + public void testInvalidGzipBufferSize() { + new GzipOutputFilter().setBufferSize(0); + } } diff --git a/webapps/docs/config/http.xml b/webapps/docs/config/http.xml index 36f9b1c3cbfb..71313b710931 100644 --- a/webapps/docs/config/http.xml +++ b/webapps/docs/config/http.xml @@ -423,7 +423,8 @@ compression may be used. The default value is - text/html,text/xml,text/plain,text/css,text/javascript,application/javascript,application/json,application/xml + text/html,text/xml,text/plain,text/css, + text/javascript,application/javascript,application/json,application/xml . If you specify a type explicitly, the default is over-ridden.

@@ -459,6 +460,34 @@ Units are in bytes.

+

Advanced gzip settings such as compression level and internal buffer + size are configured via a nested + <OutputFilterFactory> element inside the + <Connector> + when compression is enabled.

+
+      <Connector ... compression="on">
+        <OutputFilterFactory
+            className="org.apache.coyote.http11.filters.GzipOutputFilterFactory"
+            level="-1"
+            bufferSize="512"/>
+      </Connector>
+    
+ + +

A comma-separated list of content encodings that indicate + already-compressed content. When the response already has a + Content-Encoding header with one of these values, compression + will not be applied to prevent double compression. This attribute is only + used if compression is set to on or + force.

+

If not specified, the default values is + br,compress,dcb,dcz,deflate,gzip,pack200-gzip,zstd, which + includes all commonly used compression algorithms. This can be customized + to support custom compression algorithms when using a custom + outputFilterFactory.

+
+

The number of seconds during which the sockets used by this Connector will linger when they are closed. The default @@ -651,7 +680,7 @@ used.

- +

A comma-separated list of content encodings that indicate already-compressed content. When the response already has a Content-Encoding header with one of these values, compression @@ -659,7 +688,7 @@ used if compression is set to on or force.

If not specified, the default values is - br,compress,dcb,dcz,deflate,gzip,pack2000-gzip,zstd, which + br,compress,dcb,dcz,deflate,gzip,pack200-gzip,zstd, which includes all commonly used compression algorithms.