Index: test/org/apache/catalina/filters/TestExpiresFilter.java =================================================================== --- test/org/apache/catalina/filters/TestExpiresFilter.java (revision 0) +++ test/org/apache/catalina/filters/TestExpiresFilter.java (revision 0) @@ -0,0 +1,438 @@ +/* + * 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.catalina.filters; + +import java.io.IOException; +import java.net.HttpURLConnection; +import java.net.URL; +import java.util.Calendar; +import java.util.List; +import java.util.StringTokenizer; +import java.util.TimeZone; +import java.util.Map.Entry; + +import javax.servlet.ServletException; +import javax.servlet.http.HttpServlet; +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; + +import junit.framework.Assert; + +import org.apache.catalina.Context; +import org.apache.catalina.deploy.FilterDef; +import org.apache.catalina.deploy.FilterMap; +import org.apache.catalina.filters.ExpiresFilter.Duration; +import org.apache.catalina.filters.ExpiresFilter.DurationUnit; +import org.apache.catalina.filters.ExpiresFilter.ExpiresConfiguration; +import org.apache.catalina.filters.ExpiresFilter.StartingPoint; +import org.apache.catalina.startup.Tomcat; +import org.apache.catalina.startup.TomcatBaseTest; + +public class TestExpiresFilter extends TomcatBaseTest { + public static final String TEMP_DIR = System.getProperty("java.io.tmpdir"); + + public void testConfiguration() throws ServletException, Exception { + + Tomcat tomcat = getTomcatInstance(); + Context root = tomcat.addContext("", TEMP_DIR); + + FilterDef filterDef = new FilterDef(); + filterDef.addInitParameter("ExpiresDefault", "access plus 1 month"); + filterDef.addInitParameter("ExpiresByType text/html", "access plus 1 month 15 days 2 hours"); + filterDef.addInitParameter("ExpiresByType image/gif", "modification plus 5 hours 3 minutes"); + filterDef.addInitParameter("ExpiresByType image/jpg", "A10000"); + filterDef.addInitParameter("ExpiresByType video/mpeg", "M20000"); + filterDef.addInitParameter("ExpiresActive", "Off"); + filterDef.addInitParameter("ExpiresExcludedResponseStatusCodes", "304, 503"); + + ExpiresFilter expiresFilter = new ExpiresFilter(); + + filterDef.setFilter(expiresFilter); + filterDef.setFilterClass(ExpiresFilter.class.getName()); + filterDef.setFilterName(ExpiresFilter.class.getName()); + + root.addFilterDef(filterDef); + + FilterMap filterMap = new FilterMap(); + filterMap.setFilterName(ExpiresFilter.class.getName()); + filterMap.addURLPattern("*"); + + tomcat.start(); + try { + Assert.assertEquals(false, expiresFilter.isActive()); + + // VERIFY EXCLUDED RESPONSE STATUS CODES + { + int[] excludedResponseStatusCodes = expiresFilter.getExcludedResponseStatusCodesAsInts(); + Assert.assertEquals(2, excludedResponseStatusCodes.length); + Assert.assertEquals(304, excludedResponseStatusCodes[0]); + Assert.assertEquals(503, excludedResponseStatusCodes[1]); + } + + // VERIFY DEFAULT CONFIGURATION + { + ExpiresConfiguration expiresConfiguration = expiresFilter.getDefaultExpiresConfiguration(); + Assert.assertEquals(StartingPoint.ACCESS_TIME, expiresConfiguration.getStartingPoint()); + Assert.assertEquals(1, expiresConfiguration.getDurations().size()); + Assert.assertEquals(DurationUnit.MONTH, expiresConfiguration.getDurations().get(0).getUnit()); + Assert.assertEquals(1, expiresConfiguration.getDurations().get(0).getAmount()); + } + + // VERIFY TEXT/HTML + { + ExpiresConfiguration expiresConfiguration = expiresFilter.getExpiresConfigurationByContentType().get("text/html"); + Assert.assertEquals(StartingPoint.ACCESS_TIME, expiresConfiguration.getStartingPoint()); + + Assert.assertEquals(3, expiresConfiguration.getDurations().size()); + + Duration oneMonth = expiresConfiguration.getDurations().get(0); + Assert.assertEquals(DurationUnit.MONTH, oneMonth.getUnit()); + Assert.assertEquals(1, oneMonth.getAmount()); + + Duration fifteenDays = expiresConfiguration.getDurations().get(1); + Assert.assertEquals(DurationUnit.DAY, fifteenDays.getUnit()); + Assert.assertEquals(15, fifteenDays.getAmount()); + + Duration twoHours = expiresConfiguration.getDurations().get(2); + Assert.assertEquals(DurationUnit.HOUR, twoHours.getUnit()); + Assert.assertEquals(2, twoHours.getAmount()); + } + // VERIFY IMAGE/GIF + { + ExpiresConfiguration expiresConfiguration = expiresFilter.getExpiresConfigurationByContentType().get("image/gif"); + Assert.assertEquals(StartingPoint.LAST_MODIFICATION_TIME, expiresConfiguration.getStartingPoint()); + + Assert.assertEquals(2, expiresConfiguration.getDurations().size()); + + Duration fiveHours = expiresConfiguration.getDurations().get(0); + Assert.assertEquals(DurationUnit.HOUR, fiveHours.getUnit()); + Assert.assertEquals(5, fiveHours.getAmount()); + + Duration threeMinutes = expiresConfiguration.getDurations().get(1); + Assert.assertEquals(DurationUnit.MINUTE, threeMinutes.getUnit()); + Assert.assertEquals(3, threeMinutes.getAmount()); + + } + // VERIFY IMAGE/JPG + { + ExpiresConfiguration expiresConfiguration = expiresFilter.getExpiresConfigurationByContentType().get("image/jpg"); + Assert.assertEquals(StartingPoint.ACCESS_TIME, expiresConfiguration.getStartingPoint()); + + Assert.assertEquals(1, expiresConfiguration.getDurations().size()); + + Duration tenThousandSeconds = expiresConfiguration.getDurations().get(0); + Assert.assertEquals(DurationUnit.SECOND, tenThousandSeconds.getUnit()); + Assert.assertEquals(10000, tenThousandSeconds.getAmount()); + + } + // VERIFY VIDEO/MPEG + { + ExpiresConfiguration expiresConfiguration = expiresFilter.getExpiresConfigurationByContentType().get("video/mpeg"); + Assert.assertEquals(StartingPoint.LAST_MODIFICATION_TIME, expiresConfiguration.getStartingPoint()); + + Assert.assertEquals(1, expiresConfiguration.getDurations().size()); + + Duration twentyThousandSeconds = expiresConfiguration.getDurations().get(0); + Assert.assertEquals(DurationUnit.SECOND, twentyThousandSeconds.getUnit()); + Assert.assertEquals(20000, twentyThousandSeconds.getAmount()); + } + } finally { + tomcat.stop(); + } + } + + /** + * Test that a resource with empty content is also processed + */ + + public void testEmptyContent() throws Exception { + HttpServlet servlet = new HttpServlet() { + private static final long serialVersionUID = 1L; + + @Override + protected void service(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException { + response.setContentType("text/plain"); + // no content is written in the response + } + }; + + int expectedMaxAgeInSeconds = 7 * 60; + + validate(servlet, expectedMaxAgeInSeconds); + } + + public void testParseExpiresConfigurationCombinedDuration() { + ExpiresFilter expiresFilter = new ExpiresFilter(); + ExpiresConfiguration actualConfiguration = expiresFilter.parseExpiresConfiguration("access plus 1 month 15 days 2 hours"); + + Assert.assertEquals(StartingPoint.ACCESS_TIME, actualConfiguration.getStartingPoint()); + + Assert.assertEquals(3, actualConfiguration.getDurations().size()); + + } + + public void testParseExpiresConfigurationMonoDuration() { + ExpiresFilter expiresFilter = new ExpiresFilter(); + ExpiresConfiguration actualConfiguration = expiresFilter.parseExpiresConfiguration("access plus 2 hours"); + + Assert.assertEquals(StartingPoint.ACCESS_TIME, actualConfiguration.getStartingPoint()); + + Assert.assertEquals(1, actualConfiguration.getDurations().size()); + Assert.assertEquals(2, actualConfiguration.getDurations().get(0).getAmount()); + Assert.assertEquals(DurationUnit.HOUR, actualConfiguration.getDurations().get(0).getUnit()); + + } + + public void testSkipBecauseCacheControlMaxAgeIsDefined() throws Exception { + HttpServlet servlet = new HttpServlet() { + private static final long serialVersionUID = 1L; + + @Override + protected void service(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException { + response.setContentType("text/xml; charset=utf-8"); + response.addHeader("Cache-Control", "private, max-age=232"); + response.getWriter().print("Hello world"); + } + }; + + int expectedMaxAgeInSeconds = 232; + validate(servlet, expectedMaxAgeInSeconds); + } + + public void testExcludedResponseStatusCode() throws Exception { + HttpServlet servlet = new HttpServlet() { + private static final long serialVersionUID = 1L; + + @Override + protected void service(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException { + response.setStatus(HttpServletResponse.SC_NOT_MODIFIED); + response.addHeader("ETag", "W/\"1934-1269208821000\""); + response.addDateHeader("Date", System.currentTimeMillis()); + } + }; + + validate(servlet, null, HttpServletResponse.SC_NOT_MODIFIED); + } + + public void testNullContentType() throws Exception { + HttpServlet servlet = new HttpServlet() { + private static final long serialVersionUID = 1L; + + @Override + protected void service(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException { + response.setContentType(null); + } + }; + + int expectedMaxAgeInSeconds = 1 * 60; + validate(servlet, expectedMaxAgeInSeconds); + } + + public void testSkipBecauseExpiresIsDefined() throws Exception { + HttpServlet servlet = new HttpServlet() { + private static final long serialVersionUID = 1L; + + @Override + protected void service(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException { + response.setContentType("text/xml; charset=utf-8"); + response.addDateHeader("Expires", System.currentTimeMillis()); + response.getWriter().print("Hello world"); + } + }; + + validate(servlet, null); + } + + public void testUseContentTypeExpiresConfiguration() throws Exception { + HttpServlet servlet = new HttpServlet() { + private static final long serialVersionUID = 1L; + + @Override + protected void service(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException { + response.setContentType("text/xml; charset=utf-8"); + response.getWriter().print("Hello world"); + } + }; + + int expectedMaxAgeInSeconds = 3 * 60; + + validate(servlet, expectedMaxAgeInSeconds); + } + + public void testUseContentTypeWithoutCharsetExpiresConfiguration() throws Exception { + HttpServlet servlet = new HttpServlet() { + private static final long serialVersionUID = 1L; + + @Override + protected void service(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException { + response.setContentType("text/xml; charset=iso-8859-1"); + response.getWriter().print("Hello world"); + } + }; + + int expectedMaxAgeInSeconds = 5 * 60; + + validate(servlet, expectedMaxAgeInSeconds); + } + + public void testUseDefaultConfiguration1() throws Exception { + HttpServlet servlet = new HttpServlet() { + private static final long serialVersionUID = 1L; + + @Override + protected void service(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException { + response.setContentType("image/jpeg"); + response.getWriter().print("Hello world"); + } + }; + + int expectedMaxAgeInSeconds = 1 * 60; + + validate(servlet, expectedMaxAgeInSeconds); + } + + public void testUseDefaultConfiguration2() throws Exception { + HttpServlet servlet = new HttpServlet() { + private static final long serialVersionUID = 1L; + + @Override + protected void service(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException { + response.setContentType("image/jpeg"); + response.addHeader("Cache-Control", "private"); + + response.getWriter().print("Hello world"); + } + }; + + int expectedMaxAgeInSeconds = 1 * 60; + + validate(servlet, expectedMaxAgeInSeconds); + } + + public void testUseMajorTypeExpiresConfiguration() throws Exception { + HttpServlet servlet = new HttpServlet() { + private static final long serialVersionUID = 1L; + + @Override + protected void service(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException { + response.setContentType("text/json; charset=iso-8859-1"); + response.getWriter().print("Hello world"); + } + }; + + int expectedMaxAgeInSeconds = 7 * 60; + + validate(servlet, expectedMaxAgeInSeconds); + } + + protected void validate(HttpServlet servlet, Integer expectedMaxAgeInSeconds) throws Exception { + validate(servlet, expectedMaxAgeInSeconds, HttpURLConnection.HTTP_OK); + } + + protected void validate(HttpServlet servlet, Integer expectedMaxAgeInSeconds, int expectedResponseStatusCode) throws Exception { + // SETUP + + Tomcat tomcat = getTomcatInstance(); + Context root = tomcat.addContext("", TEMP_DIR); + + FilterDef filterDef = new FilterDef(); + filterDef.addInitParameter("ExpiresDefault", "access plus 1 minute"); + filterDef.addInitParameter("ExpiresByType text/xml;charset=utf-8", "access plus 3 minutes"); + filterDef.addInitParameter("ExpiresByType text/xml", "access plus 5 minutes"); + filterDef.addInitParameter("ExpiresByType text", "access plus 7 minutes"); + filterDef.addInitParameter("ExpiresExcludedResponseStatusCodes", "304, 503"); + + filterDef.setFilterClass(ExpiresFilter.class.getName()); + filterDef.setFilterName(ExpiresFilter.class.getName()); + + root.addFilterDef(filterDef); + + FilterMap filterMap = new FilterMap(); + filterMap.setFilterName(ExpiresFilter.class.getName()); + filterMap.addURLPattern("*"); + root.addFilterMap(filterMap); + + Tomcat.addServlet(root, servlet.getClass().getName(), servlet); + root.addServletMapping("/test", servlet.getClass().getName()); + + tomcat.start(); + + try { + Calendar.getInstance(TimeZone.getTimeZone("GMT")); + long timeBeforeInMillis = System.currentTimeMillis(); + + // TEST + HttpURLConnection httpURLConnection = (HttpURLConnection) new URL("http://localhost:" + tomcat.getConnector().getPort() + + "/test").openConnection(); + + // VALIDATE + Assert.assertEquals(expectedResponseStatusCode, httpURLConnection.getResponseCode()); + + StringBuilder msg = new StringBuilder(); + for (Entry> field : httpURLConnection.getHeaderFields().entrySet()) { + for (String value : field.getValue()) { + msg.append((field.getKey() == null ? "" : field.getKey() + ": ") + value + "\n"); + } + } + System.out.println(msg); + + Integer actualMaxAgeInSeconds; + + String cacheControlHeader = httpURLConnection.getHeaderField("Cache-Control"); + if (cacheControlHeader == null) { + actualMaxAgeInSeconds = null; + } else { + actualMaxAgeInSeconds = null; + StringTokenizer cacheControlTokenizer = new StringTokenizer(cacheControlHeader, ","); + while (cacheControlTokenizer.hasMoreTokens() && actualMaxAgeInSeconds == null) { + String cacheDirective = cacheControlTokenizer.nextToken(); + StringTokenizer cacheDirectiveTokenizer = new StringTokenizer(cacheDirective, "="); + if (cacheDirectiveTokenizer.countTokens() == 2) { + String key = cacheDirectiveTokenizer.nextToken().trim(); + String value = cacheDirectiveTokenizer.nextToken().trim(); + if (key.equalsIgnoreCase("max-age")) { + actualMaxAgeInSeconds = Integer.parseInt(value); + } + } + } + } + + if (expectedMaxAgeInSeconds == null) { + Assert.assertNull("actualMaxAgeInSeconds '" + actualMaxAgeInSeconds + "' should be null", actualMaxAgeInSeconds); + return; + } + + Assert.assertNotNull(actualMaxAgeInSeconds); + + int deltaInSeconds = Math.abs(actualMaxAgeInSeconds - expectedMaxAgeInSeconds); + Assert.assertTrue("actualMaxAgeInSeconds: " + actualMaxAgeInSeconds + ", expectedMaxAgeInSeconds: " + expectedMaxAgeInSeconds + + ", request time: " + timeBeforeInMillis + " for content type " + httpURLConnection.getContentType(), + deltaInSeconds < 3); + + } finally { + tomcat.stop(); + } + } + + public void testIntsToCommaDelimitedString() { + String actual = ExpiresFilter.intsToCommaDelimitedString(new int[] { 500, 503 }); + String expected = "500, 503"; + + Assert.assertEquals(expected, actual); + } +} Index: java/org/apache/catalina/filters/LocalStrings.properties =================================================================== --- java/org/apache/catalina/filters/LocalStrings.properties (revision 928504) +++ java/org/apache/catalina/filters/LocalStrings.properties (working copy) @@ -16,3 +16,20 @@ filterbase.noSuchProperty=The property "{0}" is not defined for filters of type "{1}" http.403=Access to the specified resource ({0}) has been forbidden. + +expiresFilter.noExpirationConfigured=Request "{0}" with response status "{1}" content-type "{2}", no expiration configured +expiresFilter.setExpirationDate=Request "{0}" with response status "{1}" content-type "{2}", set expiration date {3} +expiresFilter.startingPointNotFound=Starting point (access|now|modification|a|m) not found in directive "{0}" +expiresFilter.startingPointInvalid=Invalid starting point (access|now|modification|a|m) "{0}" in directive "{1}" +expiresFilter.responseAlreadyCommited=Request "{0}", can not apply ExpiresFilter on already committed response. +expiresFilter.filterNotActive=Request "{0}", ExpiresFilter is NOT active +expiresFilter.noExpirationConfiguredForContentType=No Expires configuration found for content-type "{0}" +expiresFilter.useMatchingConfiguration=Use {0} matching "{1}" for content-type "{2}" returns {3} +expiresFilter.useDefaultConfiguration=Use default {0} for content-type "{1}" returns {2} +expiresFilter.unsupportedStartingPoint=Unsupported startingPoint "{0}" +expiresFilter.unknownParameterIgnored=Unknown parameter "{0}" with value "{1}" is ignored ! +expiresFilter.exceptionProcessingParameter=Exception processing configuration parameter "{0}":"{1}" +expiresFilter.filterInitialized=Filter initialized with configuration {0} +expiresFilter.expirationHeaderAlreadyDefined=Request "{0}" with response status "{1}" content-type "{2}", expiration header already defined +expiresFilter.skippedStatusCode=Request "{0}" with response status "{1}" content-type "{1}", skip expiration header generation for given status + Index: java/org/apache/catalina/filters/ExpiresFilter.java =================================================================== --- java/org/apache/catalina/filters/ExpiresFilter.java (revision 0) +++ java/org/apache/catalina/filters/ExpiresFilter.java (revision 0) @@ -0,0 +1,1555 @@ +/* + * Copyright 2008-2009 the original author or authors. + * + * Licensed 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.catalina.filters; + +import java.io.IOException; +import java.io.PrintWriter; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Calendar; +import java.util.Date; +import java.util.Enumeration; +import java.util.GregorianCalendar; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.NoSuchElementException; +import java.util.StringTokenizer; +import java.util.regex.Pattern; + +import javax.servlet.Filter; +import javax.servlet.FilterChain; +import javax.servlet.FilterConfig; +import javax.servlet.ServletException; +import javax.servlet.ServletOutputStream; +import javax.servlet.ServletRequest; +import javax.servlet.ServletResponse; +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; +import javax.servlet.http.HttpServletResponseWrapper; + +import org.apache.juli.logging.Log; +import org.apache.juli.logging.LogFactory; + +/** + *

+ * ExpiresFilter is a Java Servlet API port of Apache + * mod_expires to add ' Expires' and ' + * Cache-Control: max-age=' headers to HTTP response according to its ' + * Content-Type'. + *

+ * + *

+ * Following documentation is inspired by mod_expires . + *

+ *

Summary

+ *

+ * This filter controls the setting of the Expires HTTP header and the + * max-age directive of the Cache-Control HTTP header in + * server responses. The expiration date can set to be relative to either the + * time the source file was last modified, or to the time of the client access. + *

+ *

+ * These HTTP headers are an instruction to the client about the document's + * validity and persistence. If cached, the document may be fetched from the + * cache rather than from the source until this time has passed. After that, the + * cache copy is considered "expired" and invalid, and a new copy must + * be obtained from the source. + *

+ *

+ * To modify Cache-Control directives other than max-age (see + * RFC + * 2616 section 14.9), you can use other servlet filters or Apache Httpd + * mod_headers module. + *

+ *

Filter Configuration

Basic configuration to add ' + * Expires' and ' Cache-Control: max-age=' + * headers to images, css and javascript

+ * + *
+ * <web-app ...>
+ *    ...
+ *    <filter>
+ *       <filter-name>ExpiresFilter</filter-name>
+ *       <filter-class>org.apache.catalina.filters.ExpiresFilter</filter-class>
+ *       <init-param>
+ *          <param-name>ExpiresByType image</param-name>
+ *          <param-value>access plus 10 minutes</param-value>
+ *       </init-param>
+ *       <init-param>
+ *          <param-name>ExpiresByType text/css</param-name>
+ *          <param-value>access plus 10 minutes</param-value>
+ *       </init-param>
+ *       <init-param>
+ *          <param-name>ExpiresByType text/javascript</param-name>
+ *          <param-value>access plus 10 minutes</param-value>
+ *       </init-param>
+ *    </filter>
+ *    ...
+ *    <filter-mapping>
+ *       <filter-name>ExpiresFilter</filter-name>
+ *       <url-pattern>/*</url-pattern>
+ *       <dispatcher>REQUEST</dispatcher>
+ *    </filter-mapping>
+ *    ...
+ * </web-app>
+ * 
+ * + *

Configuration Parameters

+ * ExpiresActive

+ *

+ * This directive enables or disables the generation of the Expires and + * Cache-Control headers by this ExpiresFilter. If set to + * Off, the headers will not be generated for any HTTP response. If set + * to On or true, the headers will be added to served HTTP + * responses according to the criteria defined by the + * ExpiresByType <content-type> and ExpiresDefault + * directives. Note that this directive does not guarantee that an + * Expires or Cache-Control header will be generated. If the + * criteria aren't met, no header will be sent, and the effect will be as + * though this directive wasn't even specified. + *

+ *

+ * This parameter is optional, default value is true. + *

+ *

+ * Enable filter + *

+ * + *
+ * <init-param>
+ *    <!-- supports case insensitive 'On' or 'true' -->
+ *    <param-name>ExpiresActive</param-name><param-value>On</param-value>
+ * </init-param>
+ * 
+ *

+ * Disable filter + *

+ * + *
+ * <init-param>
+ *    <!-- supports anything different from case insensitive 'On' and 'true' -->
+ *    <param-name>ExpiresActive</param-name><param-value>Off</param-value>
+ * </init-param>
+ * 
+ * + *

+ * ExpiresByType <content-type>

+ *

+ * This directive defines the value of the Expires header and the + * max-age directive of the Cache-Control header generated for + * documents of the specified type (e.g., text/html). The second + * argument sets the number of seconds that will be added to a base time to + * construct the expiration date. The Cache-Control: max-age is + * calculated by subtracting the request time from the expiration date and + * expressing the result in seconds. + *

+ *

+ * The base time is either the last modification time of the file, or the time + * of the client's access to the document. Which should be used is + * specified by the <code> field; M means that the + * file's last modification time should be used as the base time, and + * A means the client's access time should be used. The duration + * is expressed in seconds. A2592000 stands for + * access plus 30 days in alternate syntax. + *

+ *

+ * The difference in effect is subtle. If M (modification in + * alternate syntax) is used, all current copies of the document in all caches + * will expire at the same time, which can be good for something like a weekly + * notice that's always found at the same URL. If A ( + * access or now in alternate syntax) is used, the date of + * expiration is different for each client; this can be good for image files + * that don't change very often, particularly for a set of related + * documents that all refer to the same images (i.e., the images will be + * accessed repeatedly within a relatively short timespan). + *

+ *

+ * Example: + *

+ * + *
+ * <init-param>
+ *    <param-name>ExpiresByType text/html</param-name><param-value>access plus 1 month 15   days 2 hours</param-value>
+ * </init-param>
+ *  
+ * <init-param>
+ *    <!-- 2592000 seconds = 30 days -->
+ *    <param-name>ExpiresByType image/gif</param-name><param-value>A2592000</param-value>
+ * </init-param>
+ * 
+ *

+ * Note that this directive only has effect if ExpiresActive On has + * been specified. It overrides, for the specified MIME type only, any + * expiration date set by the ExpiresDefault directive. + *

+ *

+ * You can also specify the expiration time calculation using an alternate + * syntax, described earlier in this document. + *

+ *

+ * ExpiresExcludedResponseStatusCodes

+ *

+ * This directive defines the http response status codes for which the + * ExpiresFilter will not generate expiration headers. By default, the + * 304 status code ("Not modified") is skipped. The + * value is a comma separated list of http status codes. + *

+ *

+ * This directive is useful to ease usage of ExpiresDefault directive. + * Indeed, the behavior of 304 Not modified (which does specify a + * Content-Type header) combined with Expires and + * Cache-Control:max-age= headers can be unnecessarily tricky to + * understand. + *

+ *

+ * Configuration sample : + *

+ * + *
+ * <init-param>
+ *    <param-name>ExpiresExcludedResponseStatusCodes</param-name><param-value>302, 500, 503</param-value>
+ * </init-param>
+ * 
+ * + *

ExpiresDefault

+ *

+ * This directive sets the default algorithm for calculating the expiration time + * for all documents in the affected realm. It can be overridden on a + * type-by-type basis by the ExpiresByType directive. See the + * description of that directive for details about the syntax of the argument, + * and the "alternate syntax" description as well. + *

+ *

Alternate Syntax

+ *

+ * The ExpiresDefault and ExpiresByType directives can also be + * defined in a more readable syntax of the form: + *

+ * + *
+ * <init-param>
+ *    <param-name>ExpiresDefault</param-name><param-value><base> [plus] {<num>   <type>}*</param-value>
+ * </init-param>
+ *  
+ * <init-param>
+ *    <param-name>ExpiresByType type/encoding</param-name><param-value><base> [plus]   {<num> <type>}*</param-value>
+ * </init-param>
+ * 
+ *

+ * where <base> is one of: + *

    + *
  • access
  • + *
  • now (equivalent to 'access')
  • + *
  • modification
  • + *
+ *

+ *

+ * The plus keyword is optional. <num> should be an + * integer value (acceptable to Integer.parseInt()), and + * <type> is one of: + *

    + *
  • years
  • + *
  • months
  • + *
  • weeks
  • + *
  • days
  • + *
  • hours
  • + *
  • minutes
  • + *
  • seconds
  • + *
+ * For example, any of the following directives can be used to make documents + * expire 1 month after being accessed, by default: + *

+ * + *
+ * <init-param>
+ *    <param-name>ExpiresDefault</param-name><param-value>access plus 1 month</param-value>
+ * </init-param>
+ *  
+ * <init-param>
+ *    <param-name>ExpiresDefault</param-name><param-value>access plus 4 weeks</param-value>
+ * </init-param>
+ *  
+ * <init-param>
+ *    <param-name>ExpiresDefault</param-name><param-value>access plus 30 days</param-value>
+ * </init-param>
+ * 
+ *

+ * The expiry time can be fine-tuned by adding several ' + * <num> <type>' clauses: + *

+ * + *
+ * <init-param>
+ *    <param-name>ExpiresByType text/html</param-name><param-value>access plus 1 month 15   days 2 hours</param-value>
+ * </init-param>
+ *  
+ * <init-param>
+ *    <param-name>ExpiresByType image/gif</param-name><param-value>modification plus 5 hours 3   minutes</param-value>
+ * </init-param>
+ * 
+ *

+ * Note that if you use a modification date based setting, the Expires + * header will not be added to content that does not come from + * a file on disk. This is due to the fact that there is no modification time + * for such content. + *

+ *

Expiration headers generation eligibility

+ *

+ * A response is eligible to be enriched by ExpiresFilter if : + *

    + *
  1. no expiration header is defined (Expires header or the + * max-age directive of the Cache-Control header),
  2. + *
  3. the response status code is not excluded by the directive + * ExpiresExcludedResponseStatusCodes,
  4. + *
  5. The Content-Type of the response matches one of the types + * defined the in ExpiresByType directives or the + * ExpiresDefault directive is defined.
  6. + *
+ *

+ *

+ * Note : + *

    + *
  • If Cache-Control header contains other directives than + * max-age, they are concatenated with the max-age directive + * that is added by the ExpiresFilter.
  • + *
+ *

+ *

Expiration configuration selection

+ *

+ * The expiration configuration if elected according to the following algorithm: + *

    + *
  1. ExpiresByType matching the exact content-type returned by + * HttpServletResponse.getContentType() possibly including the charset + * (e.g. 'text/xml;charset=UTF-8'),
  2. + *
  3. ExpiresByType matching the content-type without the charset if + * HttpServletResponse.getContentType() contains a charset (e.g. ' + * text/xml;charset=UTF-8' -> 'text/xml'),
  4. + *
  5. ExpiresByType matching the major type (e.g. substring before + * '/') of HttpServletResponse.getContentType() + * (e.g. 'text/xml;charset=UTF-8' -> 'text + * '),
  6. + *
  7. ExpiresDefault
  8. + *
+ *

+ *

Implementation Details

When to write the expiration headers ?

+ *

+ * The ExpiresFilter traps the 'on before write response + * body' event to decide whether it should generate expiration headers or + * not. + *

+ *

+ * To trap the 'before write response body' event, the + * ExpiresFilter wraps the http servlet response's writer and + * outputStream to intercept calls to the methods write(), + * print(), close() and flush(). For empty response + * body (e.g. empty files), the write(), print(), + * close() and flush() methods are not called; to handle this + * case, the ExpiresFilter, at the end of its doFilter() + * method, manually triggers the onBeforeWriteResponseBody() method. + *

+ *

Configuration syntax

+ *

+ * The ExpiresFilter supports the same configuration syntax as Apache + * Httpd mod_expires. + *

+ *

+ * A challenge has been to choose the name of the <param-name> + * associated with ExpiresByType in the <filter> + * declaration. Indeed, Several ExpiresByType directives can be + * declared when web.xml syntax does not allow to declare several + * <init-param> with the same name. + *

+ *

+ * The workaround has been to declare the content type in the + * <param-name> rather than in the <param-value>. + *

+ *

Designed for extension : the open/close principle

+ *

+ * The ExpiresFilter has been designed for extension following the + * open/close principle. + *

+ *

+ * Key methods to override for extension are : + *

    + *
  • + * {@link #isEligibleToExpirationHeaderGeneration(HttpServletRequest, XHttpServletResponse)} + *
  • + *
  • + * {@link #getExpirationDate(HttpServletRequest, XHttpServletResponse)}
  • + *
+ *

+ *

Troubleshooting

+ *

+ * To troubleshoot, enable logging on the + * org.apache.catalina.filters.ExpiresFilter. + *

+ *

+ * Extract of logging.properties + *

+ * + *
+ * org.apache.catalina.filters.ExpiresFilter.level = FINE
+ * 
+ *

+ * Sample of initialization log message : + *

+ * + *
+ * Mar 26, 2010 2:01:41 PM org.apache.catalina.filters.ExpiresFilter init
+ * FINE: Filter initialized with configuration ExpiresFilter[
+ *    active=true, 
+ *    excludedResponseStatusCode=[304], 
+ *    default=null, 
+ *    byType={
+ *       image=ExpiresConfiguration[startingPoint=ACCESS_TIME, duration=[10 MINUTE]], 
+ *       text/css=ExpiresConfiguration[startingPoint=ACCESS_TIME, duration=[10 MINUTE]], 
+ *       text/javascript=ExpiresConfiguration[startingPoint=ACCESS_TIME, duration=[10 MINUTE]]}]
+ * 
+ *

+ * Sample of per-request log message where ExpiresFilter adds an + * expiration date + *

+ * + *
+ * Mar 26, 2010 2:09:47 PM org.apache.catalina.filters.ExpiresFilter onBeforeWriteResponseBody
+ * FINE: Request "/tomcat.gif" with response status "200" content-type "image/gif", set expiration date 3/26/10 2:19 PM
+ * 
+ *

+ * Sample of per-request log message where ExpiresFilter does not add + * an expiration date + *

+ * + *
+ * Mar 26, 2010 2:10:27 PM org.apache.catalina.filters.ExpiresFilter onBeforeWriteResponseBody
+ * FINE: Request "/docs/config/manager.html" with response status "200" content-type "text/html", no expiration configured
+ * 
+ * + */ +public class ExpiresFilter extends FilterBase implements Filter { + + /** + * Duration composed of an {@link #amount} and a {@link #unit} + */ + protected static class Duration { + + public static Duration minutes(int amount) { + return new Duration(amount, DurationUnit.MINUTE); + } + + public static Duration seconds(int amount) { + return new Duration(amount, DurationUnit.SECOND); + } + + final protected int amount; + + final protected DurationUnit unit; + + public Duration(int amount, DurationUnit unit) { + super(); + this.amount = amount; + this.unit = unit; + } + + public int getAmount() { + return amount; + } + + public DurationUnit getUnit() { + return unit; + } + + @Override + public String toString() { + return amount + " " + unit; + } + } + + /** + * Duration unit + */ + protected enum DurationUnit { + DAY(Calendar.DAY_OF_YEAR), HOUR(Calendar.HOUR), MINUTE(Calendar.MINUTE), MONTH(Calendar.MONTH), SECOND(Calendar.SECOND), WEEK( + Calendar.WEEK_OF_YEAR), YEAR(Calendar.YEAR); + private final int calendardField; + + private DurationUnit(int calendardField) { + this.calendardField = calendardField; + } + + public int getCalendardField() { + return calendardField; + } + + } + + /** + *

+ * Main piece of configuration of the filter. + *

+ *

+ * Can be expressed like 'access plus 1 month 15 days 2 hours'. + *

+ */ + protected static class ExpiresConfiguration { + /** + * List of duration elements. + */ + private List durations; + + /** + * Starting point of the elaspse to set in the response. + */ + private StartingPoint startingPoint; + + public ExpiresConfiguration(StartingPoint startingPoint, Duration... durations) { + this(startingPoint, Arrays.asList(durations)); + } + + public ExpiresConfiguration(StartingPoint startingPoint, List durations) { + super(); + this.startingPoint = startingPoint; + this.durations = durations; + } + + public List getDurations() { + return durations; + } + + public StartingPoint getStartingPoint() { + return startingPoint; + } + + @Override + public String toString() { + return "ExpiresConfiguration[startingPoint=" + startingPoint + ", duration=" + durations + "]"; + } + } + + /** + * Expiration configuration starting point. Either the time the + * html-page/servlet-response was served ({@link StartingPoint#ACCESS_TIME}) + * or the last time the html-page/servlet-response was modified ( + * {@link StartingPoint#LAST_MODIFICATION_TIME}). + */ + protected enum StartingPoint { + ACCESS_TIME, LAST_MODIFICATION_TIME + } + + /** + *

+ * Wrapping extension of the {@link HttpServletResponse} to yrap the + * "Start Write Response Body" event. + *

+ *

+ * For performance optimization : this extended response holds the + * {@link #lastModifiedHeader} and {@link #cacheControlHeader} values access + * to the slow {@link #getHeader(String)} and to spare the string + * to date to long conversion. + *

+ */ + public class XHttpServletResponse extends HttpServletResponseWrapper { + + /** + * Value of the Cache-Control/tt> http response header if it has + * been set. + */ + private String cacheControlHeader; + + /** + * Value of the Last-Modified http response header if it has + * been set. + */ + private long lastModifiedHeader; + + private boolean lastModifiedHeaderSet; + + private PrintWriter printWriter; + + private HttpServletRequest request; + + private ServletOutputStream servletOutputStream; + + /** + * Indicates whether calls to write methods (write(...), + * print(...), etc) of the response body have been called or + * not. + */ + private boolean writeResponseBodyStarted; + + public XHttpServletResponse(HttpServletRequest request, HttpServletResponse response) { + super(response); + this.request = request; + } + + @Override + public void addDateHeader(String name, long date) { + super.addDateHeader(name, date); + if (!lastModifiedHeaderSet) { + this.lastModifiedHeader = date; + this.lastModifiedHeaderSet = true; + } + } + + @Override + public void addHeader(String name, String value) { + super.addHeader(name, value); + if (HEADER_CACHE_CONTROL.equalsIgnoreCase(name) && cacheControlHeader == null) { + cacheControlHeader = value; + } + } + + public String getCacheControlHeader() { + return cacheControlHeader; + } + + public long getLastModifiedHeader() { + return lastModifiedHeader; + } + + @Override + public ServletOutputStream getOutputStream() throws IOException { + if (servletOutputStream == null) { + servletOutputStream = new XServletOutputStream(super.getOutputStream(), request, this); + } + return servletOutputStream; + } + + @Override + public PrintWriter getWriter() throws IOException { + if (printWriter == null) { + printWriter = new XPrintWriter(super.getWriter(), request, this); + } + return printWriter; + } + + public boolean isLastModifiedHeaderSet() { + return lastModifiedHeaderSet; + } + + public boolean isWriteResponseBodyStarted() { + return writeResponseBodyStarted; + } + + @Override + public void reset() { + super.reset(); + this.lastModifiedHeader = 0; + this.lastModifiedHeaderSet = false; + this.cacheControlHeader = null; + } + + @Override + public void setDateHeader(String name, long date) { + super.setDateHeader(name, date); + if (HEADER_LAST_MODIFIED.equalsIgnoreCase(name)) { + this.lastModifiedHeader = date; + this.lastModifiedHeaderSet = true; + } + } + + @Override + public void setHeader(String name, String value) { + super.setHeader(name, value); + if (HEADER_CACHE_CONTROL.equalsIgnoreCase(name)) { + this.cacheControlHeader = value; + } + } + + public void setWriteResponseBodyStarted(boolean writeResponseBodyStarted) { + this.writeResponseBodyStarted = writeResponseBodyStarted; + } + } + + /** + * Wrapping extension of {@link PrintWriter} to trap the + * "Start Write Response Body" event. + */ + public class XPrintWriter extends PrintWriter { + private PrintWriter out; + + private HttpServletRequest request; + + private XHttpServletResponse response; + + public XPrintWriter(PrintWriter out, HttpServletRequest request, XHttpServletResponse response) { + super(out); + this.out = out; + this.request = request; + this.response = response; + } + + public PrintWriter append(char c) { + fireBeforeWriteResponseBodyEvent(); + return out.append(c); + } + + public PrintWriter append(CharSequence csq) { + fireBeforeWriteResponseBodyEvent(); + return out.append(csq); + } + + public PrintWriter append(CharSequence csq, int start, int end) { + fireBeforeWriteResponseBodyEvent(); + return out.append(csq, start, end); + } + + public void close() { + fireBeforeWriteResponseBodyEvent(); + out.close(); + } + + private void fireBeforeWriteResponseBodyEvent() { + if (!this.response.isWriteResponseBodyStarted()) { + this.response.setWriteResponseBodyStarted(true); + onBeforeWriteResponseBody(request, response); + } + } + + public void flush() { + fireBeforeWriteResponseBodyEvent(); + out.flush(); + } + + public void print(boolean b) { + fireBeforeWriteResponseBodyEvent(); + out.print(b); + } + + public void print(char c) { + fireBeforeWriteResponseBodyEvent(); + out.print(c); + } + + public void print(char[] s) { + fireBeforeWriteResponseBodyEvent(); + out.print(s); + } + + public void print(double d) { + fireBeforeWriteResponseBodyEvent(); + out.print(d); + } + + public void print(float f) { + fireBeforeWriteResponseBodyEvent(); + out.print(f); + } + + public void print(int i) { + fireBeforeWriteResponseBodyEvent(); + out.print(i); + } + + public void print(long l) { + fireBeforeWriteResponseBodyEvent(); + out.print(l); + } + + public void print(Object obj) { + fireBeforeWriteResponseBodyEvent(); + out.print(obj); + } + + public void print(String s) { + fireBeforeWriteResponseBodyEvent(); + out.print(s); + } + + public PrintWriter printf(Locale l, String format, Object... args) { + fireBeforeWriteResponseBodyEvent(); + return out.printf(l, format, args); + } + + public PrintWriter printf(String format, Object... args) { + fireBeforeWriteResponseBodyEvent(); + return out.printf(format, args); + } + + public void println() { + fireBeforeWriteResponseBodyEvent(); + out.println(); + } + + public void println(boolean x) { + fireBeforeWriteResponseBodyEvent(); + out.println(x); + } + + public void println(char x) { + fireBeforeWriteResponseBodyEvent(); + out.println(x); + } + + public void println(char[] x) { + fireBeforeWriteResponseBodyEvent(); + out.println(x); + } + + public void println(double x) { + fireBeforeWriteResponseBodyEvent(); + out.println(x); + } + + public void println(float x) { + fireBeforeWriteResponseBodyEvent(); + out.println(x); + } + + public void println(int x) { + fireBeforeWriteResponseBodyEvent(); + out.println(x); + } + + public void println(long x) { + fireBeforeWriteResponseBodyEvent(); + out.println(x); + } + + public void println(Object x) { + fireBeforeWriteResponseBodyEvent(); + out.println(x); + } + + public void println(String x) { + fireBeforeWriteResponseBodyEvent(); + out.println(x); + } + + public void write(char[] buf) { + fireBeforeWriteResponseBodyEvent(); + out.write(buf); + } + + public void write(char[] buf, int off, int len) { + fireBeforeWriteResponseBodyEvent(); + out.write(buf, off, len); + } + + public void write(int c) { + fireBeforeWriteResponseBodyEvent(); + out.write(c); + } + + public void write(String s) { + fireBeforeWriteResponseBodyEvent(); + out.write(s); + } + + public void write(String s, int off, int len) { + fireBeforeWriteResponseBodyEvent(); + out.write(s, off, len); + } + + } + + /** + * Wrapping extension of {@link ServletOutputStream} to trap the + * "Start Write Response Body" event. + */ + public class XServletOutputStream extends ServletOutputStream { + + private HttpServletRequest request; + + private XHttpServletResponse response; + + private ServletOutputStream servletOutputStream; + + public XServletOutputStream(ServletOutputStream servletOutputStream, HttpServletRequest request, XHttpServletResponse response) { + super(); + this.servletOutputStream = servletOutputStream; + this.response = response; + this.request = request; + } + + public void close() throws IOException { + fireOnBeforeWriteResponseBodyEvent(); + servletOutputStream.close(); + } + + private void fireOnBeforeWriteResponseBodyEvent() { + if (!this.response.isWriteResponseBodyStarted()) { + this.response.setWriteResponseBodyStarted(true); + onBeforeWriteResponseBody(request, response); + } + } + + public void flush() throws IOException { + fireOnBeforeWriteResponseBodyEvent(); + servletOutputStream.flush(); + } + + public void print(boolean b) throws IOException { + fireOnBeforeWriteResponseBodyEvent(); + servletOutputStream.print(b); + } + + public void print(char c) throws IOException { + fireOnBeforeWriteResponseBodyEvent(); + servletOutputStream.print(c); + } + + public void print(double d) throws IOException { + fireOnBeforeWriteResponseBodyEvent(); + servletOutputStream.print(d); + } + + public void print(float f) throws IOException { + fireOnBeforeWriteResponseBodyEvent(); + servletOutputStream.print(f); + } + + public void print(int i) throws IOException { + fireOnBeforeWriteResponseBodyEvent(); + servletOutputStream.print(i); + } + + public void print(long l) throws IOException { + fireOnBeforeWriteResponseBodyEvent(); + servletOutputStream.print(l); + } + + public void print(String s) throws IOException { + fireOnBeforeWriteResponseBodyEvent(); + servletOutputStream.print(s); + } + + public void println() throws IOException { + fireOnBeforeWriteResponseBodyEvent(); + servletOutputStream.println(); + } + + public void println(boolean b) throws IOException { + fireOnBeforeWriteResponseBodyEvent(); + servletOutputStream.println(b); + } + + public void println(char c) throws IOException { + fireOnBeforeWriteResponseBodyEvent(); + servletOutputStream.println(c); + } + + public void println(double d) throws IOException { + fireOnBeforeWriteResponseBodyEvent(); + servletOutputStream.println(d); + } + + public void println(float f) throws IOException { + fireOnBeforeWriteResponseBodyEvent(); + servletOutputStream.println(f); + } + + public void println(int i) throws IOException { + fireOnBeforeWriteResponseBodyEvent(); + servletOutputStream.println(i); + } + + public void println(long l) throws IOException { + fireOnBeforeWriteResponseBodyEvent(); + servletOutputStream.println(l); + } + + public void println(String s) throws IOException { + fireOnBeforeWriteResponseBodyEvent(); + servletOutputStream.println(s); + } + + public void write(byte[] b) throws IOException { + fireOnBeforeWriteResponseBodyEvent(); + servletOutputStream.write(b); + } + + public void write(byte[] b, int off, int len) throws IOException { + fireOnBeforeWriteResponseBodyEvent(); + servletOutputStream.write(b, off, len); + } + + public void write(int b) throws IOException { + fireOnBeforeWriteResponseBodyEvent(); + servletOutputStream.write(b); + } + + } + + /** + * {@link Pattern} for a comma delimited string that support whitespace + * characters + */ + private static final Pattern commaSeparatedValuesPattern = Pattern.compile("\\s*,\\s*"); + + private static final String HEADER_CACHE_CONTROL = "Cache-Control"; + + private static final String HEADER_EXPIRES = "Expires"; + + private static final String HEADER_LAST_MODIFIED = "Last-Modified"; + + private static final Log log = LogFactory.getLog(ExpiresFilter.class); + + private static final String PARAMETER_EXPIRES_ACTIVE = "ExpiresActive"; + + private static final String PARAMETER_EXPIRES_BY_TYPE = "ExpiresByType"; + + private static final String PARAMETER_EXPIRES_DEFAULT = "ExpiresDefault"; + + private static final String PARAMETER_EXPIRES_EXCLUDED_RESPONSE_STATUS_CODES = "ExpiresExcludedResponseStatusCodes"; + + /** + * Convert a comma delimited list of numbers into an int[]. + * + * @param commaDelimitedInts + * can be null + * @return never null array + */ + protected static int[] commaDelimitedListToIntArray(String commaDelimitedInts) { + String[] intsAsStrings = commaDelimitedListToStringArray(commaDelimitedInts); + int[] ints = new int[intsAsStrings.length]; + for (int i = 0; i < intsAsStrings.length; i++) { + String intAsString = intsAsStrings[i]; + try { + ints[i] = Integer.parseInt(intAsString); + } catch (NumberFormatException e) { + throw new RuntimeException("Exception parsing number '" + i + "' (zero based) of comma delimited list '" + + commaDelimitedInts + "'"); + } + } + return ints; + } + + /** + * Convert a given comma delimited list of strings into an array of String + * + * @return array of patterns (non null) + */ + protected static String[] commaDelimitedListToStringArray(String commaDelimitedStrings) { + return (commaDelimitedStrings == null || commaDelimitedStrings.length() == 0) ? new String[0] : commaSeparatedValuesPattern + .split(commaDelimitedStrings); + } + + /** + * Return true if the given str contains the given + * searchStr. + */ + protected static boolean contains(String str, String searchStr) { + if (str == null || searchStr == null) { + return false; + } + return str.indexOf(searchStr) >= 0; + } + + /** + * Convert an array of ints into a comma delimited string + */ + protected static String intsToCommaDelimitedString(int[] ints) { + if (ints == null) { + return ""; + } + + StringBuilder result = new StringBuilder(); + + for (int i = 0; i < ints.length; i++) { + result.append(ints[i]); + if (i < (ints.length - 1)) { + result.append(", "); + } + } + return result.toString(); + } + + /** + * Return true if the given str is + * null or has a zero characters length. + */ + protected static boolean isEmpty(String str) { + return str == null || str.length() == 0; + } + + /** + * Return true if the given str has at least one + * character (can be a withespace). + */ + protected static boolean isNotEmpty(String str) { + return !isEmpty(str); + } + + /** + * Return true if the given string starts with the + * given prefix ignoring case. + * + * @param string + * can be null + * @param prefix + * can be null + */ + protected static boolean startsWithIgnoreCase(String string, String prefix) { + if (string == null || prefix == null) { + return string == null && prefix == null; + } + if (prefix.length() > string.length()) { + return false; + } + + return string.regionMatches(true, 0, prefix, 0, prefix.length()); + } + + /** + * Return the subset of the given str that is before the first + * occurence of the given separator. Return null + * if the given str or the given separator is + * null. Return and empty string if the separator is empty. + * + * @param str + * can be null + * @param separator + * can be null + * @return + */ + protected static String substringBefore(String str, String separator) { + if (str == null || str.isEmpty() || separator == null) { + return null; + } + + if (separator.isEmpty()) { + return ""; + } + + int separatorIndex = str.indexOf(separator); + if (separatorIndex == -1) { + return str; + } + return str.substring(0, separatorIndex); + } + + /** + * @see #isActive() + */ + private boolean active = true; + + /** + * Default Expires configuration. + */ + private ExpiresConfiguration defaultExpiresConfiguration; + + /** + * list of response status code for which the {@link ExpiresFilter} will not + * generate expiration headers. + */ + private int[] excludedResponseStatusCodes = new int[] { HttpServletResponse.SC_NOT_MODIFIED }; + + /** + * Expires configuration by content type. Visible for test. + */ + private Map expiresConfigurationByContentType = new LinkedHashMap(); + + public void destroy() { + + } + + public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException { + if (request instanceof HttpServletRequest && response instanceof HttpServletResponse) { + HttpServletRequest httpRequest = (HttpServletRequest) request; + HttpServletResponse httpResponse = (HttpServletResponse) response; + + if (response.isCommitted()) { + if (log.isDebugEnabled()) { + log.debug(sm.getString("expiresFilter.responseAlreadyCommited", httpRequest.getRequestURL())); + } + chain.doFilter(request, response); + } else if (active) { + XHttpServletResponse xResponse = new XHttpServletResponse(httpRequest, httpResponse); + chain.doFilter(request, xResponse); + if (!xResponse.isWriteResponseBodyStarted()) { + // Empty response, manually trigger + // onBeforeWriteResponseBody() + onBeforeWriteResponseBody(httpRequest, xResponse); + } + } else { + if (log.isDebugEnabled()) { + log.debug(sm.getString("expiresFilter.filterNotActive", httpRequest.getRequestURL())); + } + chain.doFilter(request, response); + } + } else { + chain.doFilter(request, response); + } + } + + public ExpiresConfiguration getDefaultExpiresConfiguration() { + return defaultExpiresConfiguration; + } + + public String getExcludedResponseStatusCodes() { + return intsToCommaDelimitedString(excludedResponseStatusCodes); + } + + public int[] getExcludedResponseStatusCodesAsInts() { + return excludedResponseStatusCodes; + } + + /** + *

+ * Returns the expiration date of the given {@link XHttpServletResponse} or + * null if no expiration date has been configured for the + * declared content type. + *

+ *

+ * protected for extension. + *

+ * + * @see HttpServletResponse#getContentType() + */ + protected Date getExpirationDate(HttpServletRequest request, XHttpServletResponse response) { + String contentType = response.getContentType(); + + // lookup exact content-type match (e.g. + // "text/html; charset=iso-8859-1") + ExpiresConfiguration configuration = expiresConfigurationByContentType.get(contentType); + if (configuration != null) { + Date result = getExpirationDate(configuration, request, response); + if (log.isErrorEnabled()) { + log.error(sm.getString("expiresFilter.useMatchingConfiguration", configuration, contentType, contentType, result)); + } + return result; + } + + if (contains(contentType, ";")) { + // lookup content-type without charset match (e.g. "text/html") + String contentTypeWithoutCharset = substringBefore(contentType, ";").trim(); + configuration = expiresConfigurationByContentType.get(contentTypeWithoutCharset); + + if (configuration != null) { + Date result = getExpirationDate(configuration, request, response); + if (log.isErrorEnabled()) { + log.error(sm.getString("expiresFilter.useMatchingConfiguration", configuration, contentTypeWithoutCharset, contentType, + result)); + } + return result; + } + } + + if (contains(contentType, "/")) { + // lookup major type match (e.g. "text") + String majorType = substringBefore(contentType, "/"); + configuration = expiresConfigurationByContentType.get(majorType); + if (configuration != null) { + Date result = getExpirationDate(configuration, request, response); + if (log.isErrorEnabled()) { + log.error(sm.getString("expiresFilter.useMatchingConfiguration", configuration, majorType, contentType, result)); + } + return result; + } + } + + if (defaultExpiresConfiguration != null) { + Date result = getExpirationDate(defaultExpiresConfiguration, request, response); + if (log.isErrorEnabled()) { + log.error(sm.getString("expiresFilter.useDefaultConfiguration", defaultExpiresConfiguration, contentType, result)); + } + return result; + } + + if (log.isErrorEnabled()) { + log.error(sm.getString("expiresFilter.noExpirationConfiguredForContentType", contentType)); + } + return null; + } + + /** + *

+ * Returns the expiration date of the given {@link ExpiresConfiguration}, + * {@link HttpServletRequest} and {@link XHttpServletResponse}. + *

+ *

+ * protected for extension. + *

+ */ + protected Date getExpirationDate(ExpiresConfiguration configuration, HttpServletRequest request, XHttpServletResponse response) { + Calendar calendar; + switch (configuration.getStartingPoint()) { + case ACCESS_TIME: + calendar = GregorianCalendar.getInstance(); + break; + case LAST_MODIFICATION_TIME: + if (response.isLastModifiedHeaderSet()) { + try { + long lastModified = response.getLastModifiedHeader(); + calendar = GregorianCalendar.getInstance(); + calendar.setTimeInMillis(lastModified); + } catch (NumberFormatException e) { + // default to now + calendar = GregorianCalendar.getInstance(); + } + } else { + // Last-Modified header not found, use now + calendar = GregorianCalendar.getInstance(); + } + break; + default: + throw new IllegalStateException(sm.getString("expiresFilter.unsupportedStartingPoint", configuration.getStartingPoint())); + } + for (Duration duration : configuration.getDurations()) { + calendar.add(duration.getUnit().getCalendardField(), duration.getAmount()); + } + + return calendar.getTime(); + } + + public Map getExpiresConfigurationByContentType() { + return expiresConfigurationByContentType; + } + + @Override + protected Log getLogger() { + return log; + } + + public void init(FilterConfig filterConfig) throws ServletException { + for (Enumeration names = filterConfig.getInitParameterNames(); names.hasMoreElements();) { + String name = names.nextElement(); + String value = filterConfig.getInitParameter(name); + + try { + if (name.startsWith(PARAMETER_EXPIRES_BY_TYPE)) { + String contentType = name.substring(PARAMETER_EXPIRES_BY_TYPE.length()).trim(); + ExpiresConfiguration expiresConfiguration = parseExpiresConfiguration(value); + this.expiresConfigurationByContentType.put(contentType, expiresConfiguration); + } else if (name.equalsIgnoreCase(PARAMETER_EXPIRES_DEFAULT)) { + ExpiresConfiguration expiresConfiguration = parseExpiresConfiguration(value); + this.defaultExpiresConfiguration = expiresConfiguration; + } else if (name.equalsIgnoreCase(PARAMETER_EXPIRES_ACTIVE)) { + this.active = "On".equalsIgnoreCase(value) || Boolean.valueOf(value); + } else if (name.equalsIgnoreCase(PARAMETER_EXPIRES_EXCLUDED_RESPONSE_STATUS_CODES)) { + this.excludedResponseStatusCodes = commaDelimitedListToIntArray(value); + } else { + log.warn(sm.getString("expiresFilter.unknownParameterIgnored", name, value)); + } + } catch (RuntimeException e) { + throw new ServletException(sm.getString("expiresFilter.exceptionProcessingParameter", name, value), e); + } + } + + log.debug(sm.getString("expiresFilter.filterInitialized", this.toString())); + } + + /** + * Indicates that the filter is active. If false, the filter is + * pass-through. Default is true. + */ + public boolean isActive() { + return active; + } + + /** + * + *

+ * protected for extension. + *

+ */ + protected boolean isEligibleToExpirationHeaderGeneration(HttpServletRequest request, XHttpServletResponse response) { + boolean expirationHeaderHasBeenSet = response.containsHeader(HEADER_EXPIRES) + || contains(response.getCacheControlHeader(), "max-age"); + if (expirationHeaderHasBeenSet) { + if (log.isDebugEnabled()) { + log.debug(sm.getString("expiresFilter.expirationHeaderAlreadyDefined", request.getRequestURI(), response.getStatus(), + response.getContentType())); + } + return false; + } + + for (int skippedStatusCode : this.excludedResponseStatusCodes) { + if (response.getStatus() == skippedStatusCode) { + if (log.isDebugEnabled()) { + log.debug(sm.getString("expiresFilter.skippedStatusCode", request.getRequestURI(), response.getStatus(), response + .getContentType())); + } + return false; + } + } + + return true; + } + + /** + *

+ * If no expiration header has been set by the servlet and an expiration has + * been defined in the {@link ExpiresFilter} configuration, sets the ' + * Expires' header and the attribute 'max-age' of the ' + * Cache-Control' header. + *

+ *

+ * Must be called on the "Start Write Response Body" event. + *

+ *

+ * Invocations to Logger.debug(...) are guarded by + * {@link Logger#isDebugEnabled()} because + * {@link HttpServletRequest#getRequestURI()} and + * {@link HttpServletResponse#getContentType()} costs String + * objects instantiations (as of Tomcat 7). + *

+ */ + public void onBeforeWriteResponseBody(HttpServletRequest request, XHttpServletResponse response) { + + if (!isEligibleToExpirationHeaderGeneration(request, response)) { + return; + } + + Date expirationDate = getExpirationDate(request, response); + if (expirationDate == null) { + if (log.isDebugEnabled()) { + log.debug(sm.getString("expiresFilter.noExpirationConfigured", request.getRequestURI(), response.getStatus(), response + .getContentType())); + } + } else { + if (log.isDebugEnabled()) { + log.debug(sm.getString("expiresFilter.setExpirationDate", request.getRequestURI(), response.getStatus(), response + .getContentType(), expirationDate)); + } + + String maxAgeDirective = "max-age=" + ((expirationDate.getTime() - System.currentTimeMillis()) / 1000); + + String cacheControlHeader = response.getCacheControlHeader(); + String newCacheControlHeader = (cacheControlHeader == null) ? maxAgeDirective : cacheControlHeader + ", " + maxAgeDirective; + response.setHeader(HEADER_CACHE_CONTROL, newCacheControlHeader); + response.setDateHeader(HEADER_EXPIRES, expirationDate.getTime()); + } + + } + + /** + * Parse configuration lines like ' + * access plus 1 month 15 days 2 hours' or ' + * modification 1 day 2 hours 5 seconds' + * + * @param line + */ + protected ExpiresConfiguration parseExpiresConfiguration(String line) { + line = line.trim(); + + StringTokenizer tokenizer = new StringTokenizer(line, " "); + + String currentToken; + + try { + currentToken = tokenizer.nextToken(); + } catch (NoSuchElementException e) { + throw new IllegalStateException(sm.getString("expiresFilter.startingPointNotFound", line)); + } + + StartingPoint startingPoint; + if ("access".equalsIgnoreCase(currentToken) || "now".equalsIgnoreCase(currentToken)) { + startingPoint = StartingPoint.ACCESS_TIME; + } else if ("modification".equalsIgnoreCase(currentToken)) { + startingPoint = StartingPoint.LAST_MODIFICATION_TIME; + } else if (!tokenizer.hasMoreTokens() && startsWithIgnoreCase(currentToken, "a")) { + startingPoint = StartingPoint.ACCESS_TIME; + // trick : convert duration configuration from old to new style + tokenizer = new StringTokenizer(currentToken.substring(1) + " seconds", " "); + } else if (!tokenizer.hasMoreTokens() && startsWithIgnoreCase(currentToken, "m")) { + startingPoint = StartingPoint.LAST_MODIFICATION_TIME; + // trick : convert duration configuration from old to new style + tokenizer = new StringTokenizer(currentToken.substring(1) + " seconds", " "); + } else { + throw new IllegalStateException(sm.getString("expiresFilter.startingPointInvalid", currentToken, line)); + } + + try { + currentToken = tokenizer.nextToken(); + } catch (NoSuchElementException e) { + throw new IllegalStateException(sm.getString("Duration not found in directive '{}'", line)); + } + + if ("plus".equalsIgnoreCase(currentToken)) { + // skip + try { + currentToken = tokenizer.nextToken(); + } catch (NoSuchElementException e) { + throw new IllegalStateException(sm.getString("Duration not found in directive '{}'", line)); + } + } + + List durations = new ArrayList(); + + while (currentToken != null) { + int amount; + try { + amount = Integer.parseInt(currentToken); + } catch (NumberFormatException e) { + throw new IllegalStateException(sm.getString("Invalid duration (number) '{}' in directive '{}'", currentToken, line)); + } + + try { + currentToken = tokenizer.nextToken(); + } catch (NoSuchElementException e) { + throw new IllegalStateException(sm.getString("Duration unit not found after amount {} in directive '{}'", amount, line)); + } + DurationUnit durationUnit; + if ("years".equalsIgnoreCase(currentToken)) { + durationUnit = DurationUnit.YEAR; + } else if ("month".equalsIgnoreCase(currentToken) || "months".equalsIgnoreCase(currentToken)) { + durationUnit = DurationUnit.MONTH; + } else if ("week".equalsIgnoreCase(currentToken) || "weeks".equalsIgnoreCase(currentToken)) { + durationUnit = DurationUnit.WEEK; + } else if ("day".equalsIgnoreCase(currentToken) || "days".equalsIgnoreCase(currentToken)) { + durationUnit = DurationUnit.DAY; + } else if ("hour".equalsIgnoreCase(currentToken) || "hours".equalsIgnoreCase(currentToken)) { + durationUnit = DurationUnit.HOUR; + } else if ("minute".equalsIgnoreCase(currentToken) || "minutes".equalsIgnoreCase(currentToken)) { + durationUnit = DurationUnit.MINUTE; + } else if ("second".equalsIgnoreCase(currentToken) || "seconds".equalsIgnoreCase(currentToken)) { + durationUnit = DurationUnit.SECOND; + } else { + throw new IllegalStateException(sm.getString( + "Invalid duration unit (years|months|weeks|days|hours|minutes|seconds) '{}' in directive '{}'", currentToken, line)); + } + + Duration duration = new Duration(amount, durationUnit); + durations.add(duration); + + if (tokenizer.hasMoreTokens()) { + currentToken = tokenizer.nextToken(); + } else { + currentToken = null; + } + } + + return new ExpiresConfiguration(startingPoint, durations); + } + + public void setActive(boolean active) { + this.active = active; + } + + public void setDefaultExpiresConfiguration(ExpiresConfiguration defaultExpiresConfiguration) { + this.defaultExpiresConfiguration = defaultExpiresConfiguration; + } + + public void setExcludedResponseStatusCodes(int[] excludedResponseStatusCodes) { + this.excludedResponseStatusCodes = excludedResponseStatusCodes; + } + + public void setExpiresConfigurationByContentType(Map expiresConfigurationByContentType) { + this.expiresConfigurationByContentType = expiresConfigurationByContentType; + } + + @Override + public String toString() { + return getClass().getSimpleName() + "[active=" + this.active + ", excludedResponseStatusCode=[" + + intsToCommaDelimitedString(this.excludedResponseStatusCodes) + "], default=" + this.defaultExpiresConfiguration + + ", byType=" + this.expiresConfigurationByContentType + "]"; + } +} Index: webapps/docs/config/filter.xml =================================================================== --- webapps/docs/config/filter.xml (revision 928504) +++ webapps/docs/config/filter.xml (working copy) @@ -86,7 +86,407 @@ +
+ + +

+ ExpiresFilter is a Java Servlet API port of Apache + mod_expires. + This filter controls the setting of the Expires HTTP header and the + max-age directive of the Cache-Control HTTP header in + server responses. The expiration date can set to be relative to either the + time the source file was last modified, or to the time of the client access. +

+ +

+ These HTTP headers are an instruction to the client about the document's + validity and persistence. If cached, the document may be fetched from the + cache rather than from the source until this time has passed. After that, the + cache copy is considered "expired" and invalid, and a new copy must + be obtained from the source. +

+

+ To modify Cache-Control directives other than max-age (see + RFC + 2616 section 14.9), you can use other servlet filters or Apache Httpd + mod_headers module. +

+ +
+ + +

+ Basic configuration to add 'Expires' and 'Cache-Control: max-age=' + headers to images, css and javascript. +

+ + +<filter> + <filter-name>ExpiresFilter</filter-name> + <filter-class>org.apache.catalina.filters.ExpiresFilter</filter-class> + <init-param> + <param-name>ExpiresByType image</param-name> + <param-value>access plus 10 minutes</param-value> + </init-param> + <init-param> + <param-name>ExpiresByType text/css</param-name> + <param-value>access plus 10 minutes</param-value> + </init-param> + <init-param> + <param-name>ExpiresByType text/javascript</param-name> + <param-value>access plus 10 minutes</param-value> + </init-param> +</filter> +... +<filter-mapping> + <filter-name>ExpiresFilter</filter-name> + <url-pattern>/*</url-pattern> + <dispatcher>REQUEST</dispatcher> +</filter-mapping> + + + +
+ + +

+ The ExpiresDefault and ExpiresByType directives can also be + defined in a more readable syntax of the form: +

+ + +<init-param> + <param-name>ExpiresDefault</param-name> + <param-value><base> [plus] {<num> <type>}*</param-value> +</init-param> + +<init-param> + <param-name>ExpiresByType type</param-name> + <param-value><base> [plus] {<num> <type>}*</param-value> +</init-param> + +<init-param> + <param-name>ExpiresByType type;encoding</param-name> + <param-value><base> [plus] {<num> <type>}*</param-value> +</init-param> + +

+ where <base> is one of: +

    +
  • access
  • +
  • now (equivalent to 'access')
  • +
  • modification
  • +
+

+

+ The plus keyword is optional. <num> should be an + integer value (acceptable to Integer.parseInt()), and + <type> is one of: +

    +
  • years
  • +
  • months
  • +
  • weeks
  • +
  • days
  • +
  • hours
  • +
  • minutes
  • +
  • seconds
  • +
+ For example, any of the following directives can be used to make documents + expire 1 month after being accessed, by default: +

+ + +<init-param> + <param-name>ExpiresDefault</param-name> + <param-value>access plus 1 month</param-value> +</init-param> + +<init-param> + <param-name>ExpiresDefault</param-name> + <param-value>access plus 4 weeks</param-value> +</init-param> + +<init-param> + <param-name>ExpiresDefault</param-name> + <param-value>access plus 30 days</param-value> +</init-param> + +

+The expiry time can be fine-tuned by adding several ' +<num> <type>' clauses: +

+ + +<init-param> + <param-name>ExpiresByType text/html</param-name> + <param-value>access plus 1 month 15 days 2 hours</param-value> +</init-param> + +<init-param> + <param-name>ExpiresByType image/gif</param-name> + <param-value>modification plus 5 hours 3 minutes</param-value> +</init-param> + +

+ Note that if you use a modification date based setting, the Expires + header will not be added to content that does not come from + a file on disk. This is due to the fact that there is no modification time + for such content. +

+
+ + +

+ A response is eligible to be enriched by ExpiresFilter if : +

    +
  1. no expiration header is defined (Expires header or the + max-age directive of the Cache-Control header),
  2. +
  3. the response status code is not excluded by the directive + ExpiresExcludedResponseStatusCodes,
  4. +
  5. The Content-Type of the response matches one of the types + defined the in ExpiresByType directives or the + ExpiresDefault directive is defined.
  6. +
+

+

+ Note : If Cache-Control header contains other directives than + max-age, they are concatenated with the max-age directive + that is added by the ExpiresFilter. +

+ +
+ + +

+ The expiration configuration if elected according to the following algorithm: +

    +
  1. ExpiresByType matching the exact content-type returned by + HttpServletResponse.getContentType() possibly including the charset + (e.g. 'text/xml;charset=UTF-8'),
  2. +
  3. ExpiresByType matching the content-type without the charset if + HttpServletResponse.getContentType() contains a charset (e.g. ' + text/xml;charset=UTF-8' -> 'text/xml'),
  4. +
  5. ExpiresByType matching the major type (e.g. substring before + '/') of HttpServletResponse.getContentType() + (e.g. 'text/xml;charset=UTF-8' -> 'text + '),
  6. +
  7. ExpiresDefault
  8. +
+

+
+ + + +

The filter class name for the Expires Filter is + org.apache.catalina.filters.ExpiresFilter + .

+ +
+ + + +

The Expires Filter supports the following + initialisation parameters:

+ + + + +

+ This directive enables or disables the generation of the Expires and + Cache-Control headers by this ExpiresFilter. If set to + Off, the headers will not be generated for any HTTP response. If set + to On or true, the headers will be added to served HTTP + responses according to the criteria defined by the + ExpiresByType <content-type> and ExpiresDefault + directives. Note that this directive does not guarantee that an + Expires or Cache-Control header will be generated. If the + criteria aren't met, no header will be sent, and the effect will be as + though this directive wasn't even specified. +

+

+ Default value is true. +

+ +

+ Sample: enable filter +

+ + +<init-param> + <!-- supports case insensitive 'On' or 'true' --> + <param-name>ExpiresActive</param-name> + <param-value>On</param-value> +</init-param> + +

+ Sample: disable filter +

+ + +<init-param> + <!-- supports anything different from case insensitive 'On' and 'true' --> + <param-name>ExpiresActive</param-name> + <param-value>Off</param-value> +</init-param> + +
+ + +

+ This directive defines the http response status codes for which the + ExpiresFilter will not generate expiration headers. By default, the + 304 status code ("Not modified") is skipped. The + value is a comma separated list of http status codes. +

+

+ This directive is useful to ease usage of ExpiresDefault directive. + Indeed, the behavior of 304 Not modified (which does specify a + Content-Type header) combined with Expires and + Cache-Control:max-age= headers can be unnecessarily tricky to + understand. +

+

+ Sample : exclude response status codes 302, 500 and 503 +

+ + +<init-param> + <param-name>ExpiresExcludedResponseStatusCodes</param-name> + <param-value>302, 500, 503</param-value> +</init-param> + +
+ + +

+ This directive defines the value of the Expires header and the + max-age directive of the Cache-Control header generated for + documents of the specified type (e.g., text/html). The second + argument sets the number of seconds that will be added to a base time to + construct the expiration date. The Cache-Control: max-age is + calculated by subtracting the request time from the expiration date and + expressing the result in seconds. +

+

+ The base time is either the last modification time of the file, or the time + of the client's access to the document. Which should be used is + specified by the <code> field; M means that the + file's last modification time should be used as the base time, and + A means the client's access time should be used. The duration + is expressed in seconds. A2592000 stands for + access plus 30 days in alternate syntax. +

+

+ The difference in effect is subtle. If M (modification in + alternate syntax) is used, all current copies of the document in all caches + will expire at the same time, which can be good for something like a weekly + notice that's always found at the same URL. If A ( + access or now in alternate syntax) is used, the date of + expiration is different for each client; this can be good for image files + that don't change very often, particularly for a set of related + documents that all refer to the same images (i.e., the images will be + accessed repeatedly within a relatively short timespan). +

+

+ Note: When the content type includes a charset (e.g. + 'ExpiresByType text/xml;charset=utf-8'), Tomcat removes blank chars + between the ';' and the 'charset' keyword. Due to this, + configuration of an expiration with a charset must not include + such a space character. +

+

+ Sample: +

+ + +<init-param> + <param-name>ExpiresByType text/html</param-name> + <param-value>access plus 1 month 15 days 2 hours</param-value> +</init-param> + +<init-param> + <!-- 2592000 seconds = 30 days --> + <param-name>ExpiresByType image/gif</param-name> + <param-value>A2592000</param-value> +</init-param> + +

+ Note that this directive only has effect if ExpiresActive On has + been specified. It overrides, for the specified MIME type only, any + expiration date set by the ExpiresDefault directive. +

+

+ You can also specify the expiration time calculation using an alternate + syntax, described earlier in this document. +

+
+ + +

+ This directive sets the default algorithm for calculating the + expiration time for all documents in the affected realm. It can be + overridden on a type-by-type basis by the ExpiresByType directive. See the + description of that directive for details about the syntax of the + argument, and the "alternate syntax" + description as well. +

+
+
+ +
+ + +

+ To troubleshoot, enable logging on the + org.apache.catalina.filters.ExpiresFilter. +

+

+ Extract of logging.properties +

+ + +org.apache.catalina.filters.ExpiresFilter.level = FINE + +

+ Sample of initialization log message : +

+ + +Mar 26, 2010 2:01:41 PM org.apache.catalina.filters.ExpiresFilter init +FINE: Filter initialized with configuration ExpiresFilter[ + active=true, + excludedResponseStatusCode=[304], + default=null, + byType={ + image=ExpiresConfiguration[startingPoint=ACCESS_TIME, duration=[10 MINUTE]], + text/css=ExpiresConfiguration[startingPoint=ACCESS_TIME, duration=[10 MINUTE]], + text/javascript=ExpiresConfiguration[startingPoint=ACCESS_TIME, duration=[10 MINUTE]]}] + +

+ Sample of per-request log message where ExpiresFilter adds an + expiration date +

+ + +Mar 26, 2010 2:09:47 PM org.apache.catalina.filters.ExpiresFilter onBeforeWriteResponseBody +FINE: Request "/tomcat.gif" with response status "200" content-type "image/gif", set expiration date 3/26/10 2:19 PM + +

+ Sample of per-request log message where ExpiresFilter does not add + an expiration date +

+ + +Mar 26, 2010 2:10:27 PM org.apache.catalina.filters.ExpiresFilter onBeforeWriteResponseBody +FINE: Request "/docs/config/manager.html" with response status "200" content-type "text/html", no expiration configured + +
+ +
+