View | Details | Raw Unified | Return to bug 48998
Collapse All | Expand All

(-)test/org/apache/catalina/filters/TestExpiresFilter.java (+438 lines)
Line 0 Link Here
1
/*
2
 *  Licensed to the Apache Software Foundation (ASF) under one or more
3
 *  contributor license agreements.  See the NOTICE file distributed with
4
 *  this work for additional information regarding copyright ownership.
5
 *  The ASF licenses this file to You under the Apache License, Version 2.0
6
 *  (the "License"); you may not use this file except in compliance with
7
 *  the License.  You may obtain a copy of the License at
8
 *
9
 *      http://www.apache.org/licenses/LICENSE-2.0
10
 *
11
 *  Unless required by applicable law or agreed to in writing, software
12
 *  distributed under the License is distributed on an "AS IS" BASIS,
13
 *  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14
 *  See the License for the specific language governing permissions and
15
 *  limitations under the License.
16
 */
17
18
package org.apache.catalina.filters;
19
20
import java.io.IOException;
21
import java.net.HttpURLConnection;
22
import java.net.URL;
23
import java.util.Calendar;
24
import java.util.List;
25
import java.util.StringTokenizer;
26
import java.util.TimeZone;
27
import java.util.Map.Entry;
28
29
import javax.servlet.ServletException;
30
import javax.servlet.http.HttpServlet;
31
import javax.servlet.http.HttpServletRequest;
32
import javax.servlet.http.HttpServletResponse;
33
34
import junit.framework.Assert;
35
36
import org.apache.catalina.Context;
37
import org.apache.catalina.deploy.FilterDef;
38
import org.apache.catalina.deploy.FilterMap;
39
import org.apache.catalina.filters.ExpiresFilter.Duration;
40
import org.apache.catalina.filters.ExpiresFilter.DurationUnit;
41
import org.apache.catalina.filters.ExpiresFilter.ExpiresConfiguration;
42
import org.apache.catalina.filters.ExpiresFilter.StartingPoint;
43
import org.apache.catalina.startup.Tomcat;
44
import org.apache.catalina.startup.TomcatBaseTest;
45
46
public class TestExpiresFilter extends TomcatBaseTest {
47
    public static final String TEMP_DIR = System.getProperty("java.io.tmpdir");
48
49
    public void testConfiguration() throws ServletException, Exception {
50
51
        Tomcat tomcat = getTomcatInstance();
52
        Context root = tomcat.addContext("", TEMP_DIR);
53
54
        FilterDef filterDef = new FilterDef();
55
        filterDef.addInitParameter("ExpiresDefault", "access plus 1 month");
56
        filterDef.addInitParameter("ExpiresByType text/html", "access plus 1 month 15 days 2 hours");
57
        filterDef.addInitParameter("ExpiresByType image/gif", "modification plus 5 hours 3 minutes");
58
        filterDef.addInitParameter("ExpiresByType image/jpg", "A10000");
59
        filterDef.addInitParameter("ExpiresByType video/mpeg", "M20000");
60
        filterDef.addInitParameter("ExpiresActive", "Off");
61
        filterDef.addInitParameter("ExpiresExcludedResponseStatusCodes", "304, 503");
62
63
        ExpiresFilter expiresFilter = new ExpiresFilter();
64
65
        filterDef.setFilter(expiresFilter);
66
        filterDef.setFilterClass(ExpiresFilter.class.getName());
67
        filterDef.setFilterName(ExpiresFilter.class.getName());
68
69
        root.addFilterDef(filterDef);
70
71
        FilterMap filterMap = new FilterMap();
72
        filterMap.setFilterName(ExpiresFilter.class.getName());
73
        filterMap.addURLPattern("*");
74
75
        tomcat.start();
76
        try {
77
            Assert.assertEquals(false, expiresFilter.isActive());
78
79
            // VERIFY EXCLUDED RESPONSE STATUS CODES
80
            {
81
                int[] excludedResponseStatusCodes = expiresFilter.getExcludedResponseStatusCodesAsInts();
82
                Assert.assertEquals(2, excludedResponseStatusCodes.length);
83
                Assert.assertEquals(304, excludedResponseStatusCodes[0]);
84
                Assert.assertEquals(503, excludedResponseStatusCodes[1]);
85
            }
86
87
            // VERIFY DEFAULT CONFIGURATION
88
            {
89
                ExpiresConfiguration expiresConfiguration = expiresFilter.getDefaultExpiresConfiguration();
90
                Assert.assertEquals(StartingPoint.ACCESS_TIME, expiresConfiguration.getStartingPoint());
91
                Assert.assertEquals(1, expiresConfiguration.getDurations().size());
92
                Assert.assertEquals(DurationUnit.MONTH, expiresConfiguration.getDurations().get(0).getUnit());
93
                Assert.assertEquals(1, expiresConfiguration.getDurations().get(0).getAmount());
94
            }
95
96
            // VERIFY TEXT/HTML
97
            {
98
                ExpiresConfiguration expiresConfiguration = expiresFilter.getExpiresConfigurationByContentType().get("text/html");
99
                Assert.assertEquals(StartingPoint.ACCESS_TIME, expiresConfiguration.getStartingPoint());
100
101
                Assert.assertEquals(3, expiresConfiguration.getDurations().size());
102
103
                Duration oneMonth = expiresConfiguration.getDurations().get(0);
104
                Assert.assertEquals(DurationUnit.MONTH, oneMonth.getUnit());
105
                Assert.assertEquals(1, oneMonth.getAmount());
106
107
                Duration fifteenDays = expiresConfiguration.getDurations().get(1);
108
                Assert.assertEquals(DurationUnit.DAY, fifteenDays.getUnit());
109
                Assert.assertEquals(15, fifteenDays.getAmount());
110
111
                Duration twoHours = expiresConfiguration.getDurations().get(2);
112
                Assert.assertEquals(DurationUnit.HOUR, twoHours.getUnit());
113
                Assert.assertEquals(2, twoHours.getAmount());
114
            }
115
            // VERIFY IMAGE/GIF
116
            {
117
                ExpiresConfiguration expiresConfiguration = expiresFilter.getExpiresConfigurationByContentType().get("image/gif");
118
                Assert.assertEquals(StartingPoint.LAST_MODIFICATION_TIME, expiresConfiguration.getStartingPoint());
119
120
                Assert.assertEquals(2, expiresConfiguration.getDurations().size());
121
122
                Duration fiveHours = expiresConfiguration.getDurations().get(0);
123
                Assert.assertEquals(DurationUnit.HOUR, fiveHours.getUnit());
124
                Assert.assertEquals(5, fiveHours.getAmount());
125
126
                Duration threeMinutes = expiresConfiguration.getDurations().get(1);
127
                Assert.assertEquals(DurationUnit.MINUTE, threeMinutes.getUnit());
128
                Assert.assertEquals(3, threeMinutes.getAmount());
129
130
            }
131
            // VERIFY IMAGE/JPG
132
            {
133
                ExpiresConfiguration expiresConfiguration = expiresFilter.getExpiresConfigurationByContentType().get("image/jpg");
134
                Assert.assertEquals(StartingPoint.ACCESS_TIME, expiresConfiguration.getStartingPoint());
135
136
                Assert.assertEquals(1, expiresConfiguration.getDurations().size());
137
138
                Duration tenThousandSeconds = expiresConfiguration.getDurations().get(0);
139
                Assert.assertEquals(DurationUnit.SECOND, tenThousandSeconds.getUnit());
140
                Assert.assertEquals(10000, tenThousandSeconds.getAmount());
141
142
            }
143
            // VERIFY VIDEO/MPEG
144
            {
145
                ExpiresConfiguration expiresConfiguration = expiresFilter.getExpiresConfigurationByContentType().get("video/mpeg");
146
                Assert.assertEquals(StartingPoint.LAST_MODIFICATION_TIME, expiresConfiguration.getStartingPoint());
147
148
                Assert.assertEquals(1, expiresConfiguration.getDurations().size());
149
150
                Duration twentyThousandSeconds = expiresConfiguration.getDurations().get(0);
151
                Assert.assertEquals(DurationUnit.SECOND, twentyThousandSeconds.getUnit());
152
                Assert.assertEquals(20000, twentyThousandSeconds.getAmount());
153
            }
154
        } finally {
155
            tomcat.stop();
156
        }
157
    }
158
159
    /**
160
     * Test that a resource with empty content is also processed
161
     */
162
163
    public void testEmptyContent() throws Exception {
164
        HttpServlet servlet = new HttpServlet() {
165
            private static final long serialVersionUID = 1L;
166
167
            @Override
168
            protected void service(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException {
169
                response.setContentType("text/plain");
170
                // no content is written in the response
171
            }
172
        };
173
174
        int expectedMaxAgeInSeconds = 7 * 60;
175
176
        validate(servlet, expectedMaxAgeInSeconds);
177
    }
178
179
    public void testParseExpiresConfigurationCombinedDuration() {
180
        ExpiresFilter expiresFilter = new ExpiresFilter();
181
        ExpiresConfiguration actualConfiguration = expiresFilter.parseExpiresConfiguration("access plus 1 month 15 days 2 hours");
182
183
        Assert.assertEquals(StartingPoint.ACCESS_TIME, actualConfiguration.getStartingPoint());
184
185
        Assert.assertEquals(3, actualConfiguration.getDurations().size());
186
187
    }
188
189
    public void testParseExpiresConfigurationMonoDuration() {
190
        ExpiresFilter expiresFilter = new ExpiresFilter();
191
        ExpiresConfiguration actualConfiguration = expiresFilter.parseExpiresConfiguration("access plus 2 hours");
192
193
        Assert.assertEquals(StartingPoint.ACCESS_TIME, actualConfiguration.getStartingPoint());
194
195
        Assert.assertEquals(1, actualConfiguration.getDurations().size());
196
        Assert.assertEquals(2, actualConfiguration.getDurations().get(0).getAmount());
197
        Assert.assertEquals(DurationUnit.HOUR, actualConfiguration.getDurations().get(0).getUnit());
198
199
    }
200
201
    public void testSkipBecauseCacheControlMaxAgeIsDefined() throws Exception {
202
        HttpServlet servlet = new HttpServlet() {
203
            private static final long serialVersionUID = 1L;
204
205
            @Override
206
            protected void service(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException {
207
                response.setContentType("text/xml; charset=utf-8");
208
                response.addHeader("Cache-Control", "private, max-age=232");
209
                response.getWriter().print("Hello world");
210
            }
211
        };
212
213
        int expectedMaxAgeInSeconds = 232;
214
        validate(servlet, expectedMaxAgeInSeconds);
215
    }
216
217
    public void testExcludedResponseStatusCode() throws Exception {
218
        HttpServlet servlet = new HttpServlet() {
219
            private static final long serialVersionUID = 1L;
220
221
            @Override
222
            protected void service(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException {
223
                response.setStatus(HttpServletResponse.SC_NOT_MODIFIED);
224
                response.addHeader("ETag", "W/\"1934-1269208821000\"");
225
                response.addDateHeader("Date", System.currentTimeMillis());
226
            }
227
        };
228
229
        validate(servlet, null, HttpServletResponse.SC_NOT_MODIFIED);
230
    }
231
232
    public void testNullContentType() throws Exception {
233
        HttpServlet servlet = new HttpServlet() {
234
            private static final long serialVersionUID = 1L;
235
236
            @Override
237
            protected void service(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException {
238
                response.setContentType(null);
239
            }
240
        };
241
242
        int expectedMaxAgeInSeconds = 1 * 60;
243
        validate(servlet, expectedMaxAgeInSeconds);
244
    }
245
246
    public void testSkipBecauseExpiresIsDefined() throws Exception {
247
        HttpServlet servlet = new HttpServlet() {
248
            private static final long serialVersionUID = 1L;
249
250
            @Override
251
            protected void service(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException {
252
                response.setContentType("text/xml; charset=utf-8");
253
                response.addDateHeader("Expires", System.currentTimeMillis());
254
                response.getWriter().print("Hello world");
255
            }
256
        };
257
258
        validate(servlet, null);
259
    }
260
261
    public void testUseContentTypeExpiresConfiguration() throws Exception {
262
        HttpServlet servlet = new HttpServlet() {
263
            private static final long serialVersionUID = 1L;
264
265
            @Override
266
            protected void service(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException {
267
                response.setContentType("text/xml; charset=utf-8");
268
                response.getWriter().print("Hello world");
269
            }
270
        };
271
272
        int expectedMaxAgeInSeconds = 3 * 60;
273
274
        validate(servlet, expectedMaxAgeInSeconds);
275
    }
276
277
    public void testUseContentTypeWithoutCharsetExpiresConfiguration() throws Exception {
278
        HttpServlet servlet = new HttpServlet() {
279
            private static final long serialVersionUID = 1L;
280
281
            @Override
282
            protected void service(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException {
283
                response.setContentType("text/xml; charset=iso-8859-1");
284
                response.getWriter().print("Hello world");
285
            }
286
        };
287
288
        int expectedMaxAgeInSeconds = 5 * 60;
289
290
        validate(servlet, expectedMaxAgeInSeconds);
291
    }
292
293
    public void testUseDefaultConfiguration1() throws Exception {
294
        HttpServlet servlet = new HttpServlet() {
295
            private static final long serialVersionUID = 1L;
296
297
            @Override
298
            protected void service(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException {
299
                response.setContentType("image/jpeg");
300
                response.getWriter().print("Hello world");
301
            }
302
        };
303
304
        int expectedMaxAgeInSeconds = 1 * 60;
305
306
        validate(servlet, expectedMaxAgeInSeconds);
307
    }
308
309
    public void testUseDefaultConfiguration2() throws Exception {
310
        HttpServlet servlet = new HttpServlet() {
311
            private static final long serialVersionUID = 1L;
312
313
            @Override
314
            protected void service(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException {
315
                response.setContentType("image/jpeg");
316
                response.addHeader("Cache-Control", "private");
317
318
                response.getWriter().print("Hello world");
319
            }
320
        };
321
322
        int expectedMaxAgeInSeconds = 1 * 60;
323
324
        validate(servlet, expectedMaxAgeInSeconds);
325
    }
326
327
    public void testUseMajorTypeExpiresConfiguration() throws Exception {
328
        HttpServlet servlet = new HttpServlet() {
329
            private static final long serialVersionUID = 1L;
330
331
            @Override
332
            protected void service(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException {
333
                response.setContentType("text/json; charset=iso-8859-1");
334
                response.getWriter().print("Hello world");
335
            }
336
        };
337
338
        int expectedMaxAgeInSeconds = 7 * 60;
339
340
        validate(servlet, expectedMaxAgeInSeconds);
341
    }
342
343
    protected void validate(HttpServlet servlet, Integer expectedMaxAgeInSeconds) throws Exception {
344
        validate(servlet, expectedMaxAgeInSeconds, HttpURLConnection.HTTP_OK);
345
    }
346
347
    protected void validate(HttpServlet servlet, Integer expectedMaxAgeInSeconds, int expectedResponseStatusCode) throws Exception {
348
        // SETUP
349
350
        Tomcat tomcat = getTomcatInstance();
351
        Context root = tomcat.addContext("", TEMP_DIR);
352
353
        FilterDef filterDef = new FilterDef();
354
        filterDef.addInitParameter("ExpiresDefault", "access plus 1 minute");
355
        filterDef.addInitParameter("ExpiresByType text/xml;charset=utf-8", "access plus 3 minutes");
356
        filterDef.addInitParameter("ExpiresByType text/xml", "access plus 5 minutes");
357
        filterDef.addInitParameter("ExpiresByType text", "access plus 7 minutes");
358
        filterDef.addInitParameter("ExpiresExcludedResponseStatusCodes", "304, 503");
359
360
        filterDef.setFilterClass(ExpiresFilter.class.getName());
361
        filterDef.setFilterName(ExpiresFilter.class.getName());
362
363
        root.addFilterDef(filterDef);
364
365
        FilterMap filterMap = new FilterMap();
366
        filterMap.setFilterName(ExpiresFilter.class.getName());
367
        filterMap.addURLPattern("*");
368
        root.addFilterMap(filterMap);
369
370
        Tomcat.addServlet(root, servlet.getClass().getName(), servlet);
371
        root.addServletMapping("/test", servlet.getClass().getName());
372
373
        tomcat.start();
374
375
        try {
376
            Calendar.getInstance(TimeZone.getTimeZone("GMT"));
377
            long timeBeforeInMillis = System.currentTimeMillis();
378
379
            // TEST
380
            HttpURLConnection httpURLConnection = (HttpURLConnection) new URL("http://localhost:" + tomcat.getConnector().getPort()
381
                    + "/test").openConnection();
382
383
            // VALIDATE
384
            Assert.assertEquals(expectedResponseStatusCode, httpURLConnection.getResponseCode());
385
386
            StringBuilder msg = new StringBuilder();
387
            for (Entry<String, List<String>> field : httpURLConnection.getHeaderFields().entrySet()) {
388
                for (String value : field.getValue()) {
389
                    msg.append((field.getKey() == null ? "" : field.getKey() + ": ") + value + "\n");
390
                }
391
            }
392
            System.out.println(msg);
393
394
            Integer actualMaxAgeInSeconds;
395
396
            String cacheControlHeader = httpURLConnection.getHeaderField("Cache-Control");
397
            if (cacheControlHeader == null) {
398
                actualMaxAgeInSeconds = null;
399
            } else {
400
                actualMaxAgeInSeconds = null;
401
                StringTokenizer cacheControlTokenizer = new StringTokenizer(cacheControlHeader, ",");
402
                while (cacheControlTokenizer.hasMoreTokens() && actualMaxAgeInSeconds == null) {
403
                    String cacheDirective = cacheControlTokenizer.nextToken();
404
                    StringTokenizer cacheDirectiveTokenizer = new StringTokenizer(cacheDirective, "=");
405
                    if (cacheDirectiveTokenizer.countTokens() == 2) {
406
                        String key = cacheDirectiveTokenizer.nextToken().trim();
407
                        String value = cacheDirectiveTokenizer.nextToken().trim();
408
                        if (key.equalsIgnoreCase("max-age")) {
409
                            actualMaxAgeInSeconds = Integer.parseInt(value);
410
                        }
411
                    }
412
                }
413
            }
414
415
            if (expectedMaxAgeInSeconds == null) {
416
                Assert.assertNull("actualMaxAgeInSeconds '" + actualMaxAgeInSeconds + "' should be null", actualMaxAgeInSeconds);
417
                return;
418
            }
419
420
            Assert.assertNotNull(actualMaxAgeInSeconds);
421
422
            int deltaInSeconds = Math.abs(actualMaxAgeInSeconds - expectedMaxAgeInSeconds);
423
            Assert.assertTrue("actualMaxAgeInSeconds: " + actualMaxAgeInSeconds + ", expectedMaxAgeInSeconds: " + expectedMaxAgeInSeconds
424
                    + ", request time: " + timeBeforeInMillis + " for content type " + httpURLConnection.getContentType(),
425
                    deltaInSeconds < 3);
426
427
        } finally {
428
            tomcat.stop();
429
        }
430
    }
431
432
    public void testIntsToCommaDelimitedString() {
433
        String actual = ExpiresFilter.intsToCommaDelimitedString(new int[] { 500, 503 });
434
        String expected = "500, 503";
435
436
        Assert.assertEquals(expected, actual);
437
    }
438
}
(-)java/org/apache/catalina/filters/LocalStrings.properties (+17 lines)
Lines 16-18 Link Here
16
filterbase.noSuchProperty=The property "{0}" is not defined for filters of type "{1}"
16
filterbase.noSuchProperty=The property "{0}" is not defined for filters of type "{1}"
17
 
17
 
18
http.403=Access to the specified resource ({0}) has been forbidden.
18
http.403=Access to the specified resource ({0}) has been forbidden.
19
20
expiresFilter.noExpirationConfigured=Request "{0}" with response status "{1}" content-type "{2}", no expiration configured
21
expiresFilter.setExpirationDate=Request "{0}" with response status "{1}" content-type "{2}", set expiration date {3}
22
expiresFilter.startingPointNotFound=Starting point (access|now|modification|a<seconds>|m<seconds>) not found in directive "{0}"
23
expiresFilter.startingPointInvalid=Invalid starting point (access|now|modification|a<seconds>|m<seconds>) "{0}" in directive "{1}"
24
expiresFilter.responseAlreadyCommited=Request "{0}", can not apply ExpiresFilter on already committed response.
25
expiresFilter.filterNotActive=Request "{0}", ExpiresFilter is NOT active
26
expiresFilter.noExpirationConfiguredForContentType=No Expires configuration found for content-type "{0}"
27
expiresFilter.useMatchingConfiguration=Use {0} matching "{1}" for content-type "{2}" returns {3}
28
expiresFilter.useDefaultConfiguration=Use default {0} for content-type "{1}" returns {2}
29
expiresFilter.unsupportedStartingPoint=Unsupported startingPoint "{0}"
30
expiresFilter.unknownParameterIgnored=Unknown parameter "{0}" with value "{1}" is ignored !
31
expiresFilter.exceptionProcessingParameter=Exception processing configuration parameter "{0}":"{1}"
32
expiresFilter.filterInitialized=Filter initialized with configuration {0}
33
expiresFilter.expirationHeaderAlreadyDefined=Request "{0}" with response status "{1}" content-type "{2}", expiration header already defined
34
expiresFilter.skippedStatusCode=Request "{0}" with response status "{1}" content-type "{1}", skip expiration header generation for given status
35
(-)java/org/apache/catalina/filters/ExpiresFilter.java (+1555 lines)
Line 0 Link Here
1
/*
2
 * Copyright 2008-2009 the original author or authors.
3
 *
4
 * Licensed under the Apache License, Version 2.0 (the "License");
5
 * you may not use this file except in compliance with the License.
6
 * You may obtain a copy of the License at
7
 *
8
 *      http://www.apache.org/licenses/LICENSE-2.0
9
 *
10
 * Unless required by applicable law or agreed to in writing, software
11
 * distributed under the License is distributed on an "AS IS" BASIS,
12
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13
 * See the License for the specific language governing permissions and
14
 * limitations under the License.
15
 */
16
17
package org.apache.catalina.filters;
18
19
import java.io.IOException;
20
import java.io.PrintWriter;
21
import java.util.ArrayList;
22
import java.util.Arrays;
23
import java.util.Calendar;
24
import java.util.Date;
25
import java.util.Enumeration;
26
import java.util.GregorianCalendar;
27
import java.util.LinkedHashMap;
28
import java.util.List;
29
import java.util.Locale;
30
import java.util.Map;
31
import java.util.NoSuchElementException;
32
import java.util.StringTokenizer;
33
import java.util.regex.Pattern;
34
35
import javax.servlet.Filter;
36
import javax.servlet.FilterChain;
37
import javax.servlet.FilterConfig;
38
import javax.servlet.ServletException;
39
import javax.servlet.ServletOutputStream;
40
import javax.servlet.ServletRequest;
41
import javax.servlet.ServletResponse;
42
import javax.servlet.http.HttpServletRequest;
43
import javax.servlet.http.HttpServletResponse;
44
import javax.servlet.http.HttpServletResponseWrapper;
45
46
import org.apache.juli.logging.Log;
47
import org.apache.juli.logging.LogFactory;
48
49
/**
50
 * <p>
51
 * ExpiresFilter is a Java Servlet API port of <a
52
 * href="http://httpd.apache.org/docs/2.2/mod/mod_expires.html">Apache
53
 * mod_expires</a> to add ' <tt>Expires</tt>' and '
54
 * <tt>Cache-Control: max-age=</tt>' headers to HTTP response according to its '
55
 * <tt>Content-Type</tt>'.
56
 * </p>
57
 * 
58
 * <p>
59
 * Following documentation is inspired by <tt>mod_expires</tt> .
60
 * </p>
61
 * <h1>Summary</h1>
62
 * <p>
63
 * This filter controls the setting of the <tt>Expires</tt> HTTP header and the
64
 * <tt>max-age</tt> directive of the <tt>Cache-Control</tt> HTTP header in
65
 * server responses. The expiration date can set to be relative to either the
66
 * time the source file was last modified, or to the time of the client access.
67
 * </p>
68
 * <p>
69
 * These HTTP headers are an instruction to the client about the document&#x27;s
70
 * validity and persistence. If cached, the document may be fetched from the
71
 * cache rather than from the source until this time has passed. After that, the
72
 * cache copy is considered &quot;expired&quot; and invalid, and a new copy must
73
 * be obtained from the source.
74
 * </p>
75
 * <p>
76
 * To modify <tt>Cache-Control</tt> directives other than <tt>max-age</tt> (see
77
 * <a href="http://www.w3.org/Protocols/rfc2616/rfc2616-sec14.html#sec14.9" >RFC
78
 * 2616 section 14.9</a>), you can use other servlet filters or <a
79
 * href="http://httpd.apache.org/docs/2.2/mod/mod_headers.html" >Apache Httpd
80
 * mod_headers</a> module.
81
 * </p>
82
 * <h1>Filter Configuration</h1><h2>Basic configuration to add &#x27;
83
 * <tt>Expires</tt>&#x27; and &#x27; <tt>Cache-Control: max-age=</tt>&#x27;
84
 * headers to images, css and javascript</h2>
85
 * 
86
 * <code><pre>
87
 * &lt;web-app ...&gt;
88
 *    ...
89
 *    &lt;filter&gt;
90
 *       &lt;filter-name&gt;ExpiresFilter&lt;/filter-name&gt;
91
 *       &lt;filter-class&gt;org.apache.catalina.filters.ExpiresFilter&lt;/filter-class&gt;
92
 *       &lt;init-param&gt;
93
 *          &lt;param-name&gt;ExpiresByType image&lt;/param-name&gt;
94
 *          &lt;param-value&gt;access plus 10 minutes&lt;/param-value&gt;
95
 *       &lt;/init-param&gt;
96
 *       &lt;init-param&gt;
97
 *          &lt;param-name&gt;ExpiresByType text/css&lt;/param-name&gt;
98
 *          &lt;param-value&gt;access plus 10 minutes&lt;/param-value&gt;
99
 *       &lt;/init-param&gt;
100
 *       &lt;init-param&gt;
101
 *          &lt;param-name&gt;ExpiresByType text/javascript&lt;/param-name&gt;
102
 *          &lt;param-value&gt;access plus 10 minutes&lt;/param-value&gt;
103
 *       &lt;/init-param&gt;
104
 *    &lt;/filter&gt;
105
 *    ...
106
 *    &lt;filter-mapping&gt;
107
 *       &lt;filter-name&gt;ExpiresFilter&lt;/filter-name&gt;
108
 *       &lt;url-pattern&gt;/*&lt;/url-pattern&gt;
109
 *       &lt;dispatcher&gt;REQUEST&lt;/dispatcher&gt;
110
 *    &lt;/filter-mapping&gt;
111
 *    ...
112
 * &lt;/web-app&gt;
113
 * </pre></code>
114
 * 
115
 * <h2>Configuration Parameters</h2><h3>
116
 * <tt>ExpiresActive</tt></h3>
117
 * <p>
118
 * This directive enables or disables the generation of the <tt>Expires</tt> and
119
 * <tt>Cache-Control</tt> headers by this <tt>ExpiresFilter</tt>. If set to
120
 * <tt>Off</tt>, the headers will not be generated for any HTTP response. If set
121
 * to <tt>On</tt> or <tt>true</tt>, the headers will be added to served HTTP
122
 * responses according to the criteria defined by the
123
 * <tt>ExpiresByType &lt;content-type&gt;</tt> and <tt>ExpiresDefault</tt>
124
 * directives. Note that this directive does not guarantee that an
125
 * <tt>Expires</tt> or <tt>Cache-Control</tt> header will be generated. If the
126
 * criteria aren&#x27;t met, no header will be sent, and the effect will be as
127
 * though this directive wasn&#x27;t even specified.
128
 * </p>
129
 * <p>
130
 * This parameter is optional, default value is <tt>true</tt>.
131
 * </p>
132
 * <p>
133
 * <i>Enable filter</i>
134
 * </p>
135
 * 
136
 * <code><pre>
137
 * &lt;init-param&gt;
138
 *    &lt;!-- supports case insensitive &#x27;On&#x27; or &#x27;true&#x27; --&gt;
139
 *    &lt;param-name&gt;ExpiresActive&lt;/param-name&gt;&lt;param-value&gt;On&lt;/param-value&gt;
140
 * &lt;/init-param&gt;
141
 * </pre></code>
142
 * <p>
143
 * <i>Disable filter</i>
144
 * </p>
145
 * 
146
 * <code><pre>
147
 * &lt;init-param&gt;
148
 *    &lt;!-- supports anything different from case insensitive &#x27;On&#x27; and &#x27;true&#x27; --&gt;
149
 *    &lt;param-name&gt;ExpiresActive&lt;/param-name&gt;&lt;param-value&gt;Off&lt;/param-value&gt;
150
 * &lt;/init-param&gt;
151
 * </pre></code>
152
 * 
153
 * <h3>
154
 * <tt>ExpiresByType &lt;content-type&gt;</tt></h3>
155
 * <p>
156
 * This directive defines the value of the <tt>Expires</tt> header and the
157
 * <tt>max-age</tt> directive of the <tt>Cache-Control</tt> header generated for
158
 * documents of the specified type (<i>e.g.</i>, <tt>text/html</tt>). The second
159
 * argument sets the number of seconds that will be added to a base time to
160
 * construct the expiration date. The <tt>Cache-Control: max-age</tt> is
161
 * calculated by subtracting the request time from the expiration date and
162
 * expressing the result in seconds.
163
 * </p>
164
 * <p>
165
 * The base time is either the last modification time of the file, or the time
166
 * of the client&#x27;s access to the document. Which should be used is
167
 * specified by the <tt>&lt;code&gt;</tt> field; <tt>M</tt> means that the
168
 * file&#x27;s last modification time should be used as the base time, and
169
 * <tt>A</tt> means the client&#x27;s access time should be used. The duration
170
 * is expressed in seconds. <tt>A2592000</tt> stands for
171
 * <tt>access plus 30 days</tt> in alternate syntax.
172
 * </p>
173
 * <p>
174
 * The difference in effect is subtle. If <tt>M</tt> (<tt>modification</tt> in
175
 * alternate syntax) is used, all current copies of the document in all caches
176
 * will expire at the same time, which can be good for something like a weekly
177
 * notice that&#x27;s always found at the same URL. If <tt>A</tt> (
178
 * <tt>access</tt> or <tt>now</tt> in alternate syntax) is used, the date of
179
 * expiration is different for each client; this can be good for image files
180
 * that don&#x27;t change very often, particularly for a set of related
181
 * documents that all refer to the same images (<i>i.e.</i>, the images will be
182
 * accessed repeatedly within a relatively short timespan).
183
 * </p>
184
 * <p>
185
 * <strong>Example:</strong>
186
 * </p>
187
 * 
188
 * <code><pre>
189
 * &lt;init-param&gt;
190
 *    &lt;param-name&gt;ExpiresByType text/html&lt;/param-name&gt;&lt;param-value&gt;access plus 1 month 15   days 2 hours&lt;/param-value&gt;
191
 * &lt;/init-param&gt;
192
 *  
193
 * &lt;init-param&gt;
194
 *    &lt;!-- 2592000 seconds = 30 days --&gt;
195
 *    &lt;param-name&gt;ExpiresByType image/gif&lt;/param-name&gt;&lt;param-value&gt;A2592000&lt;/param-value&gt;
196
 * &lt;/init-param&gt;
197
 * </pre></code>
198
 * <p>
199
 * Note that this directive only has effect if <tt>ExpiresActive On</tt> has
200
 * been specified. It overrides, for the specified MIME type <i>only</i>, any
201
 * expiration date set by the <tt>ExpiresDefault</tt> directive.
202
 * </p>
203
 * <p>
204
 * You can also specify the expiration time calculation using an alternate
205
 * syntax, described earlier in this document.
206
 * </p>
207
 * <h3>
208
 * <tt>ExpiresExcludedResponseStatusCodes</tt></h3>
209
 * <p>
210
 * This directive defines the http response status codes for which the
211
 * <tt>ExpiresFilter</tt> will not generate expiration headers. By default, the
212
 * <tt>304</tt> status code (&quot;<tt>Not modified</tt>&quot;) is skipped. The
213
 * value is a comma separated list of http status codes.
214
 * </p>
215
 * <p>
216
 * This directive is useful to ease usage of <tt>ExpiresDefault</tt> directive.
217
 * Indeed, the behavior of <tt>304 Not modified</tt> (which does specify a
218
 * <tt>Content-Type</tt> header) combined with <tt>Expires</tt> and
219
 * <tt>Cache-Control:max-age=</tt> headers can be unnecessarily tricky to
220
 * understand.
221
 * </p>
222
 * <p>
223
 * Configuration sample :
224
 * </p>
225
 * 
226
 * <code><pre>
227
 * &lt;init-param&gt;
228
 *    &lt;param-name&gt;ExpiresExcludedResponseStatusCodes&lt;/param-name&gt;&lt;param-value&gt;302, 500, 503&lt;/param-value&gt;
229
 * &lt;/init-param&gt;
230
 * </pre></code>
231
 * 
232
 * <h3>ExpiresDefault</h3>
233
 * <p>
234
 * This directive sets the default algorithm for calculating the expiration time
235
 * for all documents in the affected realm. It can be overridden on a
236
 * type-by-type basis by the <tt>ExpiresByType</tt> directive. See the
237
 * description of that directive for details about the syntax of the argument,
238
 * and the "alternate syntax" description as well.
239
 * </p>
240
 * <h1>Alternate Syntax</h1>
241
 * <p>
242
 * The <tt>ExpiresDefault</tt> and <tt>ExpiresByType</tt> directives can also be
243
 * defined in a more readable syntax of the form:
244
 * </p>
245
 * 
246
 * <code><pre>
247
 * &lt;init-param&gt;
248
 *    &lt;param-name&gt;ExpiresDefault&lt;/param-name&gt;&lt;param-value&gt;&lt;base&gt; [plus] {&lt;num&gt;   &lt;type&gt;}*&lt;/param-value&gt;
249
 * &lt;/init-param&gt;
250
 *  
251
 * &lt;init-param&gt;
252
 *    &lt;param-name&gt;ExpiresByType type/encoding&lt;/param-name&gt;&lt;param-value&gt;&lt;base&gt; [plus]   {&lt;num&gt; &lt;type&gt;}*&lt;/param-value&gt;
253
 * &lt;/init-param&gt;
254
 * </pre></code>
255
 * <p>
256
 * where <tt>&lt;base&gt;</tt> is one of:
257
 * <ul>
258
 * <li><tt>access</tt></li>
259
 * <li><tt>now</tt> (equivalent to &#x27;<tt>access</tt>&#x27;)</li>
260
 * <li><tt>modification</tt></li>
261
 * </ul>
262
 * </p>
263
 * <p>
264
 * The <tt>plus</tt> keyword is optional. <tt>&lt;num&gt;</tt> should be an
265
 * integer value (acceptable to <tt>Integer.parseInt()</tt>), and
266
 * <tt>&lt;type&gt;</tt> is one of:
267
 * <ul>
268
 * <li><tt>years</tt></li>
269
 * <li><tt>months</tt></li>
270
 * <li><tt>weeks</tt></li>
271
 * <li><tt>days</tt></li>
272
 * <li><tt>hours</tt></li>
273
 * <li><tt>minutes</tt></li>
274
 * <li><tt>seconds</tt></li>
275
 * </ul>
276
 * For example, any of the following directives can be used to make documents
277
 * expire 1 month after being accessed, by default:
278
 * </p>
279
 * 
280
 * <code><pre>
281
 * &lt;init-param&gt;
282
 *    &lt;param-name&gt;ExpiresDefault&lt;/param-name&gt;&lt;param-value&gt;access plus 1 month&lt;/param-value&gt;
283
 * &lt;/init-param&gt;
284
 *  
285
 * &lt;init-param&gt;
286
 *    &lt;param-name&gt;ExpiresDefault&lt;/param-name&gt;&lt;param-value&gt;access plus 4 weeks&lt;/param-value&gt;
287
 * &lt;/init-param&gt;
288
 *  
289
 * &lt;init-param&gt;
290
 *    &lt;param-name&gt;ExpiresDefault&lt;/param-name&gt;&lt;param-value&gt;access plus 30 days&lt;/param-value&gt;
291
 * &lt;/init-param&gt;
292
 * </pre></code>
293
 * <p>
294
 * The expiry time can be fine-tuned by adding several &#x27;
295
 * <tt>&lt;num&gt; &lt;type&gt;</tt>&#x27; clauses:
296
 * </p>
297
 * 
298
 * <code><pre>
299
 * &lt;init-param&gt;
300
 *    &lt;param-name&gt;ExpiresByType text/html&lt;/param-name&gt;&lt;param-value&gt;access plus 1 month 15   days 2 hours&lt;/param-value&gt;
301
 * &lt;/init-param&gt;
302
 *  
303
 * &lt;init-param&gt;
304
 *    &lt;param-name&gt;ExpiresByType image/gif&lt;/param-name&gt;&lt;param-value&gt;modification plus 5 hours 3   minutes&lt;/param-value&gt;
305
 * &lt;/init-param&gt;
306
 * </pre></code>
307
 * <p>
308
 * Note that if you use a modification date based setting, the <tt>Expires</tt>
309
 * header will <strong>not</strong> be added to content that does not come from
310
 * a file on disk. This is due to the fact that there is no modification time
311
 * for such content.
312
 * </p>
313
 * <h1>Expiration headers generation eligibility</h1>
314
 * <p>
315
 * A response is eligible to be enriched by <tt>ExpiresFilter</tt> if :
316
 * <ol>
317
 * <li>no expiration header is defined (<tt>Expires</tt> header or the
318
 * <tt>max-age</tt> directive of the <tt>Cache-Control</tt> header),</li>
319
 * <li>the response status code is not excluded by the directive
320
 * <tt>ExpiresExcludedResponseStatusCodes</tt>,</li>
321
 * <li>The <tt>Content-Type</tt> of the response matches one of the types
322
 * defined the in <tt>ExpiresByType</tt> directives or the
323
 * <tt>ExpiresDefault</tt> directive is defined.</li>
324
 * </ol>
325
 * </p>
326
 * <p>
327
 * Note :
328
 * <ul>
329
 * <li>If <tt>Cache-Control</tt> header contains other directives than
330
 * <tt>max-age</tt>, they are concatenated with the <tt>max-age</tt> directive
331
 * that is added by the <tt>ExpiresFilter</tt>.</li>
332
 * </ul>
333
 * </p>
334
 * <h1>Expiration configuration selection</h1>
335
 * <p>
336
 * The expiration configuration if elected according to the following algorithm:
337
 * <ol>
338
 * <li><tt>ExpiresByType</tt> matching the exact content-type returned by
339
 * <tt>HttpServletResponse.getContentType()</tt> possibly including the charset
340
 * (e.g. &#x27;<tt>text/xml;charset=UTF-8</tt>&#x27;),</li>
341
 * <li><tt>ExpiresByType</tt> matching the content-type without the charset if
342
 * <tt>HttpServletResponse.getContentType()</tt> contains a charset (e.g. &#x27;
343
 * <tt>text/xml;charset=UTF-8</tt>&#x27; -&gt; &#x27;<tt>text/xml</tt>&#x27;),</li>
344
 * <li><tt>ExpiresByType</tt> matching the major type (e.g. substring before
345
 * &#x27;<tt>/</tt>&#x27;) of <tt>HttpServletResponse.getContentType()</tt>
346
 * (e.g. &#x27;<tt>text/xml;charset=UTF-8</tt>&#x27; -&gt; &#x27;<tt>text</tt>
347
 * &#x27;),</li>
348
 * <li><tt>ExpiresDefault</tt></li>
349
 * </ol>
350
 * </p>
351
 * <h1>Implementation Details</h1><h2>When to write the expiration headers ?</h2>
352
 * <p>
353
 * The <tt>ExpiresFilter</tt> traps the &#x27;on before write response
354
 * body&#x27; event to decide whether it should generate expiration headers or
355
 * not.
356
 * </p>
357
 * <p>
358
 * To trap the &#x27;before write response body&#x27; event, the
359
 * <tt>ExpiresFilter</tt> wraps the http servlet response&#x27;s writer and
360
 * outputStream to intercept calls to the methods <tt>write()</tt>,
361
 * <tt>print()</tt>, <tt>close()</tt> and <tt>flush()</tt>. For empty response
362
 * body (e.g. empty files), the <tt>write()</tt>, <tt>print()</tt>,
363
 * <tt>close()</tt> and <tt>flush()</tt> methods are not called; to handle this
364
 * case, the <tt>ExpiresFilter</tt>, at the end of its <tt>doFilter()</tt>
365
 * method, manually triggers the <tt>onBeforeWriteResponseBody()</tt> method.
366
 * </p>
367
 * <h2>Configuration syntax</h2>
368
 * <p>
369
 * The <tt>ExpiresFilter</tt> supports the same configuration syntax as Apache
370
 * Httpd mod_expires.
371
 * </p>
372
 * <p>
373
 * A challenge has been to choose the name of the <tt>&lt;param-name&gt;</tt>
374
 * associated with <tt>ExpiresByType</tt> in the <tt>&lt;filter&gt;</tt>
375
 * declaration. Indeed, Several <tt>ExpiresByType</tt> directives can be
376
 * declared when <tt>web.xml</tt> syntax does not allow to declare several
377
 * <tt>&lt;init-param&gt;</tt> with the same name.
378
 * </p>
379
 * <p>
380
 * The workaround has been to declare the content type in the
381
 * <tt>&lt;param-name&gt;</tt> rather than in the <tt>&lt;param-value&gt;</tt>.
382
 * </p>
383
 * <h2>Designed for extension : the open/close principle</h2>
384
 * <p>
385
 * The <tt>ExpiresFilter</tt> has been designed for extension following the
386
 * open/close principle.
387
 * </p>
388
 * <p>
389
 * Key methods to override for extension are :
390
 * <ul>
391
 * <li>
392
 * {@link #isEligibleToExpirationHeaderGeneration(HttpServletRequest, XHttpServletResponse)}
393
 * </li>
394
 * <li>
395
 * {@link #getExpirationDate(HttpServletRequest, XHttpServletResponse)}</li>
396
 * </ul>
397
 * </p>
398
 * <h1>Troubleshooting</h1>
399
 * <p>
400
 * To troubleshoot, enable logging on the
401
 * <tt>org.apache.catalina.filters.ExpiresFilter</tt>.
402
 * </p>
403
 * <p>
404
 * Extract of logging.properties
405
 * </p>
406
 * 
407
 * <code><pre>
408
 * org.apache.catalina.filters.ExpiresFilter.level = FINE
409
 * </pre></code>
410
 * <p>
411
 * Sample of initialization log message :
412
 * </p>
413
 * 
414
 * <code><pre>
415
 * Mar 26, 2010 2:01:41 PM org.apache.catalina.filters.ExpiresFilter init
416
 * FINE: Filter initialized with configuration ExpiresFilter[
417
 *    active=true, 
418
 *    excludedResponseStatusCode=[304], 
419
 *    default=null, 
420
 *    byType={
421
 *       image=ExpiresConfiguration[startingPoint=ACCESS_TIME, duration=[10 MINUTE]], 
422
 *       text/css=ExpiresConfiguration[startingPoint=ACCESS_TIME, duration=[10 MINUTE]], 
423
 *       text/javascript=ExpiresConfiguration[startingPoint=ACCESS_TIME, duration=[10 MINUTE]]}]
424
 * </pre></code>
425
 * <p>
426
 * Sample of per-request log message where <tt>ExpiresFilter</tt> adds an
427
 * expiration date
428
 * </p>
429
 * 
430
 * <code><pre>
431
 * Mar 26, 2010 2:09:47 PM org.apache.catalina.filters.ExpiresFilter onBeforeWriteResponseBody
432
 * FINE: Request "/tomcat.gif" with response status "200" content-type "image/gif", set expiration date 3/26/10 2:19 PM
433
 * </pre></code>
434
 * <p>
435
 * Sample of per-request log message where <tt>ExpiresFilter</tt> does not add
436
 * an expiration date
437
 * </p>
438
 * 
439
 * <code><pre>
440
 * Mar 26, 2010 2:10:27 PM org.apache.catalina.filters.ExpiresFilter onBeforeWriteResponseBody
441
 * FINE: Request "/docs/config/manager.html" with response status "200" content-type "text/html", no expiration configured
442
 * </pre></code>
443
 * 
444
 */
445
public class ExpiresFilter extends FilterBase implements Filter {
446
447
    /**
448
     * Duration composed of an {@link #amount} and a {@link #unit}
449
     */
450
    protected static class Duration {
451
452
        public static Duration minutes(int amount) {
453
            return new Duration(amount, DurationUnit.MINUTE);
454
        }
455
456
        public static Duration seconds(int amount) {
457
            return new Duration(amount, DurationUnit.SECOND);
458
        }
459
460
        final protected int amount;
461
462
        final protected DurationUnit unit;
463
464
        public Duration(int amount, DurationUnit unit) {
465
            super();
466
            this.amount = amount;
467
            this.unit = unit;
468
        }
469
470
        public int getAmount() {
471
            return amount;
472
        }
473
474
        public DurationUnit getUnit() {
475
            return unit;
476
        }
477
478
        @Override
479
        public String toString() {
480
            return amount + " " + unit;
481
        }
482
    }
483
484
    /**
485
     * Duration unit
486
     */
487
    protected enum DurationUnit {
488
        DAY(Calendar.DAY_OF_YEAR), HOUR(Calendar.HOUR), MINUTE(Calendar.MINUTE), MONTH(Calendar.MONTH), SECOND(Calendar.SECOND), WEEK(
489
                Calendar.WEEK_OF_YEAR), YEAR(Calendar.YEAR);
490
        private final int calendardField;
491
492
        private DurationUnit(int calendardField) {
493
            this.calendardField = calendardField;
494
        }
495
496
        public int getCalendardField() {
497
            return calendardField;
498
        }
499
500
    }
501
502
    /**
503
     * <p>
504
     * Main piece of configuration of the filter.
505
     * </p>
506
     * <p>
507
     * Can be expressed like '<tt>access plus 1 month 15   days 2 hours</tt>'.
508
     * </p>
509
     */
510
    protected static class ExpiresConfiguration {
511
        /**
512
         * List of duration elements.
513
         */
514
        private List<Duration> durations;
515
516
        /**
517
         * Starting point of the elaspse to set in the response.
518
         */
519
        private StartingPoint startingPoint;
520
521
        public ExpiresConfiguration(StartingPoint startingPoint, Duration... durations) {
522
            this(startingPoint, Arrays.asList(durations));
523
        }
524
525
        public ExpiresConfiguration(StartingPoint startingPoint, List<Duration> durations) {
526
            super();
527
            this.startingPoint = startingPoint;
528
            this.durations = durations;
529
        }
530
531
        public List<Duration> getDurations() {
532
            return durations;
533
        }
534
535
        public StartingPoint getStartingPoint() {
536
            return startingPoint;
537
        }
538
539
        @Override
540
        public String toString() {
541
            return "ExpiresConfiguration[startingPoint=" + startingPoint + ", duration=" + durations + "]";
542
        }
543
    }
544
545
    /**
546
     * Expiration configuration starting point. Either the time the
547
     * html-page/servlet-response was served ({@link StartingPoint#ACCESS_TIME})
548
     * or the last time the html-page/servlet-response was modified (
549
     * {@link StartingPoint#LAST_MODIFICATION_TIME}).
550
     */
551
    protected enum StartingPoint {
552
        ACCESS_TIME, LAST_MODIFICATION_TIME
553
    }
554
555
    /**
556
     * <p>
557
     * Wrapping extension of the {@link HttpServletResponse} to yrap the
558
     * "Start Write Response Body" event.
559
     * </p>
560
     * <p>
561
     * For performance optimization : this extended response holds the
562
     * {@link #lastModifiedHeader} and {@link #cacheControlHeader} values access
563
     * to the slow {@link #getHeader(String)} and to spare the <tt>string</tt>
564
     * to <tt>date</tt> to <tt>long</tt> conversion.
565
     * </p>
566
     */
567
    public class XHttpServletResponse extends HttpServletResponseWrapper {
568
569
        /**
570
         * Value of the <tt>Cache-Control/tt> http response header if it has
571
         * been set.
572
         */
573
        private String cacheControlHeader;
574
575
        /**
576
         * Value of the <tt>Last-Modified</tt> http response header if it has
577
         * been set.
578
         */
579
        private long lastModifiedHeader;
580
581
        private boolean lastModifiedHeaderSet;
582
583
        private PrintWriter printWriter;
584
585
        private HttpServletRequest request;
586
587
        private ServletOutputStream servletOutputStream;
588
589
        /**
590
         * Indicates whether calls to write methods (<tt>write(...)</tt>,
591
         * <tt>print(...)</tt>, etc) of the response body have been called or
592
         * not.
593
         */
594
        private boolean writeResponseBodyStarted;
595
596
        public XHttpServletResponse(HttpServletRequest request, HttpServletResponse response) {
597
            super(response);
598
            this.request = request;
599
        }
600
601
        @Override
602
        public void addDateHeader(String name, long date) {
603
            super.addDateHeader(name, date);
604
            if (!lastModifiedHeaderSet) {
605
                this.lastModifiedHeader = date;
606
                this.lastModifiedHeaderSet = true;
607
            }
608
        }
609
610
        @Override
611
        public void addHeader(String name, String value) {
612
            super.addHeader(name, value);
613
            if (HEADER_CACHE_CONTROL.equalsIgnoreCase(name) && cacheControlHeader == null) {
614
                cacheControlHeader = value;
615
            }
616
        }
617
618
        public String getCacheControlHeader() {
619
            return cacheControlHeader;
620
        }
621
622
        public long getLastModifiedHeader() {
623
            return lastModifiedHeader;
624
        }
625
626
        @Override
627
        public ServletOutputStream getOutputStream() throws IOException {
628
            if (servletOutputStream == null) {
629
                servletOutputStream = new XServletOutputStream(super.getOutputStream(), request, this);
630
            }
631
            return servletOutputStream;
632
        }
633
634
        @Override
635
        public PrintWriter getWriter() throws IOException {
636
            if (printWriter == null) {
637
                printWriter = new XPrintWriter(super.getWriter(), request, this);
638
            }
639
            return printWriter;
640
        }
641
642
        public boolean isLastModifiedHeaderSet() {
643
            return lastModifiedHeaderSet;
644
        }
645
646
        public boolean isWriteResponseBodyStarted() {
647
            return writeResponseBodyStarted;
648
        }
649
650
        @Override
651
        public void reset() {
652
            super.reset();
653
            this.lastModifiedHeader = 0;
654
            this.lastModifiedHeaderSet = false;
655
            this.cacheControlHeader = null;
656
        }
657
658
        @Override
659
        public void setDateHeader(String name, long date) {
660
            super.setDateHeader(name, date);
661
            if (HEADER_LAST_MODIFIED.equalsIgnoreCase(name)) {
662
                this.lastModifiedHeader = date;
663
                this.lastModifiedHeaderSet = true;
664
            }
665
        }
666
667
        @Override
668
        public void setHeader(String name, String value) {
669
            super.setHeader(name, value);
670
            if (HEADER_CACHE_CONTROL.equalsIgnoreCase(name)) {
671
                this.cacheControlHeader = value;
672
            }
673
        }
674
675
        public void setWriteResponseBodyStarted(boolean writeResponseBodyStarted) {
676
            this.writeResponseBodyStarted = writeResponseBodyStarted;
677
        }
678
    }
679
680
    /**
681
     * Wrapping extension of {@link PrintWriter} to trap the
682
     * "Start Write Response Body" event.
683
     */
684
    public class XPrintWriter extends PrintWriter {
685
        private PrintWriter out;
686
687
        private HttpServletRequest request;
688
689
        private XHttpServletResponse response;
690
691
        public XPrintWriter(PrintWriter out, HttpServletRequest request, XHttpServletResponse response) {
692
            super(out);
693
            this.out = out;
694
            this.request = request;
695
            this.response = response;
696
        }
697
698
        public PrintWriter append(char c) {
699
            fireBeforeWriteResponseBodyEvent();
700
            return out.append(c);
701
        }
702
703
        public PrintWriter append(CharSequence csq) {
704
            fireBeforeWriteResponseBodyEvent();
705
            return out.append(csq);
706
        }
707
708
        public PrintWriter append(CharSequence csq, int start, int end) {
709
            fireBeforeWriteResponseBodyEvent();
710
            return out.append(csq, start, end);
711
        }
712
713
        public void close() {
714
            fireBeforeWriteResponseBodyEvent();
715
            out.close();
716
        }
717
718
        private void fireBeforeWriteResponseBodyEvent() {
719
            if (!this.response.isWriteResponseBodyStarted()) {
720
                this.response.setWriteResponseBodyStarted(true);
721
                onBeforeWriteResponseBody(request, response);
722
            }
723
        }
724
725
        public void flush() {
726
            fireBeforeWriteResponseBodyEvent();
727
            out.flush();
728
        }
729
730
        public void print(boolean b) {
731
            fireBeforeWriteResponseBodyEvent();
732
            out.print(b);
733
        }
734
735
        public void print(char c) {
736
            fireBeforeWriteResponseBodyEvent();
737
            out.print(c);
738
        }
739
740
        public void print(char[] s) {
741
            fireBeforeWriteResponseBodyEvent();
742
            out.print(s);
743
        }
744
745
        public void print(double d) {
746
            fireBeforeWriteResponseBodyEvent();
747
            out.print(d);
748
        }
749
750
        public void print(float f) {
751
            fireBeforeWriteResponseBodyEvent();
752
            out.print(f);
753
        }
754
755
        public void print(int i) {
756
            fireBeforeWriteResponseBodyEvent();
757
            out.print(i);
758
        }
759
760
        public void print(long l) {
761
            fireBeforeWriteResponseBodyEvent();
762
            out.print(l);
763
        }
764
765
        public void print(Object obj) {
766
            fireBeforeWriteResponseBodyEvent();
767
            out.print(obj);
768
        }
769
770
        public void print(String s) {
771
            fireBeforeWriteResponseBodyEvent();
772
            out.print(s);
773
        }
774
775
        public PrintWriter printf(Locale l, String format, Object... args) {
776
            fireBeforeWriteResponseBodyEvent();
777
            return out.printf(l, format, args);
778
        }
779
780
        public PrintWriter printf(String format, Object... args) {
781
            fireBeforeWriteResponseBodyEvent();
782
            return out.printf(format, args);
783
        }
784
785
        public void println() {
786
            fireBeforeWriteResponseBodyEvent();
787
            out.println();
788
        }
789
790
        public void println(boolean x) {
791
            fireBeforeWriteResponseBodyEvent();
792
            out.println(x);
793
        }
794
795
        public void println(char x) {
796
            fireBeforeWriteResponseBodyEvent();
797
            out.println(x);
798
        }
799
800
        public void println(char[] x) {
801
            fireBeforeWriteResponseBodyEvent();
802
            out.println(x);
803
        }
804
805
        public void println(double x) {
806
            fireBeforeWriteResponseBodyEvent();
807
            out.println(x);
808
        }
809
810
        public void println(float x) {
811
            fireBeforeWriteResponseBodyEvent();
812
            out.println(x);
813
        }
814
815
        public void println(int x) {
816
            fireBeforeWriteResponseBodyEvent();
817
            out.println(x);
818
        }
819
820
        public void println(long x) {
821
            fireBeforeWriteResponseBodyEvent();
822
            out.println(x);
823
        }
824
825
        public void println(Object x) {
826
            fireBeforeWriteResponseBodyEvent();
827
            out.println(x);
828
        }
829
830
        public void println(String x) {
831
            fireBeforeWriteResponseBodyEvent();
832
            out.println(x);
833
        }
834
835
        public void write(char[] buf) {
836
            fireBeforeWriteResponseBodyEvent();
837
            out.write(buf);
838
        }
839
840
        public void write(char[] buf, int off, int len) {
841
            fireBeforeWriteResponseBodyEvent();
842
            out.write(buf, off, len);
843
        }
844
845
        public void write(int c) {
846
            fireBeforeWriteResponseBodyEvent();
847
            out.write(c);
848
        }
849
850
        public void write(String s) {
851
            fireBeforeWriteResponseBodyEvent();
852
            out.write(s);
853
        }
854
855
        public void write(String s, int off, int len) {
856
            fireBeforeWriteResponseBodyEvent();
857
            out.write(s, off, len);
858
        }
859
860
    }
861
862
    /**
863
     * Wrapping extension of {@link ServletOutputStream} to trap the
864
     * "Start Write Response Body" event.
865
     */
866
    public class XServletOutputStream extends ServletOutputStream {
867
868
        private HttpServletRequest request;
869
870
        private XHttpServletResponse response;
871
872
        private ServletOutputStream servletOutputStream;
873
874
        public XServletOutputStream(ServletOutputStream servletOutputStream, HttpServletRequest request, XHttpServletResponse response) {
875
            super();
876
            this.servletOutputStream = servletOutputStream;
877
            this.response = response;
878
            this.request = request;
879
        }
880
881
        public void close() throws IOException {
882
            fireOnBeforeWriteResponseBodyEvent();
883
            servletOutputStream.close();
884
        }
885
886
        private void fireOnBeforeWriteResponseBodyEvent() {
887
            if (!this.response.isWriteResponseBodyStarted()) {
888
                this.response.setWriteResponseBodyStarted(true);
889
                onBeforeWriteResponseBody(request, response);
890
            }
891
        }
892
893
        public void flush() throws IOException {
894
            fireOnBeforeWriteResponseBodyEvent();
895
            servletOutputStream.flush();
896
        }
897
898
        public void print(boolean b) throws IOException {
899
            fireOnBeforeWriteResponseBodyEvent();
900
            servletOutputStream.print(b);
901
        }
902
903
        public void print(char c) throws IOException {
904
            fireOnBeforeWriteResponseBodyEvent();
905
            servletOutputStream.print(c);
906
        }
907
908
        public void print(double d) throws IOException {
909
            fireOnBeforeWriteResponseBodyEvent();
910
            servletOutputStream.print(d);
911
        }
912
913
        public void print(float f) throws IOException {
914
            fireOnBeforeWriteResponseBodyEvent();
915
            servletOutputStream.print(f);
916
        }
917
918
        public void print(int i) throws IOException {
919
            fireOnBeforeWriteResponseBodyEvent();
920
            servletOutputStream.print(i);
921
        }
922
923
        public void print(long l) throws IOException {
924
            fireOnBeforeWriteResponseBodyEvent();
925
            servletOutputStream.print(l);
926
        }
927
928
        public void print(String s) throws IOException {
929
            fireOnBeforeWriteResponseBodyEvent();
930
            servletOutputStream.print(s);
931
        }
932
933
        public void println() throws IOException {
934
            fireOnBeforeWriteResponseBodyEvent();
935
            servletOutputStream.println();
936
        }
937
938
        public void println(boolean b) throws IOException {
939
            fireOnBeforeWriteResponseBodyEvent();
940
            servletOutputStream.println(b);
941
        }
942
943
        public void println(char c) throws IOException {
944
            fireOnBeforeWriteResponseBodyEvent();
945
            servletOutputStream.println(c);
946
        }
947
948
        public void println(double d) throws IOException {
949
            fireOnBeforeWriteResponseBodyEvent();
950
            servletOutputStream.println(d);
951
        }
952
953
        public void println(float f) throws IOException {
954
            fireOnBeforeWriteResponseBodyEvent();
955
            servletOutputStream.println(f);
956
        }
957
958
        public void println(int i) throws IOException {
959
            fireOnBeforeWriteResponseBodyEvent();
960
            servletOutputStream.println(i);
961
        }
962
963
        public void println(long l) throws IOException {
964
            fireOnBeforeWriteResponseBodyEvent();
965
            servletOutputStream.println(l);
966
        }
967
968
        public void println(String s) throws IOException {
969
            fireOnBeforeWriteResponseBodyEvent();
970
            servletOutputStream.println(s);
971
        }
972
973
        public void write(byte[] b) throws IOException {
974
            fireOnBeforeWriteResponseBodyEvent();
975
            servletOutputStream.write(b);
976
        }
977
978
        public void write(byte[] b, int off, int len) throws IOException {
979
            fireOnBeforeWriteResponseBodyEvent();
980
            servletOutputStream.write(b, off, len);
981
        }
982
983
        public void write(int b) throws IOException {
984
            fireOnBeforeWriteResponseBodyEvent();
985
            servletOutputStream.write(b);
986
        }
987
988
    }
989
990
    /**
991
     * {@link Pattern} for a comma delimited string that support whitespace
992
     * characters
993
     */
994
    private static final Pattern commaSeparatedValuesPattern = Pattern.compile("\\s*,\\s*");
995
996
    private static final String HEADER_CACHE_CONTROL = "Cache-Control";
997
998
    private static final String HEADER_EXPIRES = "Expires";
999
1000
    private static final String HEADER_LAST_MODIFIED = "Last-Modified";
1001
1002
    private static final Log log = LogFactory.getLog(ExpiresFilter.class);
1003
1004
    private static final String PARAMETER_EXPIRES_ACTIVE = "ExpiresActive";
1005
1006
    private static final String PARAMETER_EXPIRES_BY_TYPE = "ExpiresByType";
1007
1008
    private static final String PARAMETER_EXPIRES_DEFAULT = "ExpiresDefault";
1009
1010
    private static final String PARAMETER_EXPIRES_EXCLUDED_RESPONSE_STATUS_CODES = "ExpiresExcludedResponseStatusCodes";
1011
1012
    /**
1013
     * Convert a comma delimited list of numbers into an <tt>int[]</tt>.
1014
     * 
1015
     * @param commaDelimitedInts
1016
     *            can be <code>null</code>
1017
     * @return never <code>null</code> array
1018
     */
1019
    protected static int[] commaDelimitedListToIntArray(String commaDelimitedInts) {
1020
        String[] intsAsStrings = commaDelimitedListToStringArray(commaDelimitedInts);
1021
        int[] ints = new int[intsAsStrings.length];
1022
        for (int i = 0; i < intsAsStrings.length; i++) {
1023
            String intAsString = intsAsStrings[i];
1024
            try {
1025
                ints[i] = Integer.parseInt(intAsString);
1026
            } catch (NumberFormatException e) {
1027
                throw new RuntimeException("Exception parsing number '" + i + "' (zero based) of comma delimited list '"
1028
                        + commaDelimitedInts + "'");
1029
            }
1030
        }
1031
        return ints;
1032
    }
1033
1034
    /**
1035
     * Convert a given comma delimited list of strings into an array of String
1036
     * 
1037
     * @return array of patterns (non <code>null</code>)
1038
     */
1039
    protected static String[] commaDelimitedListToStringArray(String commaDelimitedStrings) {
1040
        return (commaDelimitedStrings == null || commaDelimitedStrings.length() == 0) ? new String[0] : commaSeparatedValuesPattern
1041
                .split(commaDelimitedStrings);
1042
    }
1043
1044
    /**
1045
     * Return <code>true</code> if the given <code>str</code> contains the given
1046
     * <code>searchStr</code>.
1047
     */
1048
    protected static boolean contains(String str, String searchStr) {
1049
        if (str == null || searchStr == null) {
1050
            return false;
1051
        }
1052
        return str.indexOf(searchStr) >= 0;
1053
    }
1054
1055
    /**
1056
     * Convert an array of ints into a comma delimited string
1057
     */
1058
    protected static String intsToCommaDelimitedString(int[] ints) {
1059
        if (ints == null) {
1060
            return "";
1061
        }
1062
1063
        StringBuilder result = new StringBuilder();
1064
1065
        for (int i = 0; i < ints.length; i++) {
1066
            result.append(ints[i]);
1067
            if (i < (ints.length - 1)) {
1068
                result.append(", ");
1069
            }
1070
        }
1071
        return result.toString();
1072
    }
1073
1074
    /**
1075
     * Return <code>true</code> if the given <code>str</code> is
1076
     * <code>null</code> or has a zero characters length.
1077
     */
1078
    protected static boolean isEmpty(String str) {
1079
        return str == null || str.length() == 0;
1080
    }
1081
1082
    /**
1083
     * Return <code>true</code> if the given <code>str</code> has at least one
1084
     * character (can be a withespace).
1085
     */
1086
    protected static boolean isNotEmpty(String str) {
1087
        return !isEmpty(str);
1088
    }
1089
1090
    /**
1091
     * Return <code>true</code> if the given <code>string</code> starts with the
1092
     * given <code>prefix</code> ignoring case.
1093
     * 
1094
     * @param string
1095
     *            can be <code>null</code>
1096
     * @param prefix
1097
     *            can be <code>null</code>
1098
     */
1099
    protected static boolean startsWithIgnoreCase(String string, String prefix) {
1100
        if (string == null || prefix == null) {
1101
            return string == null && prefix == null;
1102
        }
1103
        if (prefix.length() > string.length()) {
1104
            return false;
1105
        }
1106
1107
        return string.regionMatches(true, 0, prefix, 0, prefix.length());
1108
    }
1109
1110
    /**
1111
     * Return the subset of the given <code>str</code> that is before the first
1112
     * occurence of the given <code>separator</code>. Return <code>null</code>
1113
     * if the given <code>str</code> or the given <code>separator</code> is
1114
     * null. Return and empty string if the <code>separator</code> is empty.
1115
     * 
1116
     * @param str
1117
     *            can be <code>null</code>
1118
     * @param separator
1119
     *            can be <code>null</code>
1120
     * @return
1121
     */
1122
    protected static String substringBefore(String str, String separator) {
1123
        if (str == null || str.isEmpty() || separator == null) {
1124
            return null;
1125
        }
1126
1127
        if (separator.isEmpty()) {
1128
            return "";
1129
        }
1130
1131
        int separatorIndex = str.indexOf(separator);
1132
        if (separatorIndex == -1) {
1133
            return str;
1134
        }
1135
        return str.substring(0, separatorIndex);
1136
    }
1137
1138
    /**
1139
     * @see #isActive()
1140
     */
1141
    private boolean active = true;
1142
1143
    /**
1144
     * Default Expires configuration.
1145
     */
1146
    private ExpiresConfiguration defaultExpiresConfiguration;
1147
1148
    /**
1149
     * list of response status code for which the {@link ExpiresFilter} will not
1150
     * generate expiration headers.
1151
     */
1152
    private int[] excludedResponseStatusCodes = new int[] { HttpServletResponse.SC_NOT_MODIFIED };
1153
1154
    /**
1155
     * Expires configuration by content type. Visible for test.
1156
     */
1157
    private Map<String, ExpiresConfiguration> expiresConfigurationByContentType = new LinkedHashMap<String, ExpiresConfiguration>();
1158
1159
    public void destroy() {
1160
1161
    }
1162
1163
    public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException {
1164
        if (request instanceof HttpServletRequest && response instanceof HttpServletResponse) {
1165
            HttpServletRequest httpRequest = (HttpServletRequest) request;
1166
            HttpServletResponse httpResponse = (HttpServletResponse) response;
1167
1168
            if (response.isCommitted()) {
1169
                if (log.isDebugEnabled()) {
1170
                    log.debug(sm.getString("expiresFilter.responseAlreadyCommited", httpRequest.getRequestURL()));
1171
                }
1172
                chain.doFilter(request, response);
1173
            } else if (active) {
1174
                XHttpServletResponse xResponse = new XHttpServletResponse(httpRequest, httpResponse);
1175
                chain.doFilter(request, xResponse);
1176
                if (!xResponse.isWriteResponseBodyStarted()) {
1177
                    // Empty response, manually trigger
1178
                    // onBeforeWriteResponseBody()
1179
                    onBeforeWriteResponseBody(httpRequest, xResponse);
1180
                }
1181
            } else {
1182
                if (log.isDebugEnabled()) {
1183
                    log.debug(sm.getString("expiresFilter.filterNotActive", httpRequest.getRequestURL()));
1184
                }
1185
                chain.doFilter(request, response);
1186
            }
1187
        } else {
1188
            chain.doFilter(request, response);
1189
        }
1190
    }
1191
1192
    public ExpiresConfiguration getDefaultExpiresConfiguration() {
1193
        return defaultExpiresConfiguration;
1194
    }
1195
1196
    public String getExcludedResponseStatusCodes() {
1197
        return intsToCommaDelimitedString(excludedResponseStatusCodes);
1198
    }
1199
1200
    public int[] getExcludedResponseStatusCodesAsInts() {
1201
        return excludedResponseStatusCodes;
1202
    }
1203
1204
    /**
1205
     * <p>
1206
     * Returns the expiration date of the given {@link XHttpServletResponse} or
1207
     * <code>null</code> if no expiration date has been configured for the
1208
     * declared content type.
1209
     * </p>
1210
     * <p>
1211
     * <code>protected</code> for extension.
1212
     * </p>
1213
     * 
1214
     * @see HttpServletResponse#getContentType()
1215
     */
1216
    protected Date getExpirationDate(HttpServletRequest request, XHttpServletResponse response) {
1217
        String contentType = response.getContentType();
1218
1219
        // lookup exact content-type match (e.g.
1220
        // "text/html; charset=iso-8859-1")
1221
        ExpiresConfiguration configuration = expiresConfigurationByContentType.get(contentType);
1222
        if (configuration != null) {
1223
            Date result = getExpirationDate(configuration, request, response);
1224
            if (log.isErrorEnabled()) {
1225
                log.error(sm.getString("expiresFilter.useMatchingConfiguration", configuration, contentType, contentType, result));
1226
            }
1227
            return result;
1228
        }
1229
1230
        if (contains(contentType, ";")) {
1231
            // lookup content-type without charset match (e.g. "text/html")
1232
            String contentTypeWithoutCharset = substringBefore(contentType, ";").trim();
1233
            configuration = expiresConfigurationByContentType.get(contentTypeWithoutCharset);
1234
1235
            if (configuration != null) {
1236
                Date result = getExpirationDate(configuration, request, response);
1237
                if (log.isErrorEnabled()) {
1238
                    log.error(sm.getString("expiresFilter.useMatchingConfiguration", configuration, contentTypeWithoutCharset, contentType,
1239
                            result));
1240
                }
1241
                return result;
1242
            }
1243
        }
1244
1245
        if (contains(contentType, "/")) {
1246
            // lookup major type match (e.g. "text")
1247
            String majorType = substringBefore(contentType, "/");
1248
            configuration = expiresConfigurationByContentType.get(majorType);
1249
            if (configuration != null) {
1250
                Date result = getExpirationDate(configuration, request, response);
1251
                if (log.isErrorEnabled()) {
1252
                    log.error(sm.getString("expiresFilter.useMatchingConfiguration", configuration, majorType, contentType, result));
1253
                }
1254
                return result;
1255
            }
1256
        }
1257
1258
        if (defaultExpiresConfiguration != null) {
1259
            Date result = getExpirationDate(defaultExpiresConfiguration, request, response);
1260
            if (log.isErrorEnabled()) {
1261
                log.error(sm.getString("expiresFilter.useDefaultConfiguration", defaultExpiresConfiguration, contentType, result));
1262
            }
1263
            return result;
1264
        }
1265
1266
        if (log.isErrorEnabled()) {
1267
            log.error(sm.getString("expiresFilter.noExpirationConfiguredForContentType", contentType));
1268
        }
1269
        return null;
1270
    }
1271
1272
    /**
1273
     * <p>
1274
     * Returns the expiration date of the given {@link ExpiresConfiguration},
1275
     * {@link HttpServletRequest} and {@link XHttpServletResponse}.
1276
     * </p>
1277
     * <p>
1278
     * <code>protected</code> for extension.
1279
     * </p>
1280
     */
1281
    protected Date getExpirationDate(ExpiresConfiguration configuration, HttpServletRequest request, XHttpServletResponse response) {
1282
        Calendar calendar;
1283
        switch (configuration.getStartingPoint()) {
1284
        case ACCESS_TIME:
1285
            calendar = GregorianCalendar.getInstance();
1286
            break;
1287
        case LAST_MODIFICATION_TIME:
1288
            if (response.isLastModifiedHeaderSet()) {
1289
                try {
1290
                    long lastModified = response.getLastModifiedHeader();
1291
                    calendar = GregorianCalendar.getInstance();
1292
                    calendar.setTimeInMillis(lastModified);
1293
                } catch (NumberFormatException e) {
1294
                    // default to now
1295
                    calendar = GregorianCalendar.getInstance();
1296
                }
1297
            } else {
1298
                // Last-Modified header not found, use now
1299
                calendar = GregorianCalendar.getInstance();
1300
            }
1301
            break;
1302
        default:
1303
            throw new IllegalStateException(sm.getString("expiresFilter.unsupportedStartingPoint", configuration.getStartingPoint()));
1304
        }
1305
        for (Duration duration : configuration.getDurations()) {
1306
            calendar.add(duration.getUnit().getCalendardField(), duration.getAmount());
1307
        }
1308
1309
        return calendar.getTime();
1310
    }
1311
1312
    public Map<String, ExpiresConfiguration> getExpiresConfigurationByContentType() {
1313
        return expiresConfigurationByContentType;
1314
    }
1315
1316
    @Override
1317
    protected Log getLogger() {
1318
        return log;
1319
    }
1320
1321
    public void init(FilterConfig filterConfig) throws ServletException {
1322
        for (Enumeration<String> names = filterConfig.getInitParameterNames(); names.hasMoreElements();) {
1323
            String name = names.nextElement();
1324
            String value = filterConfig.getInitParameter(name);
1325
1326
            try {
1327
                if (name.startsWith(PARAMETER_EXPIRES_BY_TYPE)) {
1328
                    String contentType = name.substring(PARAMETER_EXPIRES_BY_TYPE.length()).trim();
1329
                    ExpiresConfiguration expiresConfiguration = parseExpiresConfiguration(value);
1330
                    this.expiresConfigurationByContentType.put(contentType, expiresConfiguration);
1331
                } else if (name.equalsIgnoreCase(PARAMETER_EXPIRES_DEFAULT)) {
1332
                    ExpiresConfiguration expiresConfiguration = parseExpiresConfiguration(value);
1333
                    this.defaultExpiresConfiguration = expiresConfiguration;
1334
                } else if (name.equalsIgnoreCase(PARAMETER_EXPIRES_ACTIVE)) {
1335
                    this.active = "On".equalsIgnoreCase(value) || Boolean.valueOf(value);
1336
                } else if (name.equalsIgnoreCase(PARAMETER_EXPIRES_EXCLUDED_RESPONSE_STATUS_CODES)) {
1337
                    this.excludedResponseStatusCodes = commaDelimitedListToIntArray(value);
1338
                } else {
1339
                    log.warn(sm.getString("expiresFilter.unknownParameterIgnored", name, value));
1340
                }
1341
            } catch (RuntimeException e) {
1342
                throw new ServletException(sm.getString("expiresFilter.exceptionProcessingParameter", name, value), e);
1343
            }
1344
        }
1345
1346
        log.debug(sm.getString("expiresFilter.filterInitialized", this.toString()));
1347
    }
1348
1349
    /**
1350
     * Indicates that the filter is active. If <code>false</code>, the filter is
1351
     * pass-through. Default is <code>true</code>.
1352
     */
1353
    public boolean isActive() {
1354
        return active;
1355
    }
1356
1357
    /**
1358
     * 
1359
     * <p>
1360
     * <code>protected</code> for extension.
1361
     * </p>
1362
     */
1363
    protected boolean isEligibleToExpirationHeaderGeneration(HttpServletRequest request, XHttpServletResponse response) {
1364
        boolean expirationHeaderHasBeenSet = response.containsHeader(HEADER_EXPIRES)
1365
                || contains(response.getCacheControlHeader(), "max-age");
1366
        if (expirationHeaderHasBeenSet) {
1367
            if (log.isDebugEnabled()) {
1368
                log.debug(sm.getString("expiresFilter.expirationHeaderAlreadyDefined", request.getRequestURI(), response.getStatus(),
1369
                        response.getContentType()));
1370
            }
1371
            return false;
1372
        }
1373
1374
        for (int skippedStatusCode : this.excludedResponseStatusCodes) {
1375
            if (response.getStatus() == skippedStatusCode) {
1376
                if (log.isDebugEnabled()) {
1377
                    log.debug(sm.getString("expiresFilter.skippedStatusCode", request.getRequestURI(), response.getStatus(), response
1378
                            .getContentType()));
1379
                }
1380
                return false;
1381
            }
1382
        }
1383
1384
        return true;
1385
    }
1386
1387
    /**
1388
     * <p>
1389
     * If no expiration header has been set by the servlet and an expiration has
1390
     * been defined in the {@link ExpiresFilter} configuration, sets the '
1391
     * <tt>Expires</tt>' header and the attribute '<tt>max-age</tt>' of the '
1392
     * <tt>Cache-Control</tt>' header.
1393
     * </p>
1394
     * <p>
1395
     * Must be called on the "Start Write Response Body" event.
1396
     * </p>
1397
     * <p>
1398
     * Invocations to <tt>Logger.debug(...)</tt> are guarded by
1399
     * {@link Logger#isDebugEnabled()} because
1400
     * {@link HttpServletRequest#getRequestURI()} and
1401
     * {@link HttpServletResponse#getContentType()} costs <tt>String</tt>
1402
     * objects instantiations (as of Tomcat 7).
1403
     * </p>
1404
     */
1405
    public void onBeforeWriteResponseBody(HttpServletRequest request, XHttpServletResponse response) {
1406
1407
        if (!isEligibleToExpirationHeaderGeneration(request, response)) {
1408
            return;
1409
        }
1410
1411
        Date expirationDate = getExpirationDate(request, response);
1412
        if (expirationDate == null) {
1413
            if (log.isDebugEnabled()) {
1414
                log.debug(sm.getString("expiresFilter.noExpirationConfigured", request.getRequestURI(), response.getStatus(), response
1415
                        .getContentType()));
1416
            }
1417
        } else {
1418
            if (log.isDebugEnabled()) {
1419
                log.debug(sm.getString("expiresFilter.setExpirationDate", request.getRequestURI(), response.getStatus(), response
1420
                        .getContentType(), expirationDate));
1421
            }
1422
1423
            String maxAgeDirective = "max-age=" + ((expirationDate.getTime() - System.currentTimeMillis()) / 1000);
1424
1425
            String cacheControlHeader = response.getCacheControlHeader();
1426
            String newCacheControlHeader = (cacheControlHeader == null) ? maxAgeDirective : cacheControlHeader + ", " + maxAgeDirective;
1427
            response.setHeader(HEADER_CACHE_CONTROL, newCacheControlHeader);
1428
            response.setDateHeader(HEADER_EXPIRES, expirationDate.getTime());
1429
        }
1430
1431
    }
1432
1433
    /**
1434
     * Parse configuration lines like '
1435
     * <tt>access plus 1 month 15 days 2 hours</tt>' or '
1436
     * <tt>modification 1 day 2 hours 5 seconds</tt>'
1437
     * 
1438
     * @param line
1439
     */
1440
    protected ExpiresConfiguration parseExpiresConfiguration(String line) {
1441
        line = line.trim();
1442
1443
        StringTokenizer tokenizer = new StringTokenizer(line, " ");
1444
1445
        String currentToken;
1446
1447
        try {
1448
            currentToken = tokenizer.nextToken();
1449
        } catch (NoSuchElementException e) {
1450
            throw new IllegalStateException(sm.getString("expiresFilter.startingPointNotFound", line));
1451
        }
1452
1453
        StartingPoint startingPoint;
1454
        if ("access".equalsIgnoreCase(currentToken) || "now".equalsIgnoreCase(currentToken)) {
1455
            startingPoint = StartingPoint.ACCESS_TIME;
1456
        } else if ("modification".equalsIgnoreCase(currentToken)) {
1457
            startingPoint = StartingPoint.LAST_MODIFICATION_TIME;
1458
        } else if (!tokenizer.hasMoreTokens() && startsWithIgnoreCase(currentToken, "a")) {
1459
            startingPoint = StartingPoint.ACCESS_TIME;
1460
            // trick : convert duration configuration from old to new style
1461
            tokenizer = new StringTokenizer(currentToken.substring(1) + " seconds", " ");
1462
        } else if (!tokenizer.hasMoreTokens() && startsWithIgnoreCase(currentToken, "m")) {
1463
            startingPoint = StartingPoint.LAST_MODIFICATION_TIME;
1464
            // trick : convert duration configuration from old to new style
1465
            tokenizer = new StringTokenizer(currentToken.substring(1) + " seconds", " ");
1466
        } else {
1467
            throw new IllegalStateException(sm.getString("expiresFilter.startingPointInvalid", currentToken, line));
1468
        }
1469
1470
        try {
1471
            currentToken = tokenizer.nextToken();
1472
        } catch (NoSuchElementException e) {
1473
            throw new IllegalStateException(sm.getString("Duration not found in directive '{}'", line));
1474
        }
1475
1476
        if ("plus".equalsIgnoreCase(currentToken)) {
1477
            // skip
1478
            try {
1479
                currentToken = tokenizer.nextToken();
1480
            } catch (NoSuchElementException e) {
1481
                throw new IllegalStateException(sm.getString("Duration not found in directive '{}'", line));
1482
            }
1483
        }
1484
1485
        List<Duration> durations = new ArrayList<Duration>();
1486
1487
        while (currentToken != null) {
1488
            int amount;
1489
            try {
1490
                amount = Integer.parseInt(currentToken);
1491
            } catch (NumberFormatException e) {
1492
                throw new IllegalStateException(sm.getString("Invalid duration (number) '{}' in directive '{}'", currentToken, line));
1493
            }
1494
1495
            try {
1496
                currentToken = tokenizer.nextToken();
1497
            } catch (NoSuchElementException e) {
1498
                throw new IllegalStateException(sm.getString("Duration unit not found after amount {} in directive '{}'", amount, line));
1499
            }
1500
            DurationUnit durationUnit;
1501
            if ("years".equalsIgnoreCase(currentToken)) {
1502
                durationUnit = DurationUnit.YEAR;
1503
            } else if ("month".equalsIgnoreCase(currentToken) || "months".equalsIgnoreCase(currentToken)) {
1504
                durationUnit = DurationUnit.MONTH;
1505
            } else if ("week".equalsIgnoreCase(currentToken) || "weeks".equalsIgnoreCase(currentToken)) {
1506
                durationUnit = DurationUnit.WEEK;
1507
            } else if ("day".equalsIgnoreCase(currentToken) || "days".equalsIgnoreCase(currentToken)) {
1508
                durationUnit = DurationUnit.DAY;
1509
            } else if ("hour".equalsIgnoreCase(currentToken) || "hours".equalsIgnoreCase(currentToken)) {
1510
                durationUnit = DurationUnit.HOUR;
1511
            } else if ("minute".equalsIgnoreCase(currentToken) || "minutes".equalsIgnoreCase(currentToken)) {
1512
                durationUnit = DurationUnit.MINUTE;
1513
            } else if ("second".equalsIgnoreCase(currentToken) || "seconds".equalsIgnoreCase(currentToken)) {
1514
                durationUnit = DurationUnit.SECOND;
1515
            } else {
1516
                throw new IllegalStateException(sm.getString(
1517
                        "Invalid duration unit (years|months|weeks|days|hours|minutes|seconds) '{}' in directive '{}'", currentToken, line));
1518
            }
1519
1520
            Duration duration = new Duration(amount, durationUnit);
1521
            durations.add(duration);
1522
1523
            if (tokenizer.hasMoreTokens()) {
1524
                currentToken = tokenizer.nextToken();
1525
            } else {
1526
                currentToken = null;
1527
            }
1528
        }
1529
1530
        return new ExpiresConfiguration(startingPoint, durations);
1531
    }
1532
1533
    public void setActive(boolean active) {
1534
        this.active = active;
1535
    }
1536
1537
    public void setDefaultExpiresConfiguration(ExpiresConfiguration defaultExpiresConfiguration) {
1538
        this.defaultExpiresConfiguration = defaultExpiresConfiguration;
1539
    }
1540
1541
    public void setExcludedResponseStatusCodes(int[] excludedResponseStatusCodes) {
1542
        this.excludedResponseStatusCodes = excludedResponseStatusCodes;
1543
    }
1544
1545
    public void setExpiresConfigurationByContentType(Map<String, ExpiresConfiguration> expiresConfigurationByContentType) {
1546
        this.expiresConfigurationByContentType = expiresConfigurationByContentType;
1547
    }
1548
1549
    @Override
1550
    public String toString() {
1551
        return getClass().getSimpleName() + "[active=" + this.active + ", excludedResponseStatusCode=["
1552
                + intsToCommaDelimitedString(this.excludedResponseStatusCodes) + "], default=" + this.defaultExpiresConfiguration
1553
                + ", byType=" + this.expiresConfigurationByContentType + "]";
1554
    }
1555
}
(-)webapps/docs/config/filter.xml (+400 lines)
Lines 86-92 Link Here
86
86
87
</section>
87
</section>
88
88
89
<section name="Expires Filter">
89
90
91
  <subsection name="Introduction">
92
93
    <p>
94
    ExpiresFilter is a Java Servlet API port of <a
95
    href="http://httpd.apache.org/docs/2.2/mod/mod_expires.html">Apache
96
    mod_expires</a>.
97
    This filter controls the setting of the <tt>Expires</tt> HTTP header and the
98
    <tt>max-age</tt> directive of the <tt>Cache-Control</tt> HTTP header in
99
    server responses. The expiration date can set to be relative to either the
100
    time the source file was last modified, or to the time of the client access.
101
    </p>
102
    
103
    <p>
104
    These HTTP headers are an instruction to the client about the document&#x27;s
105
    validity and persistence. If cached, the document may be fetched from the
106
    cache rather than from the source until this time has passed. After that, the
107
    cache copy is considered &quot;expired&quot; and invalid, and a new copy must
108
    be obtained from the source.
109
    </p>
110
    <p>
111
    To modify <tt>Cache-Control</tt> directives other than <tt>max-age</tt> (see
112
    <a href="http://www.w3.org/Protocols/rfc2616/rfc2616-sec14.html#sec14.9" >RFC
113
    2616 section 14.9</a>), you can use other servlet filters or <a
114
    href="http://httpd.apache.org/docs/2.2/mod/mod_headers.html" >Apache Httpd
115
    mod_headers</a> module.
116
    </p>
117
        
118
  </subsection>
119
120
  <subsection name="Basic configuration sample">
121
    <p>
122
    Basic configuration to add '<tt>Expires</tt>' and '<tt>Cache-Control: max-age=</tt>' 
123
    headers to images, css and javascript.
124
    </p>
125
126
    <source>
127
&lt;filter&gt;
128
 &lt;filter-name&gt;ExpiresFilter&lt;/filter-name&gt;
129
 &lt;filter-class&gt;org.apache.catalina.filters.ExpiresFilter&lt;/filter-class&gt;
130
 &lt;init-param&gt;
131
    &lt;param-name&gt;ExpiresByType image&lt;/param-name&gt;
132
    &lt;param-value&gt;access plus 10 minutes&lt;/param-value&gt;
133
 &lt;/init-param&gt;
134
 &lt;init-param&gt;
135
    &lt;param-name&gt;ExpiresByType text/css&lt;/param-name&gt;
136
    &lt;param-value&gt;access plus 10 minutes&lt;/param-value&gt;
137
 &lt;/init-param&gt;
138
 &lt;init-param&gt;
139
    &lt;param-name&gt;ExpiresByType text/javascript&lt;/param-name&gt;
140
    &lt;param-value&gt;access plus 10 minutes&lt;/param-value&gt;
141
 &lt;/init-param&gt;
142
&lt;/filter&gt;
143
...
144
&lt;filter-mapping&gt;
145
 &lt;filter-name&gt;ExpiresFilter&lt;/filter-name&gt;
146
 &lt;url-pattern&gt;/*&lt;/url-pattern&gt;
147
 &lt;dispatcher&gt;REQUEST&lt;/dispatcher&gt;
148
&lt;/filter-mapping&gt;
149
150
    </source>
151
    
152
  </subsection>
153
  
154
  <subsection name="Alternate Syntax">
155
    <p>
156
    The <tt>ExpiresDefault</tt> and <tt>ExpiresByType</tt> directives can also be
157
    defined in a more readable syntax of the form:
158
    </p>
159
    
160
    <source>
161
&lt;init-param&gt;
162
 &lt;param-name&gt;ExpiresDefault&lt;/param-name&gt;
163
 &lt;param-value&gt;&lt;base&gt; [plus] {&lt;num&gt;   &lt;type&gt;}*&lt;/param-value&gt;
164
&lt;/init-param&gt;
165
166
&lt;init-param&gt;
167
 &lt;param-name&gt;ExpiresByType type&lt;/param-name&gt;
168
 &lt;param-value&gt;&lt;base&gt; [plus]   {&lt;num&gt; &lt;type&gt;}*&lt;/param-value&gt;
169
&lt;/init-param&gt;
170
171
&lt;init-param&gt;
172
 &lt;param-name&gt;ExpiresByType type;encoding&lt;/param-name&gt;
173
 &lt;param-value&gt;&lt;base&gt; [plus]   {&lt;num&gt; &lt;type&gt;}*&lt;/param-value&gt;
174
&lt;/init-param&gt;
175
    </source>
176
    <p>
177
    where <tt>&lt;base&gt;</tt> is one of:
178
    <ul>
179
    <li><tt>access</tt></li>
180
    <li><tt>now</tt> (equivalent to &#x27;<tt>access</tt>&#x27;)</li>
181
    <li><tt>modification</tt></li>
182
    </ul>
183
    </p>
184
    <p>
185
    The <tt>plus</tt> keyword is optional. <tt>&lt;num&gt;</tt> should be an
186
    integer value (acceptable to <tt>Integer.parseInt()</tt>), and
187
    <tt>&lt;type&gt;</tt> is one of:
188
    <ul>
189
    <li><tt>years</tt></li>
190
    <li><tt>months</tt></li>
191
    <li><tt>weeks</tt></li>
192
    <li><tt>days</tt></li>
193
    <li><tt>hours</tt></li>
194
    <li><tt>minutes</tt></li>
195
    <li><tt>seconds</tt></li>
196
    </ul>
197
    For example, any of the following directives can be used to make documents
198
    expire 1 month after being accessed, by default:
199
    </p>
200
    
201
    <source>
202
&lt;init-param&gt;
203
 &lt;param-name&gt;ExpiresDefault&lt;/param-name&gt;
204
 &lt;param-value&gt;access plus 1 month&lt;/param-value&gt;
205
&lt;/init-param&gt;
206
207
&lt;init-param&gt;
208
 &lt;param-name&gt;ExpiresDefault&lt;/param-name&gt;
209
 &lt;param-value&gt;access plus 4 weeks&lt;/param-value&gt;
210
&lt;/init-param&gt;
211
212
&lt;init-param&gt;
213
 &lt;param-name&gt;ExpiresDefault&lt;/param-name&gt;
214
 &lt;param-value&gt;access plus 30 days&lt;/param-value&gt;
215
&lt;/init-param&gt;
216
</source>
217
<p>
218
The expiry time can be fine-tuned by adding several &#x27;
219
<tt>&lt;num&gt; &lt;type&gt;</tt>&#x27; clauses:
220
</p>
221
222
<source>
223
&lt;init-param&gt;
224
 &lt;param-name&gt;ExpiresByType text/html&lt;/param-name&gt;
225
 &lt;param-value&gt;access plus 1 month 15   days 2 hours&lt;/param-value&gt;
226
&lt;/init-param&gt;
227
228
&lt;init-param&gt;
229
 &lt;param-name&gt;ExpiresByType image/gif&lt;/param-name&gt;
230
 &lt;param-value&gt;modification plus 5 hours 3   minutes&lt;/param-value&gt;
231
&lt;/init-param&gt;
232
    </source>
233
    <p>
234
    Note that if you use a modification date based setting, the <tt>Expires</tt>
235
    header will <strong>not</strong> be added to content that does not come from
236
    a file on disk. This is due to the fact that there is no modification time
237
    for such content.
238
    </p>  
239
  </subsection>
240
  
241
  <subsection name="Expiration headers generation eligibility">
242
    <p>
243
    A response is eligible to be enriched by <tt>ExpiresFilter</tt> if :
244
    <ol>
245
    <li>no expiration header is defined (<tt>Expires</tt> header or the
246
    <tt>max-age</tt> directive of the <tt>Cache-Control</tt> header),</li>
247
    <li>the response status code is not excluded by the directive
248
    <tt>ExpiresExcludedResponseStatusCodes</tt>,</li>
249
    <li>The <tt>Content-Type</tt> of the response matches one of the types
250
    defined the in <tt>ExpiresByType</tt> directives or the
251
    <tt>ExpiresDefault</tt> directive is defined.</li>
252
    </ol>
253
    </p>
254
    <p>
255
    Note : If <tt>Cache-Control</tt> header contains other directives than
256
    <tt>max-age</tt>, they are concatenated with the <tt>max-age</tt> directive
257
    that is added by the <tt>ExpiresFilter</tt>.
258
    </p>
259
260
  </subsection>
261
  
262
  <subsection name="Expiration configuration selection">
263
    <p>
264
    The expiration configuration if elected according to the following algorithm:
265
    <ol>
266
    <li><tt>ExpiresByType</tt> matching the exact content-type returned by
267
    <tt>HttpServletResponse.getContentType()</tt> possibly including the charset
268
    (e.g. &#x27;<tt>text/xml;charset=UTF-8</tt>&#x27;),</li>
269
    <li><tt>ExpiresByType</tt> matching the content-type without the charset if
270
    <tt>HttpServletResponse.getContentType()</tt> contains a charset (e.g. &#x27;
271
    <tt>text/xml;charset=UTF-8</tt>&#x27; -&gt; &#x27;<tt>text/xml</tt>&#x27;),</li>
272
    <li><tt>ExpiresByType</tt> matching the major type (e.g. substring before
273
    &#x27;<tt>/</tt>&#x27;) of <tt>HttpServletResponse.getContentType()</tt>
274
    (e.g. &#x27;<tt>text/xml;charset=UTF-8</tt>&#x27; -&gt; &#x27;<tt>text</tt>
275
    &#x27;),</li>
276
    <li><tt>ExpiresDefault</tt></li>
277
    </ol>
278
    </p>
279
  </subsection>
280
281
  <subsection name="Filter Class Name">
282
283
    <p>The filter class name for the Expires Filter is
284
    <strong><code>org.apache.catalina.filters.ExpiresFilter</code>
285
    </strong>.</p>
286
287
  </subsection>
288
  
289
  <subsection name="Initialisation parameters">
290
291
    <p>The <strong>Expires Filter</strong> supports the following
292
    initialisation parameters:</p>
293
294
    <attributes>
295
296
      <attribute name="ExpiresActive" required="false">
297
        <p>
298
        This directive enables or disables the generation of the <tt>Expires</tt> and
299
        <tt>Cache-Control</tt> headers by this <tt>ExpiresFilter</tt>. If set to
300
        <tt>Off</tt>, the headers will not be generated for any HTTP response. If set
301
        to <tt>On</tt> or <tt>true</tt>, the headers will be added to served HTTP
302
        responses according to the criteria defined by the
303
        <tt>ExpiresByType &lt;content-type&gt;</tt> and <tt>ExpiresDefault</tt>
304
        directives. Note that this directive does not guarantee that an
305
        <tt>Expires</tt> or <tt>Cache-Control</tt> header will be generated. If the
306
        criteria aren&#x27;t met, no header will be sent, and the effect will be as
307
        though this directive wasn&#x27;t even specified.
308
        </p>
309
        <p>
310
        Default value is <tt>true</tt>.
311
        </p>
312
        
313
        <p>
314
        <i>Sample: enable filter</i>
315
        </p>
316
    
317
        <source>
318
&lt;init-param&gt;
319
 &lt;!-- supports case insensitive &#x27;On&#x27; or &#x27;true&#x27; --&gt;
320
 &lt;param-name&gt;ExpiresActive&lt;/param-name&gt;
321
 &lt;param-value&gt;On&lt;/param-value&gt;
322
&lt;/init-param&gt;
323
         </source>
324
         <p>
325
         <i>Sample: disable filter</i>
326
         </p>
327
    
328
         <source>
329
&lt;init-param&gt;
330
 &lt;!-- supports anything different from case insensitive &#x27;On&#x27; and &#x27;true&#x27; --&gt;
331
 &lt;param-name&gt;ExpiresActive&lt;/param-name&gt;
332
 &lt;param-value&gt;Off&lt;/param-value&gt;
333
&lt;/init-param&gt;
334
         </source>
335
      </attribute>
336
337
      <attribute name="ExpiresExcludedResponseStatusCodes" required="false">
338
         <p>
339
         This directive defines the http response status codes for which the
340
         <tt>ExpiresFilter</tt> will not generate expiration headers. By default, the
341
         <tt>304</tt> status code (&quot;<tt>Not modified</tt>&quot;) is skipped. The
342
         value is a comma separated list of http status codes.
343
         </p>
344
         <p>
345
         This directive is useful to ease usage of <tt>ExpiresDefault</tt> directive.
346
         Indeed, the behavior of <tt>304 Not modified</tt> (which does specify a
347
         <tt>Content-Type</tt> header) combined with <tt>Expires</tt> and
348
         <tt>Cache-Control:max-age=</tt> headers can be unnecessarily tricky to
349
         understand.
350
         </p>
351
         <p>
352
         <i>Sample : exclude response status codes 302, 500 and 503</i>
353
         </p>
354
         
355
         <source>
356
&lt;init-param&gt;
357
 &lt;param-name&gt;ExpiresExcludedResponseStatusCodes&lt;/param-name&gt;
358
 &lt;param-value&gt;302, 500, 503&lt;/param-value&gt;
359
&lt;/init-param&gt;
360
         </source>
361
      </attribute>
362
363
      <attribute name="ExpiresByType &lt;content-type&gt;" required="false">
364
         <p>
365
         This directive defines the value of the <tt>Expires</tt> header and the
366
         <tt>max-age</tt> directive of the <tt>Cache-Control</tt> header generated for
367
         documents of the specified type (<i>e.g.</i>, <tt>text/html</tt>). The second
368
         argument sets the number of seconds that will be added to a base time to
369
         construct the expiration date. The <tt>Cache-Control: max-age</tt> is
370
         calculated by subtracting the request time from the expiration date and
371
         expressing the result in seconds.
372
         </p>
373
         <p>
374
         The base time is either the last modification time of the file, or the time
375
         of the client&#x27;s access to the document. Which should be used is
376
         specified by the <tt>&lt;code&gt;</tt> field; <tt>M</tt> means that the
377
         file&#x27;s last modification time should be used as the base time, and
378
         <tt>A</tt> means the client&#x27;s access time should be used. The duration
379
         is expressed in seconds. <tt>A2592000</tt> stands for
380
         <tt>access plus 30 days</tt> in alternate syntax.
381
         </p>
382
         <p>
383
         The difference in effect is subtle. If <tt>M</tt> (<tt>modification</tt> in
384
         alternate syntax) is used, all current copies of the document in all caches
385
         will expire at the same time, which can be good for something like a weekly
386
         notice that&#x27;s always found at the same URL. If <tt>A</tt> (
387
         <tt>access</tt> or <tt>now</tt> in alternate syntax) is used, the date of
388
         expiration is different for each client; this can be good for image files
389
         that don&#x27;t change very often, particularly for a set of related
390
         documents that all refer to the same images (<i>i.e.</i>, the images will be
391
         accessed repeatedly within a relatively short timespan).
392
         </p>
393
         <p>
394
         <strong>Note:</strong> When the content type includes a charset (e.g. 
395
         <tt>'ExpiresByType text/xml;charset=utf-8'</tt>), Tomcat removes blank chars 
396
         between the '<tt>;</tt>' and the '<tt>charset</tt>' keyword. Due to this, 
397
         configuration of an expiration with a charset must <strong>not</strong> include 
398
         such a space character. 
399
         </p>
400
         <p>
401
         <i>Sample:</i>
402
         </p>
403
         
404
         <source>
405
&lt;init-param&gt;
406
   &lt;param-name&gt;ExpiresByType text/html&lt;/param-name&gt;
407
   &lt;param-value&gt;access plus 1 month 15   days 2 hours&lt;/param-value&gt;
408
&lt;/init-param&gt;
409
 
410
&lt;init-param&gt;
411
   &lt;!-- 2592000 seconds = 30 days --&gt;
412
   &lt;param-name&gt;ExpiresByType image/gif&lt;/param-name&gt;
413
   &lt;param-value&gt;A2592000&lt;/param-value&gt;
414
&lt;/init-param&gt;
415
         </source>
416
         <p>
417
         Note that this directive only has effect if <tt>ExpiresActive On</tt> has
418
         been specified. It overrides, for the specified MIME type <i>only</i>, any
419
         expiration date set by the <tt>ExpiresDefault</tt> directive.
420
         </p>
421
         <p>
422
         You can also specify the expiration time calculation using an alternate
423
         syntax, described earlier in this document.
424
         </p>
425
      </attribute>
426
427
      <attribute name="ExpiresDefault" required="false">
428
         <p>
429
         This directive sets the default algorithm for calculating the
430
         expiration time for all documents in the affected realm. It can be
431
         overridden on a type-by-type basis by the <tt>ExpiresByType</tt> directive. See the
432
         description of that directive for details about the syntax of the
433
         argument, and the "alternate syntax"
434
         description as well.
435
         </p>
436
      </attribute>
437
    </attributes>
438
439
  </subsection>
440
  
441
  <subsection name="Troubleshooting">
442
    <p>
443
    To troubleshoot, enable logging on the
444
    <tt>org.apache.catalina.filters.ExpiresFilter</tt>.
445
    </p>
446
    <p>
447
    Extract of logging.properties
448
    </p>
449
    
450
    <source>
451
org.apache.catalina.filters.ExpiresFilter.level = FINE
452
    </source>
453
    <p>
454
    Sample of initialization log message :
455
    </p>
456
    
457
    <source>
458
Mar 26, 2010 2:01:41 PM org.apache.catalina.filters.ExpiresFilter init
459
FINE: Filter initialized with configuration ExpiresFilter[
460
 active=true, 
461
 excludedResponseStatusCode=[304], 
462
 default=null, 
463
 byType={
464
    image=ExpiresConfiguration[startingPoint=ACCESS_TIME, duration=[10 MINUTE]], 
465
    text/css=ExpiresConfiguration[startingPoint=ACCESS_TIME, duration=[10 MINUTE]], 
466
    text/javascript=ExpiresConfiguration[startingPoint=ACCESS_TIME, duration=[10 MINUTE]]}]
467
    </source>
468
    <p>
469
    Sample of per-request log message where <tt>ExpiresFilter</tt> adds an
470
    expiration date
471
    </p>
472
    
473
    <source>
474
Mar 26, 2010 2:09:47 PM org.apache.catalina.filters.ExpiresFilter onBeforeWriteResponseBody
475
FINE: Request "/tomcat.gif" with response status "200" content-type "image/gif", set expiration date 3/26/10 2:19 PM
476
    </source>
477
    <p>
478
    Sample of per-request log message where <tt>ExpiresFilter</tt> does not add
479
    an expiration date
480
    </p>
481
    
482
    <source>
483
Mar 26, 2010 2:10:27 PM org.apache.catalina.filters.ExpiresFilter onBeforeWriteResponseBody
484
FINE: Request "/docs/config/manager.html" with response status "200" content-type "text/html", no expiration configured
485
    </source>
486
  </subsection>
487
488
</section>
489
90
<section name="Remote Address Filter">
490
<section name="Remote Address Filter">
91
491
92
  <subsection name="Introduction">
492
  <subsection name="Introduction">

Return to bug 48998