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