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 addHeader(String name, String value) {
132                 // FIXME: only for rejected types (aka dms)
133                 if ("Content-Disposition".equalsIgnoreCase(name)) {
134                     log.error("content disposition enforced by underlying filter/servlet");
135                 }
136                 super.addHeader(name, value);
137                 this.headers.put(name, value);
138             }
139 
140             @Override
141             public void addIntHeader(String name, int value) {
142                 super.addIntHeader(name, value);
143                 this.headers.put(name, value);
144             }
145 
146             @Override
147             public void setContentLength(int len) {
148                 this.length = len;
149                 // 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.
150             }
151 
152             @Override
153             public ServletOutputStream getOutputStream() throws IOException {
154                 // make sure we set stream only once. Multiple calls to this method are allowed.
155                 if (this.stream == null) {
156                     ServletOutputStream stream = super.getOutputStream();
157                     // wrap the response to filter out everything except desired range
158                     this.stream = addRangeSupportWrapper(request, response, stream);
159 
160                     if (!isServeContent) {
161                         // swallow output on head requests
162                         this.stream = new ServletOutputStream() {
163 
164                             @Override
165                             public void write(int b) throws IOException {
166                                 // do nothing, we do not write any output now
167                             }
168                         };
169                     }
170                 }
171                 return stream;
172             }
173 
174             private ServletOutputStream addRangeSupportWrapper(final HttpServletRequest request, final HttpServletResponse response, ServletOutputStream stream) throws IOException {
175                 if (!processContent(request, response)) {
176                     // we might have to return null stream instead as the previous method already called res.sendError();
177                     return stream;
178                 }
179 
180                 if (ranges == null || ranges.isEmpty()) {
181                     // no op, serve all as usual
182                     log.debug("Didn't find any range to speak of. Serving all content as usual.");
183                     if (length != Integer.MAX_VALUE) {
184                         // set real length when we know it
185                         response.setContentLength(length);
186                     }
187                 } else if (ranges.size() == 1) {
188                     RangeInfo range = ranges.get(0);
189                     log.debug("Serving range [{}].", range);
190                     response.setContentLength(range.lengthOfRange);
191                     stream = new RangedOutputStream(stream, range);
192                 } else {
193                     log.error("Requested multiple ranges [{}].", ranges.size());
194                     // TODO: add support for multiple ranges (sent as multipart request), for now just send error back
195                     response.setHeader("Content-Range", "bytes */" + length);
196                     response.sendError(HttpServletResponse.SC_REQUESTED_RANGE_NOT_SATISFIABLE);
197                     // again we might have to return null stream after calling sendError() as the original stream might no longer be valid
198                 }
199                 return stream;
200             }
201 
202             @Override
203             public PrintWriter getWriter() throws IOException {
204                 if (!wrapWriter) {
205                     return super.getWriter();
206                 }
207                 if (this.writer == null) {
208                     this.writer = new PrintWriter(new OutputStreamWriter(getOutputStream()));
209                 }
210                 return writer;
211             }
212 
213             private boolean processContent(HttpServletRequest request, HttpServletResponse response) throws IOException {
214                 log.debug("Serving binary on uri {} was last modified at {}", new Object[] { request.getRequestURI(), lastModTime });
215                 if (!isRequestValid(request, response)) {
216                     log.debug("Skipping request {} as invalid", new Object[] { request.getRequestURI() });
217                     return false;
218                 }
219                 if (!processRange(request)) {
220                     log.debug("Could not process range of request {}", new Object[] { request.getRequestURI() });
221                     return false;
222                 }
223                 return true;
224             }
225 
226             private boolean processRange(HttpServletRequest request) throws IOException {
227                 full = new RangeInfo(0, length - 1, length);
228                 ranges = new ArrayList<RangeInfo>();
229 
230                 String range = request.getHeader("Range");
231 
232                 // Valid range header format is "bytes=n-n,n-n,n-n...". If not, then return 416.
233                 if (!range.matches("^bytes=\\d*-\\d*(,\\d*-\\d*)*$")) {
234                     response.setHeader("Content-Range", "bytes */" + length);
235                     response.sendError(HttpServletResponse.SC_REQUESTED_RANGE_NOT_SATISFIABLE);
236                     return false;
237                 }
238 
239                 // If-Range header must match ETag or be greater then LastModified. If not, then return full file.
240                 String ifRange = request.getHeader("If-Range");
241                 if (ifRange != null && !ifRange.equals(eTag)) {
242                     try {
243                         long ifRangeTime = request.getDateHeader("If-Range");
244                         if (ifRangeTime != -1 && ifRangeTime + 1000 < lastModTime) {
245                             ranges.add(full);
246                         }
247                     } catch (IllegalArgumentException ignore) {
248                         // happens when if-range contains something else then date
249                         ranges.add(full);
250                     }
251                 }
252 
253                 // in case there were no invalid If-Range headers, then look at requested byte ranges.
254                 if (ranges.isEmpty()) {
255                     for (String part : range.substring(6).split(",")) {
256                         int start = intSubstring(StringUtils.substringBefore(part, "-"));
257                         int end = intSubstring(StringUtils.substringAfter(part, "-"));
258 
259                         if (start == -1) {
260                             start = length - end;
261                             end = length - 1;
262                         } else if (end == -1 || end > length - 1) {
263                             end = length - 1;
264                         }
265 
266                         // Is range valid?
267                         if (start > end) {
268                             response.setHeader("Content-Range", "bytes */" + length); // Required in 416.
269                             response.sendError(HttpServletResponse.SC_REQUESTED_RANGE_NOT_SATISFIABLE);
270                             return false;
271                         }
272 
273                         // Add range.
274                         ranges.add(new RangeInfo(start, end, length));
275                     }
276                 }
277 
278                 response.setHeader("ETag", eTag);
279                 if (ranges.size() == 1) {
280                     RangeInfo r = ranges.get(0);
281                     response.setHeader("Accept-Ranges", "bytes");
282                     response.setHeader("Content-Range", "bytes " + r.start + "-" + r.end + "/" + r.totalLengthOfServedBinary);
283                     length = r.lengthOfRange;
284                 }
285                 return true;
286             }
287 
288             private int intSubstring(String value) {
289                 return (value.length() > 0) ? Integer.parseInt(value) : -1;
290             }
291 
292             @Override
293             public void flushBuffer() throws IOException {
294                 if (writer != null) {
295                     writer.flush();
296                 }
297                 if (stream != null) {
298                     stream.flush();
299                 }
300 
301                 super.flushBuffer();
302             }
303 
304             private boolean isRequestValid(HttpServletRequest request, HttpServletResponse response) throws IOException {
305                 String fileName = StringUtils.substringAfterLast(request.getRequestURI(), "/");
306                 eTag = fileName + "_" + length + "_" + lastModTime;
307 
308                 // If-None-Match header should contain "*" or ETag.
309                 String ifNoneMatch = request.getHeader("If-None-Match");
310                 if (ifNoneMatch != null && matches(ifNoneMatch, eTag)) {
311                     response.setHeader("ETag", eTag); // Required in 304.
312                     log.debug("Returning {} on header If-None-Match", HttpServletResponse.SC_NOT_MODIFIED);
313                     response.sendError(HttpServletResponse.SC_NOT_MODIFIED);
314                     return false;
315                 }
316 
317                 // If-Modified-Since header must be greater than LastModified. ignore if If-None-Match header exists
318                 long ifModifiedSince = request.getDateHeader("If-Modified-Since");
319                 if (ifNoneMatch == null && ifModifiedSince != -1 && ifModifiedSince + 1000 > lastModTime) {
320                     response.setHeader("ETag", eTag); // Required in 304.
321                     log.debug("Returning {} on header If-Modified-Since", HttpServletResponse.SC_NOT_MODIFIED);
322                     response.sendError(HttpServletResponse.SC_NOT_MODIFIED);
323                     return false;
324                 }
325 
326                 // If-Match header should contain "*" or ETag.
327                 String ifMatch = request.getHeader("If-Match");
328                 if (ifMatch != null && !matches(ifMatch, eTag)) {
329                     log.debug("Returning {} on header If-Match", HttpServletResponse.SC_PRECONDITION_FAILED);
330                     response.sendError(HttpServletResponse.SC_PRECONDITION_FAILED);
331                     return false;
332                 }
333 
334                 // If-Unmodified-Since header must be greater than LastModified.
335                 long ifUnmodifiedSince = request.getDateHeader("If-Unmodified-Since");
336                 if (ifUnmodifiedSince != -1 && ifUnmodifiedSince + 1000 <= lastModTime) {
337                     log.debug("Returning {} on header If-Unmodified-Since", HttpServletResponse.SC_PRECONDITION_FAILED);
338                     response.sendError(HttpServletResponse.SC_PRECONDITION_FAILED);
339                     return false;
340                 }
341 
342                 log.debug("Passed all precondition checkes for request {}", request.getRequestURI());
343                 return true;
344             }
345         };
346     }
347 
348     private boolean matches(String matchHeader, String toMatch) {
349         String[] matchValues = matchHeader.split("\\s*,\\s*");
350         Arrays.sort(matchValues);
351         return Arrays.binarySearch(matchValues, toMatch) > -1
352         || Arrays.binarySearch(matchValues, "*") > -1;
353     }
354 
355     /**
356      * Requested byte range.
357      * 
358      * @version $Id$
359      * 
360      */
361     protected class RangeInfo {
362         final int start;
363         final int end;
364         final int lengthOfRange;
365         final int totalLengthOfServedBinary;
366 
367         public RangeInfo(int start, int end, int totalLengthOfServedBinary) {
368             this.start = start;
369             this.end = end;
370             this.lengthOfRange = end - start + 1;
371             this.totalLengthOfServedBinary = totalLengthOfServedBinary;
372         }
373 
374         @Override
375         public String toString() {
376             return "Start: " + start + ", end: " + end + ", len: " + lengthOfRange + ", totalLengthOfServedBinary: " + totalLengthOfServedBinary;
377         }
378     }
379 }