View Javadoc

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