View Javadoc
1   /**
2    * This file Copyright (c) 2011-2015 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 info.magnolia.cms.cache.CacheConstants;
37  
38  import java.util.ArrayList;
39  import java.util.Date;
40  import java.util.List;
41  
42  import org.apache.commons.httpclient.HeaderElement;
43  import org.apache.commons.httpclient.util.DateParseException;
44  import org.apache.commons.httpclient.util.DateUtil;
45  import org.slf4j.Logger;
46  import org.slf4j.LoggerFactory;
47  
48  /**
49   * Calculates how long a shared cache may hold a response based on its response headers. The most restrictive policy
50   * gets used while respecting the precedence rules dictated by RFC-2616. More specifically:
51   * <ul>
52   * <li>Cache-Control: s-maxage has precedence over</li>
53   * <li>Cache-Control: max-age which in turn has precedence over</li>
54   * <li>Expires:</li>
55   * </ul>
56   * <p>
57   * Given Cache-Control: max-age=5 and Cache-Control: max-age=15 the most restrictive is 5.
58   * </p>
59   * <p>
60   * Given Cache-Control: max-age=5 and Cache-Control: s-maxage=15 the latter has precedence resulting in 15.
61   * </p>
62   * If either of Pragma: no-cache, Cache-Control: no-cache and Cache-Control: private is present the response is
63   * considered to be already-expired.
64   * Uses Apache HttpClient to parse the headers.
65   */
66  public class ResponseExpirationCalculator {
67  
68      private static class HeaderEntry {
69  
70          private final String name;
71          private final Object value;
72          private HeaderElement[] elements;
73  
74          public HeaderEntry(String name, Object value) {
75              this.name = name;
76              this.value = value;
77          }
78  
79          public String getName() {
80              return name;
81          }
82  
83          public Object getValue() {
84              return value;
85          }
86  
87          public HeaderElement[] getElements() {
88              if (elements == null) {
89                  elements = HeaderElement.parseElements(value.toString());
90              }
91              return elements;
92          }
93  
94          public String toExternalFormat() {
95              return (name != null ? name : "") + ": " + (value != null ? value : "");
96          }
97  
98          @Override
99          public String toString() {
100             return toExternalFormat();
101         }
102     }
103 
104     private static final Logger logger = LoggerFactory.getLogger(ResponseExpirationCalculator.class);
105 
106     private final List<HeaderEntry> headers = new ArrayList<HeaderEntry>();
107 
108     public boolean addHeader(String name, Object value) {
109 
110         if (CacheConstants.HEADER_EXPIRES.equals(name)) {
111             this.headers.add(new HeaderEntry(name, value));
112             return true;
113         }
114 
115         if (CacheConstants.HEADER_CACHE_CONTROL.equals(name)) {
116             this.headers.add(new HeaderEntry(name, value));
117             return true;
118         }
119 
120         if (CacheConstants.HEADER_PRAGMA.equals(name)) {
121             HeaderEntry headerEntry = new HeaderEntry(name, value);
122             if (isHeaderWithElement(headerEntry, CacheConstants.HEADER_PRAGMA, CacheConstants.HEADER_VALUE_NO_CACHE)) {
123                 this.headers.add(headerEntry);
124                 return true;
125             }
126         }
127 
128         return false;
129     }
130 
131     /**
132      * Returns the number of seconds the response can be cached where 0 means that the its already expired and must not
133      * be cached and -1 means that there's no information on how long it can be cached.
134      */
135     public int getMaxAgeInSeconds() {
136 
137         for (HeaderEntry header : headers) {
138 
139             // Pragma no-cache as response header is no specified by HTTP but is widely used
140             if (isHeaderWithElement(header, CacheConstants.HEADER_PRAGMA, CacheConstants.HEADER_VALUE_NO_CACHE)) {
141                 return 0;
142             }
143 
144             // RFC-2616 Section 14.9.1 - [...] a cache MUST NOT use the response to satisfy a subsequent request [...]
145             if (isHeaderWithElement(header, CacheConstants.HEADER_CACHE_CONTROL, CacheConstants.HEADER_VALUE_NO_CACHE)) {
146                 return 0;
147             }
148 
149             // 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.
150             if (isHeaderWithElement(header, CacheConstants.HEADER_CACHE_CONTROL, CacheConstants.HEADER_VALUE_PRIVATE)) {
151                 return 0;
152             }
153         }
154 
155         int maxAge = -1;
156 
157         // RFC-2616 Section 14.9.3 - If a response includes an s-maxage directive, then for a shared cache (but not for a
158         // private cache), the maximum age specified by this directive overrides the maximum age specified by either the
159         // max-age directive or the Expires header.
160 
161         for (HeaderEntry header : headers) {
162             HeaderElement element = getHeaderElement(header, CacheConstants.HEADER_CACHE_CONTROL, CacheConstants.HEADER_VALUE_S_MAXAGE);
163             if (element != null) {
164                 try {
165                     int n = Integer.parseInt(element.getValue());
166                     if (maxAge == -1 || n < maxAge) {
167                         maxAge = n;
168                     }
169                 } catch (NumberFormatException e) {
170                     logger.warn("Ignoring unparseable " + CacheConstants.HEADER_CACHE_CONTROL + " header [" + header.toExternalFormat() + "]");
171                 }
172             }
173         }
174 
175         if (maxAge != -1) {
176             return maxAge;
177         }
178 
179         // RFC-2616 Section 14.9.3 - If a response includes both an Expires header and a max-age directive, the max-age
180         // directive overrides the Expires header, even if the Expires header is more restrictive.
181 
182         for (HeaderEntry header : headers) {
183             HeaderElement element = getHeaderElement(header, CacheConstants.HEADER_CACHE_CONTROL, CacheConstants.HEADER_VALUE_MAX_AGE);
184             if (element != null) {
185                 try {
186                     int n = Integer.parseInt(element.getValue());
187                     if (maxAge == -1 || n < maxAge) {
188                         maxAge = n;
189                     }
190                 } catch (NumberFormatException e) {
191                     logger.warn("Ignoring unparseable " + CacheConstants.HEADER_CACHE_CONTROL + " header [" + header.toExternalFormat() + "]");
192                 }
193             }
194         }
195 
196         if (maxAge != -1) {
197             return maxAge;
198         }
199 
200         // Expires header, RFC-2616 Section 14.21
201 
202         for (HeaderEntry header : headers) {
203             if (CacheConstants.HEADER_EXPIRES.equals(header.getName())) {
204 
205                 Object value = header.getValue();
206 
207                 if (value instanceof Integer) {
208                     // the set value is in seconds since the epoch
209                     int n = (int) ((Integer) value - (System.currentTimeMillis() / 1000L));
210                     if (maxAge == -1 || n < maxAge) {
211                         maxAge = n;
212                     }
213                 }
214                 if (value instanceof Long) {
215                     // the set value is in milliseconds since the epoch
216                     int n = (int) (((Long) value - System.currentTimeMillis()) / 1000L);
217                     if (maxAge == -1 || n < maxAge) {
218                         maxAge = n;
219                     }
220                 }
221                 if (value instanceof String) {
222                     String s = (String) value;
223 
224                     // RFC2616 Section 14.21 - must treat 0 as already expired
225                     if ("0".equals(s)) {
226                         return 0;
227                     }
228 
229                     // A http-date as specified in RFC2616 Section 3.3.1, one of three possible date formats
230                     try {
231                         Date expires = DateUtil.parseDate(s);
232                         int n = (int) (expires.getTime() - System.currentTimeMillis());
233                         if (maxAge == -1 || n < maxAge) {
234                             maxAge = n;
235                         }
236                     } catch (DateParseException e) {
237                         logger.warn("Ignoring unparseable " + CacheConstants.HEADER_EXPIRES + " header [" + header.toExternalFormat() + "]");
238                     }
239                 }
240             }
241         }
242 
243         return maxAge;
244     }
245 
246     private boolean isHeaderWithElement(HeaderEntry header, String headerName, String elementName) {
247         return getHeaderElement(header, headerName, elementName) != null;
248     }
249 
250     private HeaderElement getHeaderElement(HeaderEntry header, String headerName, String elementName) {
251         if (headerName.equals(header.getName())) {
252             HeaderElement[] elements = header.getElements();
253             for (HeaderElement element : elements) {
254                 if (element.getName().equals(elementName)) {
255                     return element;
256                 }
257             }
258         }
259         return null;
260     }
261 }