View Javadoc

1   /**
2    * This file Copyright (c) 2008-2012 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.core.Path;
37  import info.magnolia.cms.util.RequestHeaderUtil;
38  
39  import java.io.ByteArrayInputStream;
40  import java.io.ByteArrayOutputStream;
41  import java.io.File;
42  import java.io.FileInputStream;
43  import java.io.FileOutputStream;
44  import java.io.IOException;
45  import java.io.OutputStream;
46  import java.io.OutputStreamWriter;
47  import java.io.PrintWriter;
48  import java.util.Collection;
49  import java.util.Iterator;
50  
51  import javax.servlet.ServletOutputStream;
52  import javax.servlet.http.HttpServletResponse;
53  import javax.servlet.http.HttpServletResponseWrapper;
54  
55  import org.apache.commons.collections.MultiMap;
56  import org.apache.commons.collections.map.MultiValueMap;
57  import org.apache.commons.httpclient.util.DateParseException;
58  import org.apache.commons.httpclient.util.DateUtil;
59  import org.apache.commons.io.FileUtils;
60  import org.apache.commons.io.IOUtils;
61  import org.apache.commons.io.output.ThresholdingOutputStream;
62  import org.slf4j.Logger;
63  import org.slf4j.LoggerFactory;
64  
65  
66  /**
67   * A response wrapper which records the status, headers and content. Unless the threshold is reached
68   * the written content gets buffered and the content can get retrieved by
69   * {@link #getBufferedContent()}. Once the threshold is reached either a tmp file is created which
70   * can be retrieved with {@link #getContentFile()} or the content/headers are made transparent to
71   * the original response if {@link #serveIfThresholdReached} is true.
72   *
73   * @version $Id$
74   */
75  public class CacheResponseWrapper extends HttpServletResponseWrapper {
76  
77      public static final int DEFAULT_THRESHOLD = 500 * 1024;
78      private static final Logger log = LoggerFactory.getLogger(CacheResponseWrapper.class);
79  
80      private final ServletOutputStream wrappedStream;
81      private PrintWriter wrappedWriter = null;
82      private final MultiMap headers = new MultiValueMap();
83      private int status = SC_OK;
84      private boolean isError;
85      private String redirectionLocation;
86      private final HttpServletResponse originalResponse;
87      private final ByteArrayOutputStream inMemoryBuffer;
88      private File contentFile;
89      private long contentLength = -1;
90      private ResponseExpirationCalculator responseExpirationCalculator;
91  
92      private final ThresholdingOutputStream thresholdingOutputStream;
93      private final boolean serveIfThresholdReached;
94  
95      private String errorMsg;
96  
97      public CacheResponseWrapper(final HttpServletResponse response, int threshold, boolean serveIfThresholdReached) {
98          super(response);
99          this.serveIfThresholdReached = serveIfThresholdReached;
100         this.originalResponse = response;
101         this.inMemoryBuffer = new ByteArrayOutputStream();
102         this.thresholdingOutputStream = new ThresholdingCacheOutputStream(threshold);
103         this.wrappedStream = new SimpleServletOutputStream(thresholdingOutputStream);
104     }
105 
106     public boolean isThresholdExceeded() {
107         return thresholdingOutputStream.isThresholdExceeded();
108     }
109 
110     public byte[] getBufferedContent(){
111         return inMemoryBuffer.toByteArray();
112     }
113 
114     public File getContentFile() {
115         return contentFile;
116     }
117 
118     // MAGNOLIA-1996: this can be called multiple times, e.g. by chunk writers, but always from a single thread.
119     @Override
120     public ServletOutputStream getOutputStream() throws IOException {
121         return wrappedStream;
122     }
123 
124     public ThresholdingOutputStream getThresholdingOutputStream() throws IOException {
125         return thresholdingOutputStream;
126     }
127 
128     @Override
129     public PrintWriter getWriter() throws IOException {
130         if (wrappedWriter == null) {
131             String encoding = getCharacterEncoding();
132             wrappedWriter = encoding != null
133                     ? new PrintWriter(new OutputStreamWriter(getOutputStream(), encoding))
134                     : new PrintWriter(new OutputStreamWriter(getOutputStream()));
135         }
136 
137         return wrappedWriter;
138     }
139 
140     @Override
141     public void flushBuffer() throws IOException {
142         flush();
143     }
144 
145     public void flush() throws IOException {
146         wrappedStream.flush();
147 
148         if (wrappedWriter != null) {
149             wrappedWriter.flush();
150         }
151     }
152 
153 
154     @Override
155     public void reset() {
156         super.reset();
157 
158         wrappedWriter = null;
159         status = SC_OK;
160         headers.clear();
161         // cleanup temp file if any
162         cleanUp();
163     }
164 
165 
166     @Override
167     public void resetBuffer() {
168         super.resetBuffer();
169         wrappedWriter = null;
170         cleanUp();
171     }
172 
173     public void cleanUp() {
174         if (contentFile != null && contentFile.exists()) {
175             if (!contentFile.delete()) {
176                 log.error("Can't delete file: " + contentFile);
177             }
178         }
179         contentFile = null;
180     }
181 
182     public int getStatus() {
183         return status;
184     }
185 
186     public boolean isError() {
187         return isError;
188     }
189 
190     public MultiMap getHeaders() {
191         return headers;
192     }
193 
194     public long getLastModified() {
195         // we're using a MultiMap. And all this is to workaround code that would possibly set the Last-Modified header with a String value
196         // it will also fail if multiple values have been set.
197         final Collection values = (Collection) headers.get("Last-Modified");
198         if (values == null || values.size() != 1) {
199             throw new IllegalStateException("Can't get Last-Modified header : no or multiple values : " + values);
200         }
201         final Object value = values.iterator().next();
202         if (value instanceof String) {
203             return parseStringDate((String) value);
204         } else if (value instanceof Long) {
205             return ((Long)value).longValue();
206         } else {
207             throw new IllegalStateException("Can't get Last-Modified header : " + value);
208         }
209     }
210 
211     private long parseStringDate(String value) {
212         try {
213             return DateUtil.parseDate(value).getTime();
214         } catch (DateParseException e) {
215             throw new IllegalStateException("Could not parse Last-Modified header with value " + value + " : " + e.getMessage());
216         }
217     }
218 
219     /**
220      * Enables expiration detection, response headers are then intercepted and suppressed from the response and used
221      * internally to calculate when the response expires (its time to live value). Use {@link #getTimeToLiveInSeconds()}
222      * to get the calculated value. See {@link ResponseExpirationCalculator} for more details on how the calculation
223      * is performed.
224      */
225     public void setResponseExpirationDetectionEnabled() {
226         this.responseExpirationCalculator = new ResponseExpirationCalculator();
227     }
228 
229     /**
230      * Returns the number of seconds the response can be cached, where 0 means that it must not be cached and -1 means
231      * that it there is no indication on how long it can be cached for. Will also return -1 if expiration calculation is
232      * disabled.
233      *
234      * @see #setResponseExpirationDetectionEnabled()
235      */
236     public int getTimeToLiveInSeconds() {
237         return responseExpirationCalculator != null ? responseExpirationCalculator.getMaxAgeInSeconds() : -1;
238     }
239 
240     public String getRedirectionLocation() {
241         return redirectionLocation;
242     }
243 
244     @Override
245     public void setDateHeader(String name, long date) {
246         replaceHeader(name, Long.valueOf(date));
247     }
248 
249     @Override
250     public void addDateHeader(String name, long date) {
251         appendHeader(name, Long.valueOf(date));
252     }
253 
254     @Override
255     public void setHeader(String name, String value) {
256         replaceHeader(name, value);
257     }
258 
259     @Override
260     public void addHeader(String name, String value) {
261         appendHeader(name, value);
262     }
263 
264     @Override
265     public void setIntHeader(String name, int value) {
266         replaceHeader(name, Integer.valueOf(value));
267     }
268 
269     @Override
270     public void addIntHeader(String name, int value) {
271         appendHeader(name, Integer.valueOf(value));
272     }
273 
274     @Override
275     public boolean containsHeader(String name) {
276         return headers.containsKey(name);
277     }
278 
279     private void replaceHeader(String name, Object value) {
280         if (responseExpirationCalculator == null || !responseExpirationCalculator.addHeader(name, value)) {
281             headers.remove(name);
282             headers.put(name, value);
283         }
284     }
285 
286     private void appendHeader(String name, Object value) {
287         if (responseExpirationCalculator == null || !responseExpirationCalculator.addHeader(name, value)) {
288             headers.put(name, value);
289         }
290     }
291 
292     @Override
293     public void setStatus(int status) {
294         this.status = status;
295     }
296 
297     @Override
298     public void setStatus(int status, String string) {
299         this.status = status;
300     }
301 
302     @Override
303     public void sendRedirect(String location) throws IOException {
304         this.status = SC_MOVED_TEMPORARILY;
305         this.redirectionLocation = location;
306     }
307 
308     @Override
309     public void sendError(int status, String errorMsg) throws IOException {
310         this.errorMsg = errorMsg;
311         this.status = status;
312         this.isError = true;
313     }
314 
315     @Override
316     public void sendError(int status) throws IOException {
317         this.status = status;
318         this.isError = true;
319     }
320 
321     @Override
322     public void setContentLength(int len) {
323         this.contentLength = len;
324     }
325 
326     public int getContentLength() {
327         return (int)(contentLength >=0 ? contentLength : thresholdingOutputStream.getByteCount());
328     }
329 
330     public void replay(HttpServletResponse target) throws IOException {
331         replayHeadersAndStatus(target);
332         replayContent(target, true);
333     }
334 
335     public void replayHeadersAndStatus(HttpServletResponse target) throws IOException {
336         if(isError){
337             if(errorMsg != null){
338                 target.sendError(status, errorMsg);
339             }
340             else{
341                 target.sendError(status);
342             }
343         }
344         else if(redirectionLocation != null){
345             target.sendRedirect(redirectionLocation);
346         }
347         else{
348             target.setStatus(status);
349         }
350 
351         target.setStatus(getStatus());
352 
353         final Iterator it = headers.keySet().iterator();
354         while (it.hasNext()) {
355             final String header = (String) it.next();
356 
357             final Collection values = (Collection) headers.get(header);
358             final Iterator valIt = values.iterator();
359             while (valIt.hasNext()) {
360                 final Object val = valIt.next();
361                 RequestHeaderUtil.setHeader(target, header, val);
362             }
363         }
364 
365         // TODO : cookies ?
366         target.setContentType(getContentType());
367         target.setCharacterEncoding(getCharacterEncoding());
368     }
369 
370     public void replayContent(HttpServletResponse target, boolean setContentLength) throws IOException {
371         if(setContentLength){
372             target.setContentLength(getContentLength());
373         }
374         if(getContentLength()>0){
375             if(isThresholdExceeded()){
376                 FileInputStream in = FileUtils.openInputStream(getContentFile());
377                 IOUtils.copy(in, target.getOutputStream());
378                 IOUtils.closeQuietly(in);
379             }
380             else{
381                 IOUtils.copy(new ByteArrayInputStream(inMemoryBuffer.toByteArray()), target.getOutputStream());
382             }
383             target.flushBuffer();
384         }
385     }
386 
387     private final class ThresholdingCacheOutputStream extends ThresholdingOutputStream {
388         OutputStream out = inMemoryBuffer;
389 
390         private ThresholdingCacheOutputStream(int threshold) {
391             super(threshold);
392         }
393 
394         @Override
395         protected OutputStream getStream() throws IOException {
396             return out;
397         }
398 
399         @Override
400         protected void thresholdReached() throws IOException {
401 
402             if(serveIfThresholdReached){
403                 replayHeadersAndStatus(originalResponse);
404                 out = originalResponse.getOutputStream();
405                 log.debug("Reached threshold for in-memory caching. Will not cache and stream response directly to user.");
406             }
407             else{
408                 contentFile = File.createTempFile("cacheStream", null, Path.getTempDirectory());
409                 if (contentFile != null) {
410                     log.debug("Reached threshold for in-memory caching. Will continue caching in new cache temp file {}", contentFile.getAbsolutePath());
411                     contentFile.deleteOnExit();
412                     out = new FileOutputStream(contentFile);
413                 } else {
414                     log.error("Reached threshold for in-memory caching, but unable to create the new cache temp file. Will not cache and stream response directly to user.");
415                     replayHeadersAndStatus(originalResponse);
416                     out = originalResponse.getOutputStream();
417                 }
418             }
419             out.write(getBufferedContent());
420             out.flush();
421         }
422     }
423 }