package com.enonic.cms.framework.util;
import java.io.File;
import java.io.IOException;
import java.io.OutputStream;
import java.io.RandomAccessFile;
import java.util.ArrayList;
import java.util.List;
import java.util.zip.GZIPOutputStream;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import org.joda.time.DateTime;
import com.google.common.net.HttpHeaders;
public class HttpServletRangeUtil
{
// Format: "bytes=n-n,n-n,n-n..."
protected static final String PATTERN_RANGE = "^bytes=\\d*-\\d*(,\\s*\\d*-\\d*)*$";
private static final int DEFAULT_BUFFER_SIZE = 1 << 15; // 32KB
private static final String SEPARATOR = "THIS_STRING_SEPARATES";
/**
* Process the range request.
*
* @param request request to be processed
* @param response response to be created
* @param filename name of file
* @param contentType Mime type
* @param file File to be downloaded
* @param attachment Whether to inline (false) or download (true)
* @throws java.io.IOException
*/
public static void processRequest( final HttpServletRequest request, final HttpServletResponse response, final String filename,
String contentType, final File file, final boolean attachment )
throws IOException
{
// I. File validation
int errorCode = checkRequestedFile( request );
if ( errorCode != 0 )
{
response.sendError( errorCode );
return;
}
errorCode = checkFileExists( file );
if ( errorCode != 0 )
{
response.sendError( errorCode );
return;
}
final String eTag = resolveETag( file );
// II. Header validation
errorCode = checkIfNoneMatch( request, eTag );
if ( errorCode != 0 )
{
response.setHeader( HttpHeaders.ETAG, eTag );
response.sendError( errorCode );
return;
}
errorCode = checkIfModifiedSince( request, file );
if ( errorCode != 0 )
{
response.setHeader( HttpHeaders.ETAG, eTag );
response.sendError( errorCode );
return;
}
errorCode = checkIfMatch( request, eTag );
if ( errorCode != 0 )
{
response.sendError( errorCode );
return;
}
errorCode = checkIfUnmodifiedSince( request, file );
if ( errorCode != 0 )
{
response.sendError( errorCode );
return;
}
// III. Process ranges
final List<Range> ranges = new ArrayList<Range>();
errorCode = processRanges( request, ranges, file, eTag );
if ( errorCode != 0 )
{
response.setHeader( HttpHeaders.CONTENT_RANGE, String.format( "bytes */%d", file.length() ) );
response.sendError( errorCode );
return;
}
contentType = contentType != null ? contentType : "application/octet-stream";
// IV. check if gzip accepted
final boolean acceptsGzip = acceptsGZip( request, response, contentType, filename, file, eTag );
// V. Process download
processDownload( response, contentType, file, ranges, acceptsGzip, attachment, filename );
}
private static int checkRequestedFile( final HttpServletRequest request )
{
final String requestedFile = request.getPathInfo();
return ( requestedFile == null ) ? HttpServletResponse.SC_NOT_FOUND : 0;
}
private static int checkFileExists( final File file )
{
return ( !file.exists() ) ? HttpServletResponse.SC_NOT_FOUND : 0;
}
private static String resolveETag( final File file )
{
final String fileName = file.getName();
final long length = file.length();
final long lastModified = file.lastModified();
return String.format( "%s_%d_%d", fileName, length, lastModified );
}
/**
* Check <code>If-None-Match</code> header contains "*" or ETag.
*
* @param request HttpServletRequest
* @param eTag ETag issue
* @return success code (0), otherwise <code>304</code> indicating that a conditional GET operation found that the resource was available and not modified
*/
private static int checkIfNoneMatch( final HttpServletRequest request, final String eTag )
{
final String ifNoneMatch = request.getHeader( HttpHeaders.IF_NONE_MATCH );
return ( ifNoneMatch != null && HttpServletUtil.checkHeaderContainsETag( ifNoneMatch, eTag ) )
? HttpServletResponse.SC_NOT_MODIFIED
: 0;
}
/**
* Check <code>If-Modified-Since</code> > LastModified.
*
* @param request HttpServletRequest
* @param file File proceed
* @return success code (0), otherwise <code>304</code> indicating that a conditional GET operation found that the resource was available and not modified
*/
private static int checkIfModifiedSince( final HttpServletRequest request, final File file )
{
final String ifNoneMatch = request.getHeader( HttpHeaders.IF_NONE_MATCH );
// In case <code>If-None-Match</code> header is set, than skip checking
if ( ifNoneMatch == null )
{
long lastModified = file.lastModified();
long ifModifiedSince = request.getDateHeader( HttpHeaders.IF_MODIFIED_SINCE );
if ( ifModifiedSince != -1 && ( ifModifiedSince + 1000 > lastModified ) )
{
return HttpServletResponse.SC_NOT_MODIFIED;
}
}
return 0;
}
/**
* Check <code>If-Match</code> header contains "*" or ETag.
*
* @param request HttpServletRequest
* @param eTag ETag issue
* @return success code (0), otherwise <code>412</code> indicating that the precondition given in one or more of the request-header fields evaluated to false when it was tested on the server
*/
private static int checkIfMatch( final HttpServletRequest request, final String eTag )
{
final String ifMatch = request.getHeader( HttpHeaders.IF_MATCH );
return ( ifMatch != null && !HttpServletUtil.checkHeaderContainsETag( ifMatch, eTag ) )
? HttpServletResponse.SC_PRECONDITION_FAILED
: 0;
}
/**
* Check <code>If-Unmodified-Since</code> > LastModified.
*
* @param request HttpServletRequest
* @param file File proceed
* @return success code (0), otherwise <code>412</code> indicating that the precondition given in one or more of the request-header fields evaluated to false when it was tested on the server
*/
private static int checkIfUnmodifiedSince( final HttpServletRequest request, final File file )
{
long lastModified = file.lastModified();
long ifUnmodifiedSince = request.getDateHeader( HttpHeaders.IF_UNMODIFIED_SINCE );
return ( ifUnmodifiedSince != -1 && ( ifUnmodifiedSince + 1000 <= lastModified ) ) ? HttpServletResponse.SC_PRECONDITION_FAILED : 0;
}
/**
* Process ranges.
*
* @param request HttpServletRequest
* @param ranges list of Ranges
* @param file File proceed
* @param eTag <code>ETag</code> header
* @return success code (0), otherwise code <code>416</code> indicating that the server cannot serve the requested byte range
* @throws IOException
*/
private static int processRanges( final HttpServletRequest request, final List<Range> ranges, final File file, final String eTag )
throws IOException
{
long length = file.length();
Range root = new Range( 0, length - 1, length );
final String range = request.getHeader( HttpHeaders.RANGE );
if ( range != null )
{
if ( !range.matches( PATTERN_RANGE ) )
{
return HttpServletResponse.SC_REQUESTED_RANGE_NOT_SATISFIABLE;
}
final String ifRange = request.getHeader( HttpHeaders.IF_RANGE );
if ( ifRange != null && !ifRange.equals( eTag ) )
{
long ifRangeValue = request.getDateHeader( HttpHeaders.IF_RANGE );
if ( ifRangeValue != -1 && ( ifRangeValue + 1000 < file.lastModified() ) )
{
ranges.add( root );
}
}
if ( ranges.isEmpty() )
{
for ( String part : range.substring( 6 ).split( "," ) )
{
int index = part.indexOf( "-" );
long start = splitLong( part, 0, index );
long end = splitLong( part, index + 1, part.length() );
if ( start == -1 )
{
start = length - end;
end = length - 1;
}
else if ( end == -1 || end > length - 1 )
{
end = length - 1;
}
if ( start > end )
{
return HttpServletResponse.SC_REQUESTED_RANGE_NOT_SATISFIABLE;
}
ranges.add( new Range( start, end, length ) );
}
}
}
return 0;
}
private static boolean acceptsGZip( final HttpServletRequest request, final HttpServletResponse response, final String contentType,
final String filename, final File file, final String eTag )
{
boolean acceptsGzip = false;
String disposition = "inline";
if ( contentType.startsWith( "text" ) || contentType.contains( "javascript" ) )
{
final String acceptEncoding = request.getHeader( HttpHeaders.ACCEPT_ENCODING );
acceptsGzip = acceptEncoding != null && HttpServletUtil.checkHeaderContainsValue( acceptEncoding, "gzip" );
// contentType += ";charset=UTF-8";
disposition = "inline";
}
else if ( !contentType.startsWith( "image" ) )
{
final String accept = request.getHeader( HttpHeaders.ACCEPT );
disposition = accept != null && HttpServletUtil.checkHeaderContainsValue( accept, contentType ) ? "inline" : "attachment";
}
response.setHeader( HttpHeaders.ACCEPT_RANGES, "bytes" );
response.setBufferSize( DEFAULT_BUFFER_SIZE );
if ( !response.containsHeader( HttpHeaders.PRAGMA ) )
{
response.setHeader( HttpHeaders.ETAG, eTag );
response.setDateHeader( HttpHeaders.LAST_MODIFIED, file.lastModified() );
if ( !response.containsHeader( HttpHeaders.EXPIRES ) )
{
setExpiresHeader( response );
}
}
if ( !response.containsHeader( HttpHeaders.CONTENT_DISPOSITION ) )
{
final String header = String.format( "%s;filename=\"%s\"", disposition, filename );
response.setHeader( HttpHeaders.CONTENT_DISPOSITION, header );
}
return acceptsGzip;
}
private static void processDownload( final HttpServletResponse response, final String contentType, final File file,
final List<Range> ranges, final boolean acceptGzip, final boolean attachment,
final String filename )
throws IOException
{
RandomAccessFile input = null;
OutputStream output = null;
boolean aborted = false;
try
{
input = new RandomAccessFile( file, "r" );
output = response.getOutputStream();
if ( ranges.isEmpty() || ranges.get( 0 ).isRoot( file.length() ) )
{
final Range root = new Range( 0, file.length() - 1, file.length() );
response.setContentType( contentType );
HttpServletUtil.setContentDisposition( response, attachment, filename );
// do not send CONTENT_RANGE for HTTP 200
// String header = String.format( "bytes %d-%d/%d", root.getStart(), root.getEnd(), root.getTotal() );
// response.setHeader( HttpHeaders.CONTENT_RANGE, header );
if ( acceptGzip )
{
response.setHeader( HttpHeaders.CONTENT_ENCODING, "gzip" );
output = new GZIPOutputStream( output, DEFAULT_BUFFER_SIZE );
}
else
{
response.setHeader( HttpHeaders.CONTENT_LENGTH, String.valueOf( root.getLength() ) );
}
// Copy complete file
aborted = copy( input, output, root );
}
else if ( ranges.size() == 1 )
{
final Range section = ranges.get( 0 );
response.setContentType( contentType );
HttpServletUtil.setContentDisposition( response, attachment, filename );
response.setStatus( HttpServletResponse.SC_PARTIAL_CONTENT );
String header = String.format( "bytes %d-%d/%d", section.getStart(), section.getEnd(), section.getTotal() );
response.setHeader( HttpHeaders.CONTENT_RANGE, header );
response.setHeader( HttpHeaders.CONTENT_LENGTH, String.valueOf( section.getLength() ) );
// Copy single section
aborted = copy( input, output, section );
}
else
{
response.setStatus( HttpServletResponse.SC_PARTIAL_CONTENT );
response.setContentType( "multipart/byteranges; boundary=" + SEPARATOR );
for ( final Range section : ranges )
{
write( output, "" );
write( output, "--" + SEPARATOR );
write( output, "Content-Type: " + contentType );
write( output, "Content-Range: bytes " + section.getStart() + "-" + section.getEnd() + "/" + section.getTotal() );
write( output, "" );
// Copy multiple sections
aborted = copy( input, output, section );
if ( aborted )
{
break;
}
}
if ( !aborted )
{
write( output, "" );
write( output, "--" + SEPARATOR + "--" );
}
}
}
finally
{
if ( input != null )
{
input.close();
}
if ( output != null && !aborted )
{
output.close();
}
}
}
private static void write( final OutputStream output, final String string )
throws IOException
{
output.write( string.getBytes() );
output.write( "\r\n".getBytes() );
}
private static void setExpiresHeader( final HttpServletResponse response )
{
final DateTime now = new DateTime();
final DateTime expirationDate = now.plusWeeks( 1 );
HttpServletUtil.setExpiresHeader( response, expirationDate.toDate() );
}
private static long splitLong( final String value, final int start, final int finish )
{
final String substring = value.substring( start, finish ).trim();
return ( substring.length() > 0 ) ? Long.parseLong( substring ) : -1;
}
/**
* return true if downloading is aborted
*/
private static boolean copy( final RandomAccessFile input, final OutputStream output, final Range range )
throws IOException
{
try
{
final byte[] buffer = new byte[DEFAULT_BUFFER_SIZE];
int read;
// all
if ( input.length() == range.getLength() )
{
while ( ( read = input.read( buffer ) ) > 0 )
{
output.write( buffer, 0, read );
}
}
// partition
else
{
input.seek( range.getStart() );
long length = range.getLength();
while ( ( read = input.read( buffer ) ) > 0 )
{
if ( ( length -= read ) > 0 )
{
output.write( buffer, 0, read );
}
else
{
output.write( buffer, 0, (int) length + read );
break;
}
}
}
}
catch ( final IOException e )
{
// MS IE may stop downloading ( for example PDF file ) to continue further downloading using Content-Range.
// In this case connection is closed and we will have here ClientAbortException for Apache Tomcat.
// this is typical situation for IE, so stack trace is not written to log/console.
return true; // stop sending content to client
}
return false;
}
}