All Downloads are FREE. Search and download functionalities are using the official Maven repository.

org.apache.catalina.filters.ExpiresFilter Maven / Gradle / Ivy

There is a newer version: 11.0.0-M20
Show newest version
/*
 *  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.io.PrintWriter;
import java.util.ArrayList;
import java.util.Calendar;
import java.util.Date;
import java.util.Enumeration;
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 jakarta.servlet.FilterChain;
import jakarta.servlet.FilterConfig;
import jakarta.servlet.ServletException;
import jakarta.servlet.ServletOutputStream;
import jakarta.servlet.ServletRequest;
import jakarta.servlet.ServletResponse;
import jakarta.servlet.WriteListener;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import jakarta.servlet.http.HttpServletResponseWrapper;
import jakarta.servlet.http.MappingMatch;

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 '{@code Expires}' and * '{@code Cache-Control: max-age=}' headers to HTTP response according to its * '{@code Content-Type}'. *

* *

* Following documentation is inspired by mod_expires *

*

Summary

*

* This filter controls the setting of the {@code Expires} HTTP header and the * {@code max-age} directive of the {@code 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 {@code Cache-Control} directives other than {@code 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 * '{@code Expires}' and '{@code Cache-Control: max-age=}' * headers to images, CSS and JavaScript

* *
 * {@code
 * 
 *    ...
 *    
 *       ExpiresFilter
 *       org.apache.catalina.filters.ExpiresFilter
 *       
 *          ExpiresByType image
 *          access plus 10 minutes
 *       
 *       
 *          ExpiresByType text/css
 *          access plus 10 minutes
 *       
 *       
 *          ExpiresByType application/javascript
 *          access plus 10 minutes
 *       
 *    
 *    ...
 *    
 *       ExpiresFilter
 *       /*
 *       REQUEST
 *    
 *    ...
 * 
 * }
 * 
* *

Configuration Parameters

* *

{@code ExpiresByType }

*

* This directive defines the value of the {@code Expires} header and the * {@code max-age} directive of the {@code Cache-Control} header generated for * documents of the specified type (e.g., {@code text/html}). The second * argument sets the number of seconds that will be added to a base time to * construct the expiration date. The {@code 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; {@code M} means that the * file's last modification time should be used as the base time, and * {@code A} means the client's access time should be used. The duration * is expressed in seconds. {@code A2592000} stands for * {@code access plus 30 days} in alternate syntax. *

*

* The difference in effect is subtle. If {@code M} ({@code 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 {@code A} ( * {@code access} or {@code 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: *

* *
 * {@code
 * 
 *    ExpiresByType text/html
 *    access plus 1 month 15 days 2 hours
 * 
 *
 * 
 *    
 *    ExpiresByType image/gif
 *    A2592000
 * 
 * }
 * 
*

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

*

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

*

* {@code ExpiresExcludedResponseStatusCodes}

*

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

*

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

*

* Configuration sample : *

* *
 * {@code
 * 
 *    ExpiresExcludedResponseStatusCodes
 *    302, 500, 503
 * 
 * }
 * 
* *

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 {@code 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 {@code ExpiresDefault} and {@code ExpiresByType} directives can also be * defined in a more readable syntax of the form: *

* *
 * {@code
 * 
 *    ExpiresDefault
 *     [plus] ( )*
 * 
 *
 * 
 *    ExpiresByType type/encoding
 *     [plus] ( )*
 * 
 * }
 * 
*

* where {@code } is one of: *

*
    *
  • {@code access}
  • *
  • {@code now} (equivalent to '{@code access}')
  • *
  • {@code modification}
  • *
*

* The {@code plus} keyword is optional. {@code } should be an * integer value (acceptable to {@code Integer.parseInt()}), and * {@code } is one of: *

*
    *
  • {@code years}
  • *
  • {@code months}
  • *
  • {@code weeks}
  • *
  • {@code days}
  • *
  • {@code hours}
  • *
  • {@code minutes}
  • *
  • {@code seconds}
  • *
*

* For example, any of the following directives can be used to make documents * expire 1 month after being accessed, by default: *

* *
 * {@code
 * 
 *    ExpiresDefault
 *    access plus 1 month
 * 
 *
 * 
 *    ExpiresDefault
 *    access plus 4 weeks
 * 
 *
 * 
 *    ExpiresDefault
 *    access plus 30 days
 * 
 * }
 * 
*

* The expiry time can be fine-tuned by adding several ' * {@code }' clauses: *

* *
 * {@code
 * 
 *    ExpiresByType text/html
 *    access plus 1 month 15 days 2 hours
 * 
 *
 * 
 *    ExpiresByType image/gif
 *    modification plus 5 hours 3 minutes
 * 
 * }
 * 
*

* Note that if you use a modification date based setting, the {@code 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 {@code ExpiresFilter} if : *

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

* Note : *

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

Expiration configuration selection

*

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

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

Implementation Details

When to write the expiration headers ?

*

* The {@code 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 * {@code ExpiresFilter} wraps the http servlet response's writer and * outputStream to intercept calls to the methods {@code write()}, * {@code print()}, {@code close()} and {@code flush()}. For empty response * body (e.g. empty files), the {@code write()}, {@code print()}, * {@code close()} and {@code flush()} methods are not called; to handle this * case, the {@code ExpiresFilter}, at the end of its {@code doFilter()} * method, manually triggers the {@code onBeforeWriteResponseBody()} method. *

*

Configuration syntax

*

* The {@code ExpiresFilter} supports the same configuration syntax as Apache * Httpd mod_expires. *

*

* A challenge has been to choose the name of the {@code } * associated with {@code ExpiresByType} in the {@code } * declaration. Indeed, Several {@code ExpiresByType} directives can be * declared when {@code web.xml} syntax does not allow to declare several * {@code } with the same name. *

*

* The workaround has been to declare the content type in the * {@code } rather than in the {@code }. *

*

Designed for extension : the open/close principle

*

* The {@code 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 * {@code 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[ * excludedResponseStatusCode=[304], * default=null, * byType={ * image=ExpiresConfiguration[startingPoint=ACCESS_TIME, duration=[10 MINUTE]], * text/css=ExpiresConfiguration[startingPoint=ACCESS_TIME, duration=[10 MINUTE]], * application/javascript=ExpiresConfiguration[startingPoint=ACCESS_TIME, duration=[10 MINUTE]]}] * *

* Sample of per-request log message where {@code 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 {@code 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 { /** * Duration composed of an {@link #amount} and a {@link #unit} */ protected static class Duration { protected final int amount; protected final 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 calendarField; private DurationUnit(int calendarField) { this.calendarField = calendarField; } public int getCalendardField() { return calendarField; } } /** *

* Main piece of configuration of the filter. *

*

* Can be expressed like '{@code access plus 1 month 15 days 2 hours}'. *

*/ protected static class ExpiresConfiguration { /** * List of duration elements. */ private final List durations; /** * Starting point of the elapse to set in the response. */ private final StartingPoint startingPoint; 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 {@code string} * to {@code date} to {@code long} conversion. *

*/ public class XHttpServletResponse extends HttpServletResponseWrapper { /** * Value of the {@code Cache-Control} http response header if it has * been set. */ private String cacheControlHeader; /** * Value of the {@code Last-Modified} http response header if it has * been set. */ private long lastModifiedHeader; private boolean lastModifiedHeaderSet; private PrintWriter printWriter; private final HttpServletRequest request; private ServletOutputStream servletOutputStream; /** * Indicates whether calls to write methods ({@code write(...)}, * {@code 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 final PrintWriter out; private final HttpServletRequest request; private final XHttpServletResponse response; public XPrintWriter(PrintWriter out, HttpServletRequest request, XHttpServletResponse response) { super(out); this.out = out; this.request = request; this.response = response; } @Override public PrintWriter append(char c) { fireBeforeWriteResponseBodyEvent(); return out.append(c); } @Override public PrintWriter append(CharSequence csq) { fireBeforeWriteResponseBodyEvent(); return out.append(csq); } @Override public PrintWriter append(CharSequence csq, int start, int end) { fireBeforeWriteResponseBodyEvent(); return out.append(csq, start, end); } @Override public void close() { fireBeforeWriteResponseBodyEvent(); out.close(); } private void fireBeforeWriteResponseBodyEvent() { if (!this.response.isWriteResponseBodyStarted()) { this.response.setWriteResponseBodyStarted(true); onBeforeWriteResponseBody(request, response); } } @Override public void flush() { fireBeforeWriteResponseBodyEvent(); out.flush(); } @Override public void print(boolean b) { fireBeforeWriteResponseBodyEvent(); out.print(b); } @Override public void print(char c) { fireBeforeWriteResponseBodyEvent(); out.print(c); } @Override public void print(char[] s) { fireBeforeWriteResponseBodyEvent(); out.print(s); } @Override public void print(double d) { fireBeforeWriteResponseBodyEvent(); out.print(d); } @Override public void print(float f) { fireBeforeWriteResponseBodyEvent(); out.print(f); } @Override public void print(int i) { fireBeforeWriteResponseBodyEvent(); out.print(i); } @Override public void print(long l) { fireBeforeWriteResponseBodyEvent(); out.print(l); } @Override public void print(Object obj) { fireBeforeWriteResponseBodyEvent(); out.print(obj); } @Override public void print(String s) { fireBeforeWriteResponseBodyEvent(); out.print(s); } @Override public PrintWriter printf(Locale l, String format, Object... args) { fireBeforeWriteResponseBodyEvent(); return out.printf(l, format, args); } @Override public PrintWriter printf(String format, Object... args) { fireBeforeWriteResponseBodyEvent(); return out.printf(format, args); } @Override public void println() { fireBeforeWriteResponseBodyEvent(); out.println(); } @Override public void println(boolean x) { fireBeforeWriteResponseBodyEvent(); out.println(x); } @Override public void println(char x) { fireBeforeWriteResponseBodyEvent(); out.println(x); } @Override public void println(char[] x) { fireBeforeWriteResponseBodyEvent(); out.println(x); } @Override public void println(double x) { fireBeforeWriteResponseBodyEvent(); out.println(x); } @Override public void println(float x) { fireBeforeWriteResponseBodyEvent(); out.println(x); } @Override public void println(int x) { fireBeforeWriteResponseBodyEvent(); out.println(x); } @Override public void println(long x) { fireBeforeWriteResponseBodyEvent(); out.println(x); } @Override public void println(Object x) { fireBeforeWriteResponseBodyEvent(); out.println(x); } @Override public void println(String x) { fireBeforeWriteResponseBodyEvent(); out.println(x); } @Override public void write(char[] buf) { fireBeforeWriteResponseBodyEvent(); out.write(buf); } @Override public void write(char[] buf, int off, int len) { fireBeforeWriteResponseBodyEvent(); out.write(buf, off, len); } @Override public void write(int c) { fireBeforeWriteResponseBodyEvent(); out.write(c); } @Override public void write(String s) { fireBeforeWriteResponseBodyEvent(); out.write(s); } @Override 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 final HttpServletRequest request; private final XHttpServletResponse response; private final ServletOutputStream servletOutputStream; public XServletOutputStream(ServletOutputStream servletOutputStream, HttpServletRequest request, XHttpServletResponse response) { super(); this.servletOutputStream = servletOutputStream; this.response = response; this.request = request; } @Override public void close() throws IOException { fireOnBeforeWriteResponseBodyEvent(); servletOutputStream.close(); } private void fireOnBeforeWriteResponseBodyEvent() { if (!this.response.isWriteResponseBodyStarted()) { this.response.setWriteResponseBodyStarted(true); onBeforeWriteResponseBody(request, response); } } @Override public void flush() throws IOException { fireOnBeforeWriteResponseBodyEvent(); servletOutputStream.flush(); } @Override public void print(boolean b) throws IOException { fireOnBeforeWriteResponseBodyEvent(); servletOutputStream.print(b); } @Override public void print(char c) throws IOException { fireOnBeforeWriteResponseBodyEvent(); servletOutputStream.print(c); } @Override public void print(double d) throws IOException { fireOnBeforeWriteResponseBodyEvent(); servletOutputStream.print(d); } @Override public void print(float f) throws IOException { fireOnBeforeWriteResponseBodyEvent(); servletOutputStream.print(f); } @Override public void print(int i) throws IOException { fireOnBeforeWriteResponseBodyEvent(); servletOutputStream.print(i); } @Override public void print(long l) throws IOException { fireOnBeforeWriteResponseBodyEvent(); servletOutputStream.print(l); } @Override public void print(String s) throws IOException { fireOnBeforeWriteResponseBodyEvent(); servletOutputStream.print(s); } @Override public void println() throws IOException { fireOnBeforeWriteResponseBodyEvent(); servletOutputStream.println(); } @Override public void println(boolean b) throws IOException { fireOnBeforeWriteResponseBodyEvent(); servletOutputStream.println(b); } @Override public void println(char c) throws IOException { fireOnBeforeWriteResponseBodyEvent(); servletOutputStream.println(c); } @Override public void println(double d) throws IOException { fireOnBeforeWriteResponseBodyEvent(); servletOutputStream.println(d); } @Override public void println(float f) throws IOException { fireOnBeforeWriteResponseBodyEvent(); servletOutputStream.println(f); } @Override public void println(int i) throws IOException { fireOnBeforeWriteResponseBodyEvent(); servletOutputStream.println(i); } @Override public void println(long l) throws IOException { fireOnBeforeWriteResponseBodyEvent(); servletOutputStream.println(l); } @Override public void println(String s) throws IOException { fireOnBeforeWriteResponseBodyEvent(); servletOutputStream.println(s); } @Override public void write(byte[] b) throws IOException { fireOnBeforeWriteResponseBodyEvent(); servletOutputStream.write(b); } @Override public void write(byte[] b, int off, int len) throws IOException { fireOnBeforeWriteResponseBodyEvent(); servletOutputStream.write(b, off, len); } @Override public void write(int b) throws IOException { fireOnBeforeWriteResponseBodyEvent(); servletOutputStream.write(b); } /** * TODO SERVLET 3.1 */ @Override public boolean isReady() { return false; } /** * TODO SERVLET 3.1 */ @Override public void setWriteListener(WriteListener listener) { } } /** * {@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"; // Log must be non-static as loggers are created per class-loader and this // Filter may be used in multiple class loaders private final Log log = LogFactory.getLog(ExpiresFilter.class); // must not be static 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 {@code int[]}. * * @param commaDelimitedInts * can be {@code null} * @return never {@code 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(sm.getString("expiresFilter.numberError", Integer.valueOf(i), commaDelimitedInts)); } } return ints; } /** * Convert a given comma delimited list of strings into an array of String * * @param commaDelimitedStrings the string to be split * @return array of patterns (non {@code null}) */ protected static String[] commaDelimitedListToStringArray( String commaDelimitedStrings) { return (commaDelimitedStrings == null || commaDelimitedStrings.length() == 0) ? new String[0] : commaSeparatedValuesPattern.split(commaDelimitedStrings); } /** * @return {@code true} if the given {@code str} contains the given * {@code searchStr}. * @param str String that will be searched * @param searchStr The substring to search */ protected static boolean contains(String str, String searchStr) { if (str == null || searchStr == null) { return false; } return str.contains(searchStr); } /** * Convert an array of ints into a comma delimited string * @param ints The int array * @return a comma separated 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(); } /** * @param str The String to check * @return {@code true} if the given {@code str} is * {@code null} or has a zero characters length. */ protected static boolean isEmpty(String str) { return str == null || str.length() == 0; } /** * @param str The String to check * @return {@code true} if the given {@code str} has at least one * character (can be a whitespace). */ protected static boolean isNotEmpty(String str) { return !isEmpty(str); } /** * @return {@code true} if the given {@code string} starts with the * given {@code prefix} ignoring case. * * @param string * can be {@code null} * @param prefix * can be {@code 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 {@code str} that is before the first * occurrence of the given {@code separator}. Return {@code null} * if the given {@code str} or the given {@code separator} is * null. Return and empty string if the {@code separator} is empty. * * @param str * can be {@code null} * @param separator * can be {@code null} */ 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); } /** * 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<>(); @Override 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.responseAlreadyCommitted", httpRequest.getRequestURL())); } chain.doFilter(request, response); } else { XHttpServletResponse xResponse = new XHttpServletResponse( httpRequest, httpResponse); chain.doFilter(request, xResponse); if (!xResponse.isWriteResponseBodyStarted()) { // Empty response, manually trigger // onBeforeWriteResponseBody() onBeforeWriteResponseBody(httpRequest, xResponse); } } } 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 * {@code null} if no expiration date has been configured for the * declared content type. *

* {@code protected} for extension. * * @param request The HTTP request * @param response The wrapped HTTP response * * @return the expiration date * @see HttpServletResponse#getContentType() */ protected Date getExpirationDate(HttpServletRequest request, XHttpServletResponse response) { String contentType = response.getContentType(); if (contentType == null && request != null && request.getHttpServletMapping().getMappingMatch() == MappingMatch.DEFAULT && response.getStatus() == HttpServletResponse.SC_NOT_MODIFIED) { // Default servlet normally sets the content type but does not for // 304 responses. Look it up. String servletPath = request.getServletPath(); if (servletPath != null) { int lastSlash = servletPath.lastIndexOf('/'); if (lastSlash > -1) { String fileName = servletPath.substring(lastSlash + 1); contentType = request.getServletContext().getMimeType(fileName); } } } if (contentType != null) { contentType = contentType.toLowerCase(Locale.ENGLISH); } // 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, response); if (log.isDebugEnabled()) { log.debug(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, response); if (log.isDebugEnabled()) { log.debug(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, response); if (log.isDebugEnabled()) { log.debug(sm.getString( "expiresFilter.useMatchingConfiguration", configuration, majorType, contentType, result)); } return result; } } if (defaultExpiresConfiguration != null) { Date result = getExpirationDate(defaultExpiresConfiguration, response); if (log.isDebugEnabled()) { log.debug(sm.getString("expiresFilter.useDefaultConfiguration", defaultExpiresConfiguration, contentType, result)); } return result; } if (log.isDebugEnabled()) { log.debug(sm.getString( "expiresFilter.noExpirationConfiguredForContentType", contentType)); } return null; } /** *

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

*

* {@code protected} for extension. *

* @param configuration The parsed expires * @param response The Servlet response * @return the expiration date */ protected Date getExpirationDate(ExpiresConfiguration configuration, XHttpServletResponse response) { Calendar calendar; switch (configuration.getStartingPoint()) { case ACCESS_TIME: calendar = Calendar.getInstance(); break; case LAST_MODIFICATION_TIME: if (response.isLastModifiedHeaderSet()) { try { long lastModified = response.getLastModifiedHeader(); calendar = Calendar.getInstance(); calendar.setTimeInMillis(lastModified); } catch (NumberFormatException e) { // default to now calendar = Calendar.getInstance(); } } else { // Last-Modified header not found, use now calendar = Calendar.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; } @Override 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().toLowerCase(Locale.ENGLISH); 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_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())); } /** *

* {@code protected} for extension. *

* @param request The Servlet request * @param response The Servlet response * @return true if an expire header may be added */ 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(), Integer.valueOf(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(), Integer.valueOf(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 * '{@code Expires}' header and the attribute '{@code max-age}' of the * '{@code Cache-Control}' header. *

*

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

*

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

* @param request The Servlet request * @param response The Servlet response */ 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(), Integer.valueOf(response.getStatus()), response.getContentType())); } } else { if (log.isDebugEnabled()) { log.debug(sm.getString("expiresFilter.setExpirationDate", request.getRequestURI(), Integer.valueOf(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 * '{@code access plus 1 month 15 days 2 hours}' or * '{@code modification 1 day 2 hours 5 seconds}' * * @param inputLine the input * @return the parsed expires */ protected ExpiresConfiguration parseExpiresConfiguration(String inputLine) { String line = inputLine.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( "expiresFilter.noDurationFound", line)); } if ("plus".equalsIgnoreCase(currentToken)) { // skip try { currentToken = tokenizer.nextToken(); } catch (NoSuchElementException e) { throw new IllegalStateException(sm.getString( "expiresFilter.noDurationFound", line)); } } List durations = new ArrayList<>(); while (currentToken != null) { int amount; try { amount = Integer.parseInt(currentToken); } catch (NumberFormatException e) { throw new IllegalStateException(sm.getString( "expiresFilter.invalidDurationNumber", currentToken, line)); } try { currentToken = tokenizer.nextToken(); } catch (NoSuchElementException e) { throw new IllegalStateException( sm.getString( "expiresFilter.noDurationUnitAfterAmount", Integer.valueOf(amount), line)); } DurationUnit durationUnit; if ("year".equalsIgnoreCase(currentToken) || "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( "expiresFilter.invalidDurationUnit", 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 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() + "[excludedResponseStatusCode=[" + intsToCommaDelimitedString(this.excludedResponseStatusCodes) + "], default=" + this.defaultExpiresConfiguration + ", byType=" + this.expiresConfigurationByContentType + "]"; } }




© 2015 - 2024 Weber Informatics LLC | Privacy Policy