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