View Javadoc
1   /**
2    * This file Copyright (c) 2011-2014 Magnolia International
3    * Ltd.  (http://www.magnolia-cms.com). All rights reserved.
4    *
5    *
6    * This file is dual-licensed under both the Magnolia
7    * Network Agreement and the GNU General Public License.
8    * You may elect to use one or the other of these licenses.
9    *
10   * This file is distributed in the hope that it will be
11   * useful, but AS-IS and WITHOUT ANY WARRANTY; without even the
12   * implied warranty of MERCHANTABILITY or FITNESS FOR A
13   * PARTICULAR PURPOSE, TITLE, or NONINFRINGEMENT.
14   * Redistribution, except as permitted by whichever of the GPL
15   * or MNA you select, is prohibited.
16   *
17   * 1. For the GPL license (GPL), you can redistribute and/or
18   * modify this file under the terms of the GNU General
19   * Public License, Version 3, as published by the Free Software
20   * Foundation.  You should have received a copy of the GNU
21   * General Public License, Version 3 along with this program;
22   * if not, write to the Free Software Foundation, Inc., 51
23   * Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.
24   *
25   * 2. For the Magnolia Network Agreement (MNA), this file
26   * and the accompanying materials are made available under the
27   * terms of the MNA which accompanies this distribution, and
28   * is available at http://www.magnolia-cms.com/mna.html
29   *
30   * Any modifications to this file must keep this entire header
31   * intact.
32   *
33   */
34  package info.magnolia.module.cache.filter;
35  
36  import java.util.ArrayList;
37  import java.util.Date;
38  import java.util.List;
39  
40  import org.apache.commons.httpclient.HeaderElement;
41  import org.apache.commons.httpclient.util.DateParseException;
42  import org.apache.commons.httpclient.util.DateUtil;
43  import org.slf4j.Logger;
44  import org.slf4j.LoggerFactory;
45  
46  
47  /**
48   * Calculates how long a shared cache may hold a response based on its response headers. The most restrictive policy
49   * gets used while respecting the precedence rules dictated by RFC-2616. More specifically:
50   * <ul>
51   *     <li>Cache-Control: s-maxage has precedence over</li>
52   *     <li>Cache-Control: max-age which in turn has precedence over</li>
53   *     <li>Expires: </li>
54   * </ul>
55   *
56   * <p>Given Cache-Control: max-age=5 and Cache-Control: max-age=15 the most restrictive is 5.</p>
57   *
58   * <p>Given Cache-Control: max-age=5 and Cache-Control: s-maxage=15 the latter has precedence resulting in 15.</p>
59   * 
60   * If either of Pragma: no-cache, Cache-Control: no-cache and Cache-Control: private is present the response is
61   * considered to be already-expired.
62   *
63   * Uses Apache HttpClient to parse the headers.
64   *
65   * @version $Id$
66   */
67  public class ResponseExpirationCalculator {
68  
69      private static class HeaderEntry {
70  
71          private final String name;
72          private final Object value;
73          private HeaderElement[] elements;
74  
75          public HeaderEntry(String name, Object value) {
76              this.name = name;
77              this.value = value;
78          }
79  
80          public String getName() {
81              return name;
82          }
83  
84          public Object getValue() {
85              return value;
86          }
87  
88          public HeaderElement[] getElements() {
89              if (elements == null) {
90                  elements = HeaderElement.parseElements(value.toString());
91              }
92              return elements;
93          }
94  
95          public String toExternalFormat() {
96              return (name != null ? name : "") + ": " + (value != null ? value : "");
97          }
98  
99          @Override
100         public String toString() {
101             return toExternalFormat();
102         }
103     }
104 
105     private static final Logger logger = LoggerFactory.getLogger(ResponseExpirationCalculator.class);
106 
107     private final List<HeaderEntry> headers = new ArrayList<HeaderEntry>();
108 
109     public boolean addHeader(String name, Object value) {
110 
111         if ("Expires".equals(name)) {
112             this.headers.add(new HeaderEntry(name, value));
113             return true;
114         }
115 
116         if ("Cache-Control".equals(name)) {
117             this.headers.add(new HeaderEntry(name, value));
118             return true;
119         }
120 
121         if ("Pragma".equals(name)) {
122             HeaderEntry headerEntry = new HeaderEntry(name, value);
123             if (isHeaderWithElement(headerEntry, "Pragma", "no-cache")) {
124                 this.headers.add(headerEntry);
125                 return true;
126             }
127         }
128 
129         return false;
130     }
131 
132     /**
133      * Returns the number of seconds the response can be cached where 0 means that the its already expired and must not
134      * be cached and -1 means that there's no information on how long it can be cached.
135      */
136     public int getMaxAgeInSeconds() {
137 
138         for (HeaderEntry header : headers) {
139 
140             // Pragma no-cache as response header is no specified by HTTP but is widely used
141             if (isHeaderWithElement(header, "Pragma", "no-cache")) {
142                 return 0;
143             }
144 
145             // RFC-2616 Section 14.9.1 - [...] a cache MUST NOT use the response to satisfy a subsequent request [...]
146             if (isHeaderWithElement(header, "Cache-Control", "no-cache")) {
147                 return 0;
148             }
149 
150             // RFC-2616 Section 14.9.1 - Indicates that all or part of the response message is intended for a single user and MUST NOT be cached by a shared cache.
151             if (isHeaderWithElement(header, "Cache-Control", "private")) {
152                 return 0;
153             }
154         }
155 
156         int maxAge = -1;
157 
158         // RFC-2616 Section 14.9.3 - If a response includes an s-maxage directive, then for a shared cache (but not for a
159         // private cache), the maximum age specified by this directive overrides the maximum age specified by either the
160         // max-age directive or the Expires header.
161 
162         for (HeaderEntry header : headers) {
163             HeaderElement element = getHeaderElement(header, "Cache-Control", "s-maxage");
164             if (element != null) {
165                 try {
166                     int n = Integer.parseInt(element.getValue());
167                     if (maxAge == -1 || n < maxAge) {
168                         maxAge = n;
169                     }
170                 } catch (NumberFormatException e) {
171                     logger.warn("Ignoring unparseable Cache-Control header [" + header.toExternalFormat() +  "]");
172                 }
173             }
174         }
175 
176         if (maxAge != -1) {
177             return maxAge;
178         }
179 
180         // RFC-2616 Section 14.9.3 - If a response includes both an Expires header and a max-age directive, the max-age
181         // directive overrides the Expires header, even if the Expires header is more restrictive.
182 
183         for (HeaderEntry header : headers) {
184             HeaderElement element = getHeaderElement(header, "Cache-Control", "max-age");
185             if (element != null) {
186                 try {
187                     int n = Integer.parseInt(element.getValue());
188                     if (maxAge == -1 || n < maxAge) {
189                         maxAge = n;
190                     }
191                 } catch (NumberFormatException e) {
192                     logger.warn("Ignoring unparseable Cache-Control header [" + header.toExternalFormat() +  "]");
193                 }
194             }
195         }
196 
197         if (maxAge != -1) {
198             return maxAge;
199         }
200 
201         // Expires header, RFC-2616 Section 14.21
202 
203         for (HeaderEntry header : headers) {
204             if ("Expires".equals(header.getName())) {
205 
206                 Object value = header.getValue();
207 
208                 if (value instanceof Integer) {
209                     // the set value is in seconds since the epoch
210                     int n = (int) ((Integer) value - (System.currentTimeMillis() / 1000L));
211                     if (maxAge == -1 || n < maxAge) {
212                         maxAge = n;
213                     }
214                 }
215                 if (value instanceof Long) {
216                     // the set value is in milliseconds since the epoch
217                     int n = (int) (((Long) value - System.currentTimeMillis()) / 1000L);
218                     if (maxAge == -1 || n < maxAge) {
219                         maxAge = n;
220                     }
221                 }
222                 if (value instanceof String) {
223                     String s = (String) value;
224 
225                     // RFC2616 Section 14.21 - must treat 0 as already expired
226                     if ("0".equals(s)) {
227                         return 0;
228                     }
229 
230                     // A http-date as specified in RFC2616 Section 3.3.1, one of three possible date formats
231                     try {
232                         Date expires = DateUtil.parseDate(s);
233                         int n = (int) (expires.getTime() - System.currentTimeMillis());
234                         if (maxAge == -1 || n < maxAge) {
235                             maxAge = n;
236                         }
237                     } catch (DateParseException e) {
238                         logger.warn("Ignoring unparseable Expires header [" + header.toExternalFormat() +  "]");
239                     }
240                 }
241             }
242         }
243 
244         return maxAge;
245     }
246 
247     private boolean isHeaderWithElement(HeaderEntry header, String headerName, String elementName) {
248         return getHeaderElement(header, headerName, elementName) != null;
249     }
250 
251     private HeaderElement getHeaderElement(HeaderEntry header, String headerName, String elementName) {
252         if (headerName.equals(header.getName())) {
253             HeaderElement[] elements = header.getElements();
254             for (HeaderElement element : elements) {
255                 if (element.getName().equals(elementName)) {
256                     return element;
257                 }
258             }
259         }
260         return null;
261     }
262 }