Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions java/org/apache/catalina/connector/Connector.java
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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();
}
Expand Down
7 changes: 7 additions & 0 deletions java/org/apache/catalina/startup/Catalina.java
Original file line number Diff line number Diff line change
Expand Up @@ -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/"));
Expand Down
101 changes: 49 additions & 52 deletions java/org/apache/coyote/CompressionConfig.java
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -62,15 +57,15 @@ 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<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()) {
if (!token.isEmpty()) {
newEncodings.add(token);
}
}
Expand Down Expand Up @@ -192,7 +187,6 @@ public int getCompressionMinSize() {
return compressionMinSize;
}


/**
* Set Minimum size to trigger compression.
*
Expand All @@ -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<OutputFilterFactory> factories) {
if (factories == null || factories.isEmpty()) {
return null;
}

// Check if compression is enabled
if (compressionLevel == 0) {
return false;
return null;
}

boolean useTransferEncoding = false;
Expand All @@ -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;
}
}

Expand All @@ -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<String> 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<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;
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<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;
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
Expand All @@ -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
*
Expand Down
100 changes: 100 additions & 0 deletions java/org/apache/coyote/EncodingNegotiator.java
Original file line number Diff line number Diff line change
@@ -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.
* <p>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<OutputFilterFactory> factories, List<TE> entries) {
return negotiateEncodings(factories, entries, TE::getEncoding, TE::getQuality);
}

/**
* Selects the best {@link OutputFilterFactory} for the HTTP {@code Accept-Encoding} header.
* <p>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<OutputFilterFactory> factories, List<AcceptEncoding> acceptEncodings) {
return negotiateEncodings(factories, acceptEncodings, AcceptEncoding::getEncoding, AcceptEncoding::getQuality);
}

private static <T> OutputFilterFactory negotiateEncodings(
List<OutputFilterFactory> factories,
List<T> entries,
Function<T,String> encodingFn,
ToDoubleFunction<T> 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;
}
}
Loading