diff --git a/source/java/org/alfresco/repo/web/util/HttpRangeProcessor.java b/source/java/org/alfresco/repo/web/util/HttpRangeProcessor.java
new file mode 100644
index 0000000000..c2c3f99976
--- /dev/null
+++ b/source/java/org/alfresco/repo/web/util/HttpRangeProcessor.java
@@ -0,0 +1,507 @@
+/*
+ * Copyright (C) 2005-2012 Alfresco Software Limited.
+ *
+ * This file is part of Alfresco
+ *
+ * Alfresco is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Lesser General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * Alfresco is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License
+ * along with Alfresco. If not, see .
+ */
+package org.alfresco.repo.web.util;
+
+import java.io.IOException;
+import java.io.InputStream;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.List;
+import java.util.StringTokenizer;
+
+import javax.servlet.ServletOutputStream;
+import javax.servlet.http.HttpServletResponse;
+
+import org.alfresco.service.cmr.repository.ContentReader;
+import org.alfresco.service.cmr.repository.ContentService;
+import org.alfresco.service.cmr.repository.NodeRef;
+import org.alfresco.service.namespace.QName;
+import org.apache.commons.logging.Log;
+import org.apache.commons.logging.LogFactory;
+
+/**
+ * Generates HTTP response for "Range" scoped HTTP requests for content.
+ */
+public class HttpRangeProcessor
+{
+ private static final Log logger = LogFactory.getLog(HttpRangeProcessor.class);
+ private static final String HEADER_CONTENT_TYPE = "Content-Type";
+ private static final String HEADER_CONTENT_RANGE = "Content-Range";
+ private static final String HEADER_CONTENT_LENGTH = "Content-Length";
+ private static final String MULTIPART_BYTERANGES_BOUNDRY = "";
+ private static final String MULTIPART_BYTERANGES_HEADER = "multipart/byteranges; boundary=" + MULTIPART_BYTERANGES_BOUNDRY;
+ private static final String MULTIPART_BYTERANGES_BOUNDRY_SEP = "--" + MULTIPART_BYTERANGES_BOUNDRY;
+ private static final String MULTIPART_BYTERANGES_BOUNDRY_END = MULTIPART_BYTERANGES_BOUNDRY_SEP + "--";
+ /** size of a multi-part byte range output buffer */
+ private static final int CHUNKSIZE = 64*1024;
+ private ContentService contentService;
+
+
+ /**
+ * Constructor.
+ *
+ * @param contentService
+ */
+ public HttpRangeProcessor(ContentService contentService)
+ {
+ this.contentService = contentService;
+ }
+
+ /**
+ * Process a range header - handles single and multiple range requests.
+ */
+ public boolean processRange(HttpServletResponse res, ContentReader reader, String range,
+ NodeRef ref, QName property, String mimetype, String userAgent)
+ throws IOException
+ {
+ // test for multiple byte ranges present in header
+ if (range.indexOf(',') == -1)
+ {
+ return processSingleRange(res, reader, range, mimetype);
+ }
+ else
+ {
+ return processMultiRange(res, range, ref, property, mimetype, userAgent);
+ }
+ }
+
+ /**
+ * Process a single range request.
+ *
+ * @param res HttpServletResponse
+ * @param reader ContentReader to retrieve content
+ * @param range Range header value
+ * @param mimetype Content mimetype
+ *
+ * @return true if processed range, false otherwise
+ */
+ private boolean processSingleRange(HttpServletResponse res, ContentReader reader, String range, String mimetype)
+ throws IOException
+ {
+ // return the specific set of bytes as requested in the content-range header
+
+ /* Examples of byte-content-range-spec values, assuming that the entity contains total of 1234 bytes:
+ The first 500 bytes:
+ bytes 0-499/1234
+ The second 500 bytes:
+ bytes 500-999/1234
+ All except for the first 500 bytes:
+ bytes 500-1233/1234 */
+ /* 'Range' header example:
+ bytes=10485760-20971519 */
+
+ boolean processedRange = false;
+ Range r = null;
+ try
+ {
+ r = Range.constructRange(range, mimetype, reader.getSize());
+ }
+ catch (IllegalArgumentException err)
+ {
+ if (getLogger().isDebugEnabled())
+ getLogger().debug("Failed to parse range header - returning 416 status code: " + err.getMessage());
+
+ res.setStatus(HttpServletResponse.SC_REQUESTED_RANGE_NOT_SATISFIABLE);
+ res.setHeader(HEADER_CONTENT_RANGE, "\"*\"");
+ res.getOutputStream().close();
+ return true;
+ }
+
+ // set Partial Content status and range headers
+ res.setStatus(HttpServletResponse.SC_PARTIAL_CONTENT);
+ res.setContentType(mimetype);
+ String contentRange = "bytes " + Long.toString(r.start) + "-" + Long.toString(r.end) + "/" + Long.toString(reader.getSize());
+ res.setHeader(HEADER_CONTENT_RANGE, contentRange);
+ res.setHeader(HEADER_CONTENT_LENGTH, Long.toString((r.end - r.start) + 1L));
+
+ if (getLogger().isDebugEnabled())
+ getLogger().debug("Processing: Content-Range: " + contentRange);
+
+ InputStream is = null;
+ try
+ {
+ // output the binary data for the range
+ ServletOutputStream os = res.getOutputStream();
+ is = reader.getContentInputStream();
+
+ streamRangeBytes(r, is, os, 0L);
+
+ os.close();
+ processedRange = true;
+ }
+ catch (IOException err)
+ {
+ if (getLogger().isDebugEnabled())
+ getLogger().debug("Unable to process single range due to IO Exception: " + err.getMessage());
+ throw err;
+ }
+ finally
+ {
+ if (is != null) is.close();
+ }
+
+ return processedRange;
+ }
+
+ /**
+ * Process multiple ranges.
+ *
+ * @param res HttpServletResponse
+ * @param range Range header value
+ * @param ref NodeRef to the content for streaming
+ * @param property Content Property for the content
+ * @param mimetype Mimetype of the content
+ * @param userAgent User Agent of the caller
+ *
+ * @return true if processed range, false otherwise
+ */
+ private boolean processMultiRange(
+ HttpServletResponse res, String range, NodeRef ref, QName property, String mimetype, String userAgent)
+ throws IOException
+ {
+ final Log logger = getLogger();
+
+ // return the sets of bytes as requested in the content-range header
+ // the response will be formatted as multipart/byteranges media type message
+
+ /* Examples of byte-ranges-specifier values (assuming an entity-body of length 10000):
+
+ - The first 500 bytes (byte offsets 0-499, inclusive): bytes=0-499
+ - The second 500 bytes (byte offsets 500-999, inclusive):
+ bytes=500-999
+ - The final 500 bytes (byte offsets 9500-9999, inclusive):
+ bytes=-500
+ - Or bytes=9500-
+ - The first and last bytes only (bytes 0 and 9999): bytes=0-0,-1
+ - Several legal but not canonical specifications of byte offsets 500-999, inclusive:
+ bytes=500-600,601-999
+ bytes=500-700,601-999 */
+
+ boolean processedRange = false;
+
+ // get the content reader
+ ContentReader reader = contentService.getReader(ref, property);
+
+ final List ranges = new ArrayList(8);
+ long entityLength = reader.getSize();
+ for (StringTokenizer t=new StringTokenizer(range, ", "); t.hasMoreTokens(); /**/)
+ {
+ try
+ {
+ ranges.add(Range.constructRange(t.nextToken(), mimetype, entityLength));
+ }
+ catch (IllegalArgumentException err)
+ {
+ if (getLogger().isDebugEnabled())
+ getLogger().debug("Failed to parse range header - returning 416 status code: " + err.getMessage());
+
+ res.setStatus(HttpServletResponse.SC_REQUESTED_RANGE_NOT_SATISFIABLE);
+ res.setHeader(HEADER_CONTENT_RANGE, "\"*\"");
+ res.getOutputStream().close();
+ return true;
+ }
+ }
+
+ if (ranges.size() != 0)
+ {
+ // merge byte ranges if possible - IE handles this well, FireFox not so much
+ if (userAgent == null || userAgent.indexOf("MSIE ") != -1)
+ {
+ Collections.sort(ranges);
+
+ for (int i=0; i= second.start)
+ {
+ if (logger.isDebugEnabled())
+ logger.debug("Merging byte range: " + first + " with " + second);
+
+ if (first.end < second.end)
+ {
+ // merge second range into first
+ first.end = second.end;
+ }
+ // else we simply discard the second range - it is contained within the first
+
+ // delete second range
+ ranges.remove(i + 1);
+ // reset loop index
+ i--;
+ }
+ }
+ }
+
+ // calculate response content length
+ long length = MULTIPART_BYTERANGES_BOUNDRY_END.length() + 2;
+ for (Range r : ranges)
+ {
+ length += r.getLength();
+ }
+
+ // output headers as we have at least one range to process
+ res.setStatus(HttpServletResponse.SC_PARTIAL_CONTENT);
+ res.setHeader(HEADER_CONTENT_TYPE, MULTIPART_BYTERANGES_HEADER);
+ res.setHeader(HEADER_CONTENT_LENGTH, Long.toString(length));
+
+ ServletOutputStream os = res.getOutputStream();
+
+ InputStream is = null;
+ try
+ {
+ for (Range r : ranges)
+ {
+ if (logger.isDebugEnabled())
+ logger.debug("Processing: " + r.getContentRange());
+
+ try
+ {
+ // output the header bytes for the range
+ r.outputHeader(os);
+
+ // output the binary data for the range
+ // need a new reader for each new InputStream
+ is = contentService.getReader(ref, property).getContentInputStream();
+ streamRangeBytes(r, is, os, 0L);
+ is.close();
+ is = null;
+
+ // section marker and flush stream
+ os.println();
+ os.flush();
+ }
+ catch (IOException err)
+ {
+ if (getLogger().isDebugEnabled())
+ getLogger().debug("Unable to process multiple range due to IO Exception: " + err.getMessage());
+ throw err;
+ }
+ }
+ }
+ finally
+ {
+ if (is != null)
+ {
+ is.close();
+ }
+ }
+
+ // end marker
+ os.println(MULTIPART_BYTERANGES_BOUNDRY_END);
+ os.close();
+ processedRange = true;
+ }
+
+ return processedRange;
+ }
+
+ /**
+ * Stream a range of bytes from the given InputStream to the ServletOutputStream
+ *
+ * @param r Byte Range to process
+ * @param is InputStream
+ * @param os ServletOutputStream
+ * @param offset Assumed InputStream position - to calculate skip bytes from
+ *
+ * @return current InputStream position - so the stream can be reused if required
+ */
+ private void streamRangeBytes(final Range r, final InputStream is, final ServletOutputStream os, long offset)
+ throws IOException
+ {
+ final Log logger = getLogger();
+ final boolean trace = logger.isTraceEnabled();
+
+ // TODO: investigate using getFileChannel() on ContentReader
+
+ if (r.start != 0L && r.start > offset)
+ {
+ long skipped = offset + is.skip(r.start - offset);
+ if (skipped < r.start)
+ {
+ // Nothing left to download!
+ return;
+ }
+ }
+ long span = (r.end - r.start) + 1L;
+ long bytesLeft = span;
+ int read = 0;
+ byte[] buf = new byte[((int)bytesLeft) < CHUNKSIZE ? (int)bytesLeft : CHUNKSIZE];
+ while ((read = is.read(buf)) > 0 && bytesLeft != 0L)
+ {
+ os.write(buf, 0, read);
+
+ bytesLeft -= (long)read;
+
+ if (bytesLeft != 0L)
+ {
+ int resize = ((int)bytesLeft) < CHUNKSIZE ? (int)bytesLeft : CHUNKSIZE;
+ if (resize != buf.length)
+ {
+ buf = new byte[resize];
+ }
+ }
+ if (trace) logger.trace("...wrote " + read + " bytes, with " + bytesLeft + " to go...");
+ }
+ }
+
+
+ /**
+ * Representation of a single byte range.
+ */
+ private static class Range implements Comparable
+ {
+ private long start;
+ private long end;
+ private long entityLength;
+ private String contentType;
+ private String contentRange;
+
+ /**
+ * Constructor
+ *
+ * @param contentType Mimetype of the range content
+ * @param start Start position in the parent entity
+ * @param end End position in the parent entity
+ * @param entityLength Length of the parent entity
+ */
+ Range(String contentType, long start, long end, long entityLength)
+ {
+ this.contentType = HEADER_CONTENT_TYPE + ": " + contentType;
+ this.start = start;
+ this.end = end;
+ this.entityLength = entityLength;
+ }
+
+ /**
+ * Factory method to construct a byte range from a range header value.
+ *
+ * @param range Range header value
+ * @param contentType Mimetype of the range
+ * @param entityLength Length of the parent entity
+ *
+ * @return Range
+ *
+ * @throws IllegalArgumentException for an invalid range
+ */
+ static Range constructRange(String range, String contentType, long entityLength)
+ {
+ if (range == null)
+ {
+ throw new IllegalArgumentException("Range argument is mandatory");
+ }
+
+ // strip total if present - it does not give us anything useful
+ if (range.indexOf('/') != -1)
+ {
+ range = range.substring(0, range.indexOf('/'));
+ }
+
+ // find the separator
+ int separator = range.indexOf('-');
+ if (separator == -1)
+ {
+ throw new IllegalArgumentException("Invalid range: " + range);
+ }
+
+ try
+ {
+ // split range and parse values
+ long start = 0L;
+ if (separator != 0)
+ {
+ start = Long.parseLong(range.substring(0, separator));
+ }
+ long end = entityLength - 1L;
+ if (separator != range.length() - 1)
+ {
+ end = Long.parseLong(range.substring(separator + 1));
+ }
+
+ // return object to represent the byte-range
+ return new Range(contentType, start, end, entityLength);
+ }
+ catch (NumberFormatException err)
+ {
+ throw new IllegalArgumentException("Unable to parse range value: " + range);
+ }
+ }
+
+
+ /**
+ * Output the header bytes for a multi-part byte range header
+ */
+ void outputHeader(ServletOutputStream os) throws IOException
+ {
+ // output multi-part boundry separator
+ os.println(MULTIPART_BYTERANGES_BOUNDRY_SEP);
+ // output content type and range size sub-header for this part
+ os.println(this.contentType);
+ os.println(getContentRange());
+ os.println();
+ }
+
+ /**
+ * @return the length in bytes of the byte range content including the header bytes
+ */
+ int getLength()
+ {
+ // length in bytes of range plus it's header plus section marker and line feed bytes
+ return MULTIPART_BYTERANGES_BOUNDRY_SEP.length() + 2 +
+ this.contentType.length() + 2 +
+ getContentRange().length() + 4 + (int)(this.end - this.start + 1L) + 2;
+ }
+
+ /**
+ * @return the Content-Range header string value for this byte range
+ */
+ private String getContentRange()
+ {
+ if (this.contentRange == null)
+ {
+ this.contentRange = "Content-Range: bytes " + Long.toString(this.start) + "-" +
+ Long.toString(this.end) + "/" + Long.toString(this.entityLength);
+ }
+ return this.contentRange;
+ }
+
+ @Override
+ public String toString()
+ {
+ return this.start + "-" + this.end;
+ }
+
+ /**
+ * @see java.lang.Comparable#compareTo(java.lang.Object)
+ */
+ public int compareTo(Range o)
+ {
+ return this.start > o.start ? 1 : -1;
+ }
+ }
+
+
+ /**
+ * @return the logger
+ */
+ private static Log getLogger()
+ {
+ return logger;
+ }
+}
diff --git a/source/java/org/alfresco/repo/webdav/GetMethod.java b/source/java/org/alfresco/repo/webdav/GetMethod.java
index 923b108094..b00572cdcc 100644
--- a/source/java/org/alfresco/repo/webdav/GetMethod.java
+++ b/source/java/org/alfresco/repo/webdav/GetMethod.java
@@ -29,7 +29,9 @@ import java.util.StringTokenizer;
import javax.servlet.http.HttpServletResponse;
+import org.alfresco.model.ContentModel;
import org.alfresco.repo.content.filestore.FileContentReader;
+import org.alfresco.repo.web.util.HttpRangeProcessor;
import org.alfresco.service.cmr.model.FileFolderService;
import org.alfresco.service.cmr.model.FileInfo;
import org.alfresco.service.cmr.model.FileNotFoundException;
@@ -50,12 +52,14 @@ public class GetMethod extends WebDAVMethod
{
// Request parameters
+ private static final String RANGE_HEADER_UNIT_SPECIFIER = "bytes=";
private ArrayList ifMatchTags = null;
private ArrayList ifNoneMatchTags = null;
private Date m_ifModifiedSince = null;
private Date m_ifUnModifiedSince = null;
protected boolean m_returnContent = true;
+ private String byteRanges;
/**
* Default constructor
@@ -77,7 +81,11 @@ public class GetMethod extends WebDAVMethod
if (strRange != null && strRange.length() > 0)
{
- logger.warn("Range header (" + strRange + ") not supported");
+ byteRanges = strRange;
+ if (logger.isDebugEnabled())
+ {
+ logger.debug("Range header supplied: " + byteRanges);
+ }
}
// Capture all the If headers, process later
@@ -208,14 +216,35 @@ public class GetMethod extends WebDAVMethod
(ContentReader) reader,
I18NUtil.getMessage(FileContentReader.MSG_MISSING_CONTENT),
realNodeInfo.getNodeRef(), reader);
- // there is content associated with the node
- m_response.setHeader(WebDAV.HEADER_CONTENT_LENGTH, Long.toString(reader.getSize()));
- m_response.setHeader(WebDAV.HEADER_CONTENT_TYPE, reader.getMimetype());
- if (m_returnContent)
+ if (byteRanges != null && byteRanges.startsWith(RANGE_HEADER_UNIT_SPECIFIER))
{
- // copy the content to the response output stream
- reader.getContent(m_response.getOutputStream());
+ HttpRangeProcessor rangeProcessor = new HttpRangeProcessor(getContentService());
+ String userAgent = m_request.getHeader(WebDAV.HEADER_USER_AGENT);
+
+ if (m_returnContent)
+ {
+ rangeProcessor.processRange(
+ m_response,
+ reader,
+ byteRanges.substring(6),
+ realNodeInfo.getNodeRef(),
+ ContentModel.PROP_CONTENT,
+ reader.getMimetype(),
+ userAgent);
+ }
+ }
+ else
+ {
+ // there is content associated with the node
+ m_response.setHeader(WebDAV.HEADER_CONTENT_LENGTH, Long.toString(reader.getSize()));
+ m_response.setHeader(WebDAV.HEADER_CONTENT_TYPE, reader.getMimetype());
+
+ if (m_returnContent)
+ {
+ // copy the content to the response output stream
+ reader.getContent(m_response.getOutputStream());
+ }
}
}
}