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