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