diff --git a/source/java/org/alfresco/web/app/servlet/BaseDownloadContentServlet.java b/source/java/org/alfresco/web/app/servlet/BaseDownloadContentServlet.java index d88736a472..f4322b3869 100644 --- a/source/java/org/alfresco/web/app/servlet/BaseDownloadContentServlet.java +++ b/source/java/org/alfresco/web/app/servlet/BaseDownloadContentServlet.java @@ -28,6 +28,7 @@ import java.util.List; import java.util.StringTokenizer; import javax.servlet.ServletException; +import javax.servlet.ServletOutputStream; import javax.servlet.http.HttpServletRequest; import javax.servlet.http.HttpServletResponse; @@ -66,12 +67,31 @@ import org.springframework.extensions.surf.util.URLEncoder; */ public abstract class BaseDownloadContentServlet extends BaseServlet { - private static final long serialVersionUID = -4558907921887235966L; + private static final String HEADER_IF_MODIFIED_SINCE = "If-Modified-Since"; + + private static final long serialVersionUID = -4558907921887235967L; private static final String POWER_POINT_DOCUMENT_MIMETYPE = "application/vnd.powerpoint"; - private static final String POWER_POINT_2007_DOCUMENT_MIMETYPE = "application/vnd.openxmlformats-officedocument.presentationml.presentation"; + 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 + "--"; + + 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 HEADER_ACCEPT_RANGES = "Accept-Ranges"; + private static final String HEADER_RANGE = "Range"; + private static final String HEADER_ETAG = "ETag"; + private static final String HEADER_CACHE_CONTROL = "Cache-Control"; + private static final String HEADER_LAST_MODIFIED = "Last-Modified"; + private static final String HEADER_CONTENT_DISPOSITION = "Content-Disposition"; + + /** size of a multi-part byte range output buffer */ + private static final int CHUNKSIZE = 64*1024; + protected static final String MIMETYPE_OCTET_STREAM = "application/octet-stream"; protected static final String MSG_ERROR_CONTENT_MISSING = "error_content_missing"; @@ -104,7 +124,7 @@ public abstract class BaseDownloadContentServlet extends BaseServlet * page if the user does not have the correct permissions */ protected void processDownloadRequest(HttpServletRequest req, HttpServletResponse res, - boolean redirectToLogin) + boolean redirectToLogin, boolean transmitContent) throws ServletException, IOException { Log logger = getLogger(); @@ -117,9 +137,6 @@ public abstract class BaseDownloadContentServlet extends BaseServlet ((queryString != null && queryString.length() > 0) ? ("?" + queryString) : "")); } - // TODO: add compression here? - // see http://servlets.com/jservlet2/examples/ch06/ViewResourceCompress.java for example - // only really needed if we don't use the built in compression of the servlet container uri = uri.substring(req.getContextPath().length()); StringTokenizer t = new StringTokenizer(uri, "/"); int tokenCount = t.countTokens(); @@ -246,7 +263,7 @@ public abstract class BaseDownloadContentServlet extends BaseServlet Date modified = (Date)nodeService.getProperty(nodeRef, ContentModel.PROP_MODIFIED); if (modified != null) { - long modifiedSince = req.getDateHeader("If-Modified-Since"); + long modifiedSince = req.getDateHeader(HEADER_IF_MODIFIED_SINCE); if (modifiedSince > 0L) { // round the date to the ignore millisecond value which is not supplied by header @@ -259,16 +276,16 @@ public abstract class BaseDownloadContentServlet extends BaseServlet return; } } - res.setDateHeader("Last-Modified", modified.getTime()); - res.setHeader("Cache-Control", "must-revalidate"); - res.setHeader("ETag", "\"" + Long.toString(modified.getTime()) + "\""); + res.setDateHeader(HEADER_LAST_MODIFIED, modified.getTime()); + res.setHeader(HEADER_CACHE_CONTROL, "must-revalidate"); + res.setHeader(HEADER_ETAG, "\"" + Long.toString(modified.getTime()) + "\""); } if (attachment == true) { // set header based on filename - will force a Save As from the browse if it doesn't recognise it // this is better than the default response of the browser trying to display the contents - res.setHeader("Content-Disposition", "attachment"); + res.setHeader(HEADER_CONTENT_DISPOSITION, "attachment"); } // get the content reader @@ -292,111 +309,84 @@ public abstract class BaseDownloadContentServlet extends BaseServlet mimetype = mimetypeMap.getMimetype(ext); } } - + // explicitly set the content disposition header if the content is powerpoint if (!attachment && (mimetype.equals(POWER_POINT_2007_DOCUMENT_MIMETYPE) || mimetype.equals(POWER_POINT_DOCUMENT_MIMETYPE))) { - res.setHeader("Content-Disposition", "attachment"); + res.setHeader(HEADER_CONTENT_DISPOSITION, "attachment"); } - - // set mimetype for the content and the character encoding for the stream - res.setContentType(mimetype); - res.setCharacterEncoding(reader.getEncoding()); // get the content and stream directly to the response output stream // assuming the repo is capable of streaming in chunks, this should allow large files // to be streamed directly to the browser response stream. - res.setHeader("Accept-Ranges", "bytes"); - try + res.setHeader(HEADER_ACCEPT_RANGES, "bytes"); + + // for a GET request, transmit the content else just the headers are sent + if (transmitContent) { - boolean processedRange = false; - String range = req.getHeader("Content-Range"); - if (range == null) + try { - range = req.getHeader("Range"); - } - if (range != null) - { - if (logger.isDebugEnabled()) - logger.debug("Found content range header: " + range); - // 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 */ - try + boolean processedRange = false; + String range = req.getHeader(HEADER_CONTENT_RANGE); + if (range == null) { + range = req.getHeader(HEADER_RANGE); + } + if (range != null) + { + if (logger.isDebugEnabled()) + logger.debug("Found content range header: " + range); + + // ensure the range header is starts with "bytes=" and process the range(s) if (range.length() > 6) { - StringTokenizer r = new StringTokenizer(range.substring(6), "-/"); - if (r.countTokens() >= 2) - { - long start = Long.parseLong(r.nextToken()); - long end = Long.parseLong(r.nextToken()); - - res.setStatus(HttpServletResponse.SC_PARTIAL_CONTENT); - res.setHeader("Content-Range", range); - res.setHeader("Content-Length", Long.toString(((end-start)+1L))); - - InputStream is = null; - try - { - is = reader.getContentInputStream(); - if (start != 0) is.skip(start); - long span = (end-start)+1; - long total = 0; - int read = 0; - byte[] buf = new byte[((int)span) < 8192 ? (int)span : 8192]; - while ((read = is.read(buf)) != 0 && total < span) - { - total += (long)read; - res.getOutputStream().write(buf, 0, (int)read); - } - res.getOutputStream().close(); - processedRange = true; - } - finally - { - if (is != null) is.close(); - } - } + processedRange = processRange(res, reader, range.substring(6), nodeRef, propertyQName, mimetype); } } - catch (NumberFormatException nerr) + if (processedRange == false) { - // processedRange flag will stay false if this occurs + if (logger.isDebugEnabled()) + logger.debug("Sending complete file content..."); + + // set mimetype for the content and the character encoding for the stream + res.setContentType(mimetype); + res.setCharacterEncoding(reader.getEncoding()); + + // return the complete entity range + long size = reader.getSize(); + res.setHeader(HEADER_CONTENT_RANGE, "bytes 0-" + Long.toString(size-1L) + "/" + Long.toString(size)); + res.setHeader(HEADER_CONTENT_LENGTH, Long.toString(size)); + reader.getContent( res.getOutputStream() ); } } - if (processedRange == false) + catch (SocketException e1) { - // As per the spec: - // If the server ignores a byte-range-spec because it is syntactically - // invalid, the server SHOULD treat the request as if the invalid Range - // header field did not exist. - long size = reader.getSize(); - res.setHeader("Content-Range", "bytes 0-" + Long.toString(size-1L) + "/" + Long.toString(size)); - res.setHeader("Content-Length", Long.toString(size)); - reader.getContent( res.getOutputStream() ); + // the client cut the connection - our mission was accomplished apart from a little error message + if (logger.isDebugEnabled()) + logger.debug("Client aborted stream read:\n\tnode: " + nodeRef + "\n\tcontent: " + reader); + } + catch (ContentIOException e2) + { + if (logger.isInfoEnabled()) + logger.info("Failed stream read:\n\tnode: " + nodeRef + " due to: " + e2.getMessage()); + } + catch (Throwable err) + { + if (err.getCause() instanceof SocketException) + { + // the client cut the connection - our mission was accomplished apart from a little error message + if (logger.isDebugEnabled()) + logger.debug("Client aborted stream read:\n\tnode: " + nodeRef + "\n\tcontent: " + reader); + } + else throw err; } } - catch (SocketException e1) + else { - // the client cut the connection - our mission was accomplished apart from a little error message - if (logger.isInfoEnabled()) - logger.info("Client aborted stream read:\n\tnode: " + nodeRef + "\n\tcontent: " + reader); - } - catch (ContentIOException e2) - { - if (logger.isInfoEnabled()) - logger.info("Client aborted stream read:\n\tnode: " + nodeRef + "\n\tcontent: " + reader); + if (logger.isDebugEnabled()) + logger.debug("HEAD request processed - no content sent."); + res.getOutputStream().close(); } } catch (Throwable err) @@ -405,6 +395,430 @@ public abstract class BaseDownloadContentServlet extends BaseServlet } } + /** + * Process a range header - handles single and multiple range requests. + */ + private boolean processRange(HttpServletResponse res, ContentReader reader, String range, + NodeRef ref, QName property, String mimetype) + 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); + } + } + + /** + * 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 + * + * @return true if processed range, false otherwise + */ + private boolean processMultiRange(HttpServletResponse res, String range, NodeRef ref, QName property, String mimetype) + throws IOException + { + final Log logger = getLogger(); + final boolean trace = logger.isTraceEnabled(); + + // 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 + ContentService contentService = getServiceRegistry(getServletContext()).getContentService(); + 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 + if (ranges.size() > 1) + { + for (int i=0; i 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; + } + } + /** * Helper to generate a URL to a content node for downloading content from the server. * diff --git a/source/java/org/alfresco/web/app/servlet/DownloadContentServlet.java b/source/java/org/alfresco/web/app/servlet/DownloadContentServlet.java index bdd2b6169c..4b3f0f76a4 100644 --- a/source/java/org/alfresco/web/app/servlet/DownloadContentServlet.java +++ b/source/java/org/alfresco/web/app/servlet/DownloadContentServlet.java @@ -81,16 +81,16 @@ public class DownloadContentServlet extends BaseDownloadContentServlet return logger; } - /** - * @see javax.servlet.http.HttpServlet#doGet(javax.servlet.http.HttpServletRequest, javax.servlet.http.HttpServletResponse) + /* (non-Javadoc) + * @see javax.servlet.http.HttpServlet#doHead(javax.servlet.http.HttpServletRequest, javax.servlet.http.HttpServletResponse) */ - protected void doGet(final HttpServletRequest req, final HttpServletResponse res) - throws ServletException, IOException + @Override + protected void doHead(final HttpServletRequest req, final HttpServletResponse res) throws ServletException, IOException { if (logger.isDebugEnabled()) { String queryString = req.getQueryString(); - logger.debug("Authenticating request to URL: " + req.getRequestURI() + + logger.debug("Authenticating (HEAD) request to URL: " + req.getRequestURI() + ((queryString != null && queryString.length() > 0) ? ("?" + queryString) : "")); } @@ -106,7 +106,39 @@ public class DownloadContentServlet extends BaseDownloadContentServlet { public Void execute() throws Throwable { - processDownloadRequest(req, res, true); + processDownloadRequest(req, res, true, false); + return null; + } + }; + transactionService.getRetryingTransactionHelper().doInTransaction(processCallback, true); + } + + /** + * @see javax.servlet.http.HttpServlet#doGet(javax.servlet.http.HttpServletRequest, javax.servlet.http.HttpServletResponse) + */ + protected void doGet(final HttpServletRequest req, final HttpServletResponse res) + throws ServletException, IOException + { + if (logger.isDebugEnabled()) + { + String queryString = req.getQueryString(); + logger.debug("Authenticating (GET) request to URL: " + req.getRequestURI() + + ((queryString != null && queryString.length() > 0) ? ("?" + queryString) : "")); + } + + AuthenticationStatus status = servletAuthenticate(req, res); + if (status == AuthenticationStatus.Failure) + { + return; + } + + ServiceRegistry serviceRegistry = getServiceRegistry(getServletContext()); + TransactionService transactionService = serviceRegistry.getTransactionService(); + RetryingTransactionCallback processCallback = new RetryingTransactionCallback() + { + public Void execute() throws Throwable + { + processDownloadRequest(req, res, true, true); return null; } };