View Javadoc

1   /**
2    * This file Copyright (c) 2011 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.cms.filters;
35  
36  import java.io.IOException;
37  import java.io.OutputStreamWriter;
38  import java.io.PrintWriter;
39  import java.util.ArrayList;
40  import java.util.Arrays;
41  import java.util.HashMap;
42  import java.util.List;
43  import java.util.Map;
44  
45  import javax.servlet.FilterChain;
46  import javax.servlet.ServletException;
47  import javax.servlet.ServletOutputStream;
48  import javax.servlet.http.HttpServletRequest;
49  import javax.servlet.http.HttpServletResponse;
50  import javax.servlet.http.HttpServletResponseWrapper;
51  
52  import org.apache.commons.lang.StringUtils;
53  import org.slf4j.Logger;
54  import org.slf4j.LoggerFactory;
55  
56  /**
57   * This filter will process any incoming requests containing Range or If-Range headers and swallow all produced output except for that matching the requested range.
58   * 
59   * @version $Id$
60   * 
61   */
62  public class RangeSupportFilter extends AbstractMgnlFilter {
63  
64      private static final Logger log = LoggerFactory.getLogger(RangeSupportFilter.class);
65  
66      private boolean wrapWriter = true;
67  
68      private boolean isRangedRequest = false;
69  
70      private boolean isServeContent = true;
71  
72      long lastModTime = -1;
73      @Override
74      public void doFilter(HttpServletRequest request, HttpServletResponse response, FilterChain chain) throws IOException, ServletException {
75  
76          isRangedRequest = request.getHeader("Range") != null;
77          // react only on ranged requests
78          if (isRangedRequest) {
79              response = wrapResponse(request, response);
80  
81              // client might just check on us to see if we support ranged requests before actually requesting the content
82              if ("HEAD".equalsIgnoreCase(request.getMethod())) {
83                  isServeContent = false;
84              }
85          }
86          chain.doFilter(request, response);
87  
88      }
89  
90      public boolean isWrapWriter() {
91          return wrapWriter;
92      }
93  
94      /**
95       * RFP defines ony byte ranges, however writers operate on characters which might be more then one byte long. We might be cutting the character in half at the boundary of range which might make some clients unhappy even tho they asked for it. Default value is true.
96       * 
97       * @param wrapWriter
98       */
99      public void setWrapWriter(boolean wrapWriter) {
100         this.wrapWriter = wrapWriter;
101     }
102 
103     private HttpServletResponse wrapResponse(final HttpServletRequest request, final HttpServletResponse response) {
104         return new HttpServletResponseWrapper(response) {
105 
106             /** default length is max. We hope that the underlying code will set proper content length as a header before we proceed serving some bytes. */
107             private int length = Integer.MAX_VALUE;
108 
109             private final Map<String, Object> headers = new HashMap<String, Object>();
110 
111             private String eTag;
112 
113             private List<RangeInfo> ranges;
114 
115             private RangeInfo full;
116 
117             private ServletOutputStream stream;
118 
119             private PrintWriter writer;
120 
121             @Override
122             public void addDateHeader(String name, long date) {
123                 super.addDateHeader(name, date);
124                 this.headers.put(name, date);
125                 if ("Last-Modified".equalsIgnoreCase(name)) {
126                     lastModTime = date;
127                 }
128             }
129 
130             @Override
131             public void setDateHeader(String name, long date) {
132                 super.setDateHeader(name, date);
133                 this.headers.put(name, date);
134                 if ("Last-Modified".equalsIgnoreCase(name)) {
135                     lastModTime = date;
136                 }
137             }
138 
139             @Override
140             public void addHeader(String name, String value) {
141                 if ("Content-Disposition".equalsIgnoreCase(name) && log.isDebugEnabled()) {
142                     log.warn("content disposition enforced by underlying filter/servlet");
143                 }
144                 super.addHeader(name, value);
145                 this.headers.put(name, value);
146             }
147 
148             @Override
149             public void setHeader(String name, String value) {
150                 if ("Content-Disposition".equalsIgnoreCase(name) && log.isDebugEnabled()) {
151                     log.warn("content disposition enforced by underlying filter/servlet");
152                 }
153                 super.setHeader(name, value);
154                 this.headers.put(name, value);
155             }
156 
157             @Override
158             public void addIntHeader(String name, int value) {
159                 super.addIntHeader(name, value);
160                 this.headers.put(name, value);
161             }
162 
163             @Override
164             public void setIntHeader(String name, int value) {
165                 super.setIntHeader(name, value);
166                 this.headers.put(name, value);
167             }
168 
169             @Override
170             public void setContentLength(int len) {
171                 this.length = len;
172                 // do not propagate length up. We might not be able to change it once it is set. We will set it ourselves once we are ready to serve bytes.
173             }
174 
175             @Override
176             public ServletOutputStream getOutputStream() throws IOException {
177                 // make sure we set stream only once. Multiple calls to this method are allowed.
178                 if (this.stream == null) {
179                     ServletOutputStream stream = super.getOutputStream();
180                     // wrap the response to filter out everything except desired range
181                     this.stream = addRangeSupportWrapper(request, response, stream);
182 
183                     if (!isServeContent || this.stream == null) {
184                         // swallow output on head requests
185                         this.stream = new ServletOutputStream() {
186 
187                             @Override
188                             public void write(int b) throws IOException {
189                                 // do nothing, we do not write any output now
190                             }
191                         };
192                     }
193                 }
194                 return stream;
195             }
196 
197             private ServletOutputStream addRangeSupportWrapper(final HttpServletRequest request, final HttpServletResponse response, ServletOutputStream stream) throws IOException {
198                 if (!processContent(request, response)) {
199                     // we might have to return null stream instead as the previous method already called res.sendError();
200                     return null;
201                 }
202 
203                 if (headers.containsKey("Content-Range")) {
204                     // doesn't work for tomcat as it accesses underlying stream under our hands!!!
205                     log.debug("Range request was handled by underlying filter/servlet.");
206                     return stream;
207                 }
208                 if (ranges == null || ranges.isEmpty()) {
209                     // no op, serve all as usual
210                     log.debug("Didn't find any range to speak of. Serving all content as usual.");
211                     if (length != Integer.MAX_VALUE) {
212                         // set real length when we know it
213                         response.setContentLength(length);
214                     }
215                 } else if (ranges.size() == 1) {
216                     RangeInfo range = ranges.get(0);
217                     log.debug("Serving range [{}].", range);
218                     response.setContentLength(range.lengthOfRange);
219                     // setting 206 header is essential for some clients. The would abort if response is set to 200
220                     response.setStatus(SC_PARTIAL_CONTENT);
221                     stream = new RangedOutputStream(stream, range);
222                 } else {
223                     log.error("Requested multiple ranges [{}].", ranges.size());
224                     // TODO: add support for multiple ranges (sent as multipart request), for now just send error back
225                     response.setHeader("Content-Range", "bytes */" + length);
226                     response.sendError(HttpServletResponse.SC_REQUESTED_RANGE_NOT_SATISFIABLE);
227                     // again we might have to return null stream after calling sendError() as the original stream might no longer be valid
228                 }
229                 return stream;
230             }
231 
232             @Override
233             public PrintWriter getWriter() throws IOException {
234                 if (!wrapWriter) {
235                     return super.getWriter();
236                 }
237                 if (this.writer == null) {
238                     this.writer = new PrintWriter(new OutputStreamWriter(getOutputStream()));
239                 }
240                 return writer;
241             }
242 
243             private boolean processContent(HttpServletRequest request, HttpServletResponse response) throws IOException {
244                 log.debug("Serving binary on uri {} was last modified at {}", new Object[] { request.getRequestURI(), lastModTime });
245                 if (!isRequestValid(request, response)) {
246                     log.debug("Skipping request {} since it doesn't require body", new Object[] { request.getRequestURI() });
247                     return false;
248                 }
249                 if (!processRange(request)) {
250                     log.debug("Could not process range of request {}", new Object[] { request.getRequestURI() });
251                     return false;
252                 }
253                 return true;
254             }
255 
256             private boolean processRange(HttpServletRequest request) throws IOException {
257                 full = new RangeInfo(0, length - 1, length);
258                 ranges = new ArrayList<RangeInfo>();
259 
260                 String range = request.getHeader("Range");
261 
262                 // Valid range header format is "bytes=n-n,n-n,n-n...". If not, then return 416.
263                 if (!range.matches("^bytes=\\d*-\\d*(,\\d*-\\d*)*$")) {
264                     response.setHeader("Content-Range", "bytes */" + length);
265                     response.sendError(HttpServletResponse.SC_REQUESTED_RANGE_NOT_SATISFIABLE);
266                     return false;
267                 }
268 
269                 // If-Range header must match ETag or be greater then LastModified. If not, then return full file.
270                 String ifRange = request.getHeader("If-Range");
271                 if (ifRange != null && !ifRange.equals(eTag)) {
272                     try {
273                         long ifRangeTime = request.getDateHeader("If-Range");
274                         if (ifRangeTime != -1 && ifRangeTime + 1000 < lastModTime) {
275                             ranges.add(full);
276                         }
277                     } catch (IllegalArgumentException ignore) {
278                         // happens when if-range contains something else then date
279                         ranges.add(full);
280                     }
281                 }
282 
283                 // in case there were no invalid If-Range headers, then look at requested byte ranges.
284                 if (ranges.isEmpty()) {
285                     for (String part : range.substring(6).split(",")) {
286                         int start = intSubstring(StringUtils.substringBefore(part, "-"));
287                         int end = intSubstring(StringUtils.substringAfter(part, "-"));
288 
289                         if (start == -1) {
290                             start = length - end;
291                             end = length - 1;
292                         } else if (end == -1 || end > length - 1) {
293                             end = length - 1;
294                         }
295 
296                         // Is range valid?
297                         if (start > end) {
298                             response.setHeader("Content-Range", "bytes */" + length); // Required in 416.
299                             response.sendError(HttpServletResponse.SC_REQUESTED_RANGE_NOT_SATISFIABLE);
300                             return false;
301                         }
302 
303                         // Add range.
304                         ranges.add(new RangeInfo(start, end, length));
305                     }
306                 }
307 
308                 response.setHeader("ETag", eTag);
309                 if (ranges.size() == 1) {
310                     RangeInfo r = ranges.get(0);
311                     response.setHeader("Accept-Ranges", "bytes");
312                     response.setHeader("Content-Range", "bytes " + r.start + "-" + r.end + "/" + r.totalLengthOfServedBinary);
313                     length = r.lengthOfRange;
314                 }
315                 return true;
316             }
317 
318             private int intSubstring(String value) {
319                 return (value.length() > 0) ? Integer.parseInt(value) : -1;
320             }
321 
322             @Override
323             public void flushBuffer() throws IOException {
324                 if (writer != null) {
325                     writer.flush();
326                 }
327                 if (stream != null) {
328                     stream.flush();
329                 }
330 
331                 super.flushBuffer();
332             }
333 
334             private boolean isRequestValid(HttpServletRequest request, HttpServletResponse response) throws IOException {
335                 String fileName = StringUtils.substringAfterLast(request.getRequestURI(), "/");
336                 eTag = fileName + "_" + length + "_" + lastModTime;
337 
338                 // If-None-Match header should contain "*" or ETag.
339                 String ifNoneMatch = request.getHeader("If-None-Match");
340                 if (ifNoneMatch != null && matches(ifNoneMatch, eTag)) {
341                     response.setHeader("ETag", eTag); // Required in 304.
342                     log.debug("Returning {} on header If-None-Match", HttpServletResponse.SC_NOT_MODIFIED);
343                     response.sendError(HttpServletResponse.SC_NOT_MODIFIED);
344                     return false;
345                 }
346 
347                 // If-Modified-Since header must be greater than LastModified. ignore if If-None-Match header exists
348                 long ifModifiedSince = request.getDateHeader("If-Modified-Since");
349                 if (ifNoneMatch == null && ifModifiedSince != -1 && ifModifiedSince + 1000 > lastModTime) {
350                     response.setHeader("ETag", eTag); // Required in 304.
351                     // 304 response should contain Date header unless running on timeless server (see 304 response docu)
352                     response.addDateHeader("Date", lastModTime);
353                     log.debug("Returning {} on header If-Modified-Since", HttpServletResponse.SC_NOT_MODIFIED);
354                     response.sendError(HttpServletResponse.SC_NOT_MODIFIED);
355                     return false;
356                 }
357 
358                 // If-Match header should contain "*" or ETag.
359                 String ifMatch = request.getHeader("If-Match");
360                 if (ifMatch != null && !matches(ifMatch, eTag)) {
361                     log.debug("Returning {} on header If-Match", HttpServletResponse.SC_PRECONDITION_FAILED);
362                     response.sendError(HttpServletResponse.SC_PRECONDITION_FAILED);
363                     return false;
364                 }
365 
366                 // If-Unmodified-Since header must be greater than LastModified.
367                 long ifUnmodifiedSince = request.getDateHeader("If-Unmodified-Since");
368                 if (ifUnmodifiedSince != -1 && ifUnmodifiedSince + 1000 <= lastModTime) {
369                     log.debug("Returning {} on header If-Unmodified-Since", HttpServletResponse.SC_PRECONDITION_FAILED);
370                     response.sendError(HttpServletResponse.SC_PRECONDITION_FAILED);
371                     return false;
372                 }
373 
374                 log.debug("Passed all precondition checkes for request {}", request.getRequestURI());
375                 return true;
376             }
377         };
378     }
379 
380     private boolean matches(String matchHeader, String toMatch) {
381         String[] matchValues = matchHeader.split("\\s*,\\s*");
382         Arrays.sort(matchValues);
383         return Arrays.binarySearch(matchValues, toMatch) > -1
384         || Arrays.binarySearch(matchValues, "*") > -1;
385     }
386 
387     /**
388      * Requested byte range.
389      * 
390      * @version $Id$
391      * 
392      */
393     protected class RangeInfo {
394         final int start;
395         final int end;
396         final int lengthOfRange;
397         final int totalLengthOfServedBinary;
398 
399         public RangeInfo(int start, int end, int totalLengthOfServedBinary) {
400             this.start = start;
401             this.end = end;
402             this.lengthOfRange = end - start + 1;
403             this.totalLengthOfServedBinary = totalLengthOfServedBinary;
404         }
405 
406         @Override
407         public String toString() {
408             return "Start: " + start + ", end: " + end + ", len: " + lengthOfRange + ", totalLengthOfServedBinary: " + totalLengthOfServedBinary;
409         }
410     }
411 }