/* Copyright 2005-2006 Tim Fennell
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package net.sourceforge.stripes.action;
import java.io.IOException;
import java.io.InputStream;
import java.io.PrintWriter;
import java.io.Reader;
import java.io.StringReader;
import java.text.SimpleDateFormat;
import java.util.ArrayList;
import java.util.Collections;
import java.util.Date;
import java.util.Iterator;
import java.util.List;
import javax.servlet.ServletOutputStream;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import net.sourceforge.stripes.controller.AsyncResponse;
import net.sourceforge.stripes.exception.StripesRuntimeException;
import net.sourceforge.stripes.util.Log;
import net.sourceforge.stripes.util.Range;
/**
* <p>Resolution for streaming data back to the client (in place of forwarding the user to
* another page). Designed to be used for streaming non-page data such as generated images/charts
* and XML islands.</p>
*
* <p>Optionally supports the use of a file name which, if set, will cause a
* Content-Disposition header to be written to the output, resulting in a "Save As" type
* dialog box appearing in the user's browser. If you do not wish to supply a file name, but
* wish to achieve this behaviour, simple supply a file name of "".</p>
*
* <p>StreamingResolution is designed to be subclassed where necessary to provide streaming
* output where the data being streamed is not contained in an InputStream or Reader. This
* would normally be done using an anonymous inner class as follows:</p>
*
*<pre>
*return new StreamingResolution("text/xml") {
* public void stream(HttpServletResponse response) throws Exception {
* // custom output generation code
* response.getWriter().write(...);
* // or
* response.getOutputStream().write(...);
* }
*}.setFilename("your-filename.xml");
*</pre>
*
* @author Tim Fennell
*/
public class StreamingResolution implements Resolution {
/** Date format string for RFC 822 dates. */
private static final String RFC_822_DATE_FORMAT = "EEE, d MMM yyyy HH:mm:ss Z";
/** Boundary for use in multipart responses. */
private static final String MULTIPART_BOUNDARY = "BOUNDARY_F7C98B76AEF711DF86D1B4FCDFD72085";
private static final Log log = Log.getInstance(StreamingResolution.class);
private InputStream inputStream;
private Reader reader;
private String filename;
private String contentType;
private String characterEncoding;
private long lastModified = -1;
private long length = -1;
private boolean attachment;
private boolean rangeSupport = false;
private List<Range<Long>> byteRanges;
/**
* Constructor only to be used when subclassing the StreamingResolution (usually using
* an anonymous inner class. If this constructor is used, and stream() is not overridden
* then an exception will be thrown!
*
* @param contentType the content type of the data in the stream (e.g. image/png)
*/
public StreamingResolution(String contentType) {
this.contentType = contentType;
}
/**
* Constructor that builds a StreamingResolution that will stream binary data back to the
* client and identify the data as being of the specified content type.
*
* @param contentType the content type of the data in the stream (e.g. image/png)
* @param inputStream an InputStream from which to read the data to return to the client
*/
public StreamingResolution(String contentType, InputStream inputStream) {
this.contentType = contentType;
this.inputStream = inputStream;
}
/**
* Constructor that builds a StreamingResolution that will stream character data back to the
* client and identify the data as being of the specified content type.
*
* @param contentType the content type of the data in the stream (e.g. text/xml)
* @param reader a Reader from which to read the character data to return to the client
*/
public StreamingResolution(String contentType, Reader reader) {
this.contentType = contentType;
this.reader = reader;
}
/**
* Constructor that builds a StreamingResolution that will stream character data from a String
* back to the client and identify the data as being of the specified content type.
*
* @param contentType the content type of the data in the stream (e.g. text/xml)
* @param output a String to stream back to the client
*/
public StreamingResolution(String contentType, String output) {
this(contentType, new StringReader(output));
}
/**
* Sets the filename that will be the default name suggested when the user is prompted
* to save the file/stream being sent back. If the stream is not for saving by the user
* (i.e. it should be displayed or used by the browser) this value should not be set.
*
* @param filename the default filename the user will see
* @return StreamingResolution so that this method call can be chained to the constructor
* and returned
*/
public StreamingResolution setFilename(String filename) {
this.filename = filename;
setAttachment(filename != null);
return this;
}
/**
* Sets the character encoding that will be set on the request when executing this
* resolution. If none is set, then the current character encoding (either the one
* selected by the LocalePicker or the container default one) will be used.
*
* @param characterEncoding the character encoding to use instead of the default
*/
public void setCharacterEncoding(String characterEncoding) {
this.characterEncoding = characterEncoding;
}
/**
* Sets the modification-date timestamp. If this property is set, the browser may be able to
* apply it to the downloaded file. If this property is unset, the modification-date parameter
* will be omitted.
*
* @param lastModified The date-time (as a long) that the file was last modified. Optional.
* @return StreamingResolution so that this method call can be chained to the constructor and
* returned.
*/
public StreamingResolution setLastModified(long lastModified) {
this.lastModified = lastModified;
return this;
}
/**
* Sets the file length. If this property is set, the file size will be reported in the HTTP
* header. This may help with file download progress indicators. If this property is unset, the
* size parameter will be omitted.
*
* @param length The length of the file in bytes.
* @return StreamingResolution so that this method call can be chained to the constructor and
* returned.
*/
public StreamingResolution setLength(long length) {
this.length = length;
return this;
}
/**
* Indicates whether to use content-disposition attachment headers or not. (Defaults to true).
*
* @param attachment Whether the content should be treated as an attachment, or a direct
* download.
* @return StreamingResolution so that this method call can be chained to the constructor and
* returned.
*/
public StreamingResolution setAttachment(boolean attachment) {
this.attachment = attachment;
return this;
}
/**
* Indicates whether byte range serving is supported by stream method. (Defaults to false).
* Besides setting this flag, the ActionBean also needs to set the length of the response and
* provide an {@link InputStream}-based input. Reasons for disabling byte range serving:
* <ul>
* <li>The stream method is overridden and does not support byte range serving</li>
* <li>The input to this {@link StreamingResolution} was created on-demand, and retrieving in
* byte ranges would redo this process for every byte range.</li>
* </ul>
* Reasons for enabling byte range serving:
* <ul>
* <li>Streaming static multimedia files</li>
* <li>Supporting resuming download managers</li>
* </ul>
*
* @param rangeSupport Whether byte range serving is supported by stream method.
* @return StreamingResolution so that this method call can be chained to the constructor and
* returned.
*/
public StreamingResolution setRangeSupport(boolean rangeSupport) {
this.rangeSupport = rangeSupport;
return this;
}
/**
* Streams data from the InputStream or Reader to the response's OutputStream or PrinterWriter,
* using a moderately sized buffer to ensure that the operation is reasonably efficient.
* Once the InputStream or Reader signaled the end of the stream, close() is called on it.
*
* @param request the HttpServletRequest being processed
* @param response the paired HttpServletResponse
* @throws IOException if there is a problem accessing one of the streams or reader/writer
* objects used.
*/
final public void execute(HttpServletRequest request, HttpServletResponse response)
throws Exception {
/*-
* Process byte ranges only when the following three conditions are met:
* - Length has been defined (without length it is impossible to efficiently stream)
* - rangeSupport has not been set to false
* - Output is binary and not character based
-*/
if (rangeSupport && (length >= 0) && (inputStream != null))
byteRanges = parseRangeHeader(request.getHeader("Range"));
applyHeaders(response);
stream(response);
AsyncResponse asyncResponse = AsyncResponse.get(request);
if (asyncResponse != null) {
// async started, complete
asyncResponse.complete();
}
}
/**
* Sets the response headers, based on what is known about the file or stream being handled.
*
* @param response the current HttpServletResponse
*/
protected void applyHeaders(HttpServletResponse response) {
if (byteRanges != null) {
response.setStatus(HttpServletResponse.SC_PARTIAL_CONTENT);
}
if ((byteRanges == null) || (byteRanges.size() == 1)) {
response.setContentType(this.contentType);
}
else {
response.setContentType("multipart/byteranges; boundary=" + MULTIPART_BOUNDARY);
}
if (this.characterEncoding != null) {
response.setCharacterEncoding(characterEncoding);
}
// Set Content-Length header
if (length >= 0) {
if (byteRanges == null) {
// Odd that ServletResponse.setContentLength is limited to int.
// requires downcast from long to int e.g.
// response.setContentLength((int)length);
// Workaround to allow large files:
response.addHeader("Content-Length", Long.toString(length));
}
else if (byteRanges.size() == 1) {
Range<Long> byteRange;
byteRange = byteRanges.get(0);
response.setHeader("Content-Length",
Long.toString(byteRange.getEnd() - byteRange.getStart() + 1));
response.setHeader("Content-Range", "bytes " + byteRange.getStart() + "-"
+ byteRange.getEnd() + "/" + length);
}
}
// Set Last-Modified header
if (lastModified >= 0) {
response.setDateHeader("Last-Modified", lastModified);
}
// For Content-Disposition spec, see http://www.ietf.org/rfc/rfc2183.txt
if (attachment || filename != null) {
// Value of filename should be RFC 2047 encoded here (see RFC 2616) but few browsers
// support that, so just escape the quote for now
StringBuilder header = new StringBuilder(attachment ? "attachment" : "inline");
if (filename != null) {
String escaped = this.filename.replace("\"", "\\\"");
header.append(";filename=\"").append(escaped).append("\"");
}
if (lastModified >= 0) {
SimpleDateFormat format = new SimpleDateFormat(RFC_822_DATE_FORMAT);
String value = format.format(new Date(lastModified));
header.append(";modification-date=\"").append(value).append("\"");
}
if (length >= 0) {
header.append(";size=").append(length);
}
response.setHeader("Content-Disposition", header.toString());
}
}
/**
* Parse the Range header according to RFC 2616 section 14.35.1. Example ranges from this
* section:
* <ul>
* <li>The first 500 bytes (byte offsets 0-499, inclusive): bytes=0-499</li>
* <li>The second 500 bytes (byte offsets 500-999, inclusive): bytes=500-999</li>
* <li>The final 500 bytes (byte offsets 9500-9999, inclusive): bytes=-500 - Or bytes=9500-</li>
* <li>The first and last bytes only (bytes 0 and 9999): bytes=0-0,-1</li>
* <li>Several legal but not canonical specifications of the second 500 bytes (byte offsets
* 500-999, inclusive): bytes=500-600,601-999 bytes=500-700,601-999</li>
* </ul>
*
* @param value the value of the Range header
* @return List of sorted, non-overlapping ranges
*/
protected List<Range<Long>> parseRangeHeader(String value) {
Iterator<Range<Long>> i;
String byteRangesSpecifier[], bytesUnit, byteRangeSet[];
List<Range<Long>> res;
long lastEnd = -1;
if (value == null)
return null;
res = new ArrayList<Range<Long>>();
// Parse prelude
byteRangesSpecifier = value.split("=");
if (byteRangesSpecifier.length != 2)
return null;
bytesUnit = byteRangesSpecifier[0];
byteRangeSet = byteRangesSpecifier[1].split(",");
if (!bytesUnit.equals("bytes"))
return null;
// Parse individual byte ranges
for (String byteRangeSpec : byteRangeSet) {
String[] bytePos;
Long firstBytePos = null, lastBytePos = null;
bytePos = byteRangeSpec.split("-", -1);
try {
if (bytePos[0].trim().length() > 0)
firstBytePos = Long.valueOf(bytePos[0].trim());
if (bytePos[1].trim().length() > 0)
lastBytePos = Long.valueOf(bytePos[1].trim());
}
catch (NumberFormatException e) {
log.warn("Unable to parse Range header", e);
}
if ((firstBytePos == null) && (lastBytePos == null)) {
return null;
}
else if (firstBytePos == null) {
firstBytePos = length - lastBytePos;
lastBytePos = length - 1;
}
else if (lastBytePos == null) {
lastBytePos = length - 1;
}
if (firstBytePos > lastBytePos)
return null;
if (firstBytePos < 0)
return null;
if (lastBytePos >= length)
return null;
res.add(new Range<Long>(firstBytePos, lastBytePos));
}
// Sort byte ranges
Collections.sort(res);
// Remove overlapping ranges
i = res.listIterator();
while (i.hasNext()) {
Range<Long> range;
range = i.next();
if (lastEnd >= range.getStart()) {
range.setStart(lastEnd + 1);
if ((range.getStart() >= length) || (range.getStart() > range.getEnd()))
i.remove();
else
lastEnd = range.getEnd();
}
else {
lastEnd = range.getEnd();
}
}
if (res.isEmpty())
return null;
else
return res;
}
/**
* <p>
* Does the actual streaming of data through the response. If subclassed, this method should be
* overridden to stream back data other than data supplied by an InputStream or a Reader
* supplied to a constructor. If not implementing byte range serving, be sure not to set
* rangeSupport to true.
* </p>
*
* <p>
* If an InputStream or Reader was supplied to a constructor, this implementation uses a
* moderately sized buffer to stream data from it to the response to make the operation
* reasonably efficient, and closes the InputStream or the Reader. If an IOException occurs when
* closing it, that exception will be logged as a warning, and <em>not</em> thrown to avoid
* masking a possibly previously thrown exception.
* </p>
*
* @param response the HttpServletResponse from which either the output stream or writer can be
* obtained
* @throws Exception if any problems arise when streaming data
*/
protected void stream(HttpServletResponse response) throws Exception {
int length;
if (this.reader != null) {
char[] buffer = new char[512];
try {
PrintWriter out = response.getWriter();
while ( (length = this.reader.read(buffer)) != -1 ) {
out.write(buffer, 0, length);
}
}
finally {
try {
this.reader.close();
}
catch (Exception e) {
log.warn("Error closing reader", e);
}
}
}
else if (this.inputStream != null) {
byte[] buffer = new byte[512];
long count = 0;
try {
ServletOutputStream out = response.getOutputStream();
if (byteRanges == null) {
while ((length = this.inputStream.read(buffer)) != -1) {
out.write(buffer, 0, length);
}
}
else {
for (Range<Long> byteRange : byteRanges) {
// See RFC 2616 section 14.16
if (byteRanges.size() > 1) {
out.print("--" + MULTIPART_BOUNDARY + "\r\n");
out.print("Content-Type: " + contentType + "\r\n");
out.print("Content-Range: bytes " + byteRange.getStart() + "-"
+ byteRange.getEnd() + "/" + this.length + "\r\n");
out.print("\r\n");
}
if (count < byteRange.getStart()) {
long skip;
skip = byteRange.getStart() - count;
this.inputStream.skip(skip);
count += skip;
}
while ((length = this.inputStream.read(buffer, 0, (int) Math.min(
buffer.length, byteRange.getEnd() + 1 - count))) != -1) {
out.write(buffer, 0, length);
count += length;
if (byteRange.getEnd() + 1 == count)
break;
}
if (byteRanges.size() > 1) {
out.print("\r\n");
}
}
if (byteRanges.size() > 1)
out.print("--" + MULTIPART_BOUNDARY + "--\r\n");
}
}
finally {
try {
this.inputStream.close();
}
catch (Exception e) {
log.warn("Error closing input stream", e);
}
}
}
else {
throw new StripesRuntimeException("A StreamingResolution was constructed without " +
"supplying a Reader or InputStream, but stream() was not overridden. Please " +
"either supply a source of streaming data, or override the stream() method.");
}
}
}