/*
* Data Hub Service (DHuS) - For Space data distribution.
* Copyright (C) 2013,2014,2015 GAEL Systems
*
* This file is part of DHuS software sources.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program 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 Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package fr.gael.dhus.olingo.v1;
import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;
import org.apache.olingo.odata2.api.commons.HttpStatusCodes;
import org.apache.olingo.odata2.api.processor.ODataContext;
import org.apache.olingo.odata2.api.processor.ODataResponse;
import java.io.IOException;
import java.io.InputStream;
import java.text.SimpleDateFormat;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Date;
import java.util.List;
import java.util.Locale;
/**
* Utility class to create stream OData responses for Media Entities.
*/
public class MediaResponseBuilder
{
private static final Logger LOGGER = LogManager.getLogger(MediaResponseBuilder.class);
public static final long DEFAULT_EXPIRE_TIME = 7 * 24 * 60 * 60 * 1000;
public static final String MULTIPART_BOUNDARY = "MULTIPART_BYTERANGES";
/** Hidden empty constructor. */
private MediaResponseBuilder() {}
/**
* Returns true if the given match header matches the given value.
*
* @param match_header The match header.
* @param to_match The value to be matched.
* @return True if the given match header matches the given value.
*/
private static boolean matches (String match_header, String to_match)
{
String[] matchValues = match_header.split ("\\s*,\\s*");
Arrays.sort (matchValues);
return Arrays.binarySearch (matchValues, to_match) > -1 ||
Arrays.binarySearch (matchValues, "*") > -1;
}
/**
* Returns a substring of the given string value from the given begin index
* to the given end index as a long. If the substring is empty, then -1 will
* be returned.
*
* @param value The string value to return a substring as long for.
* @param begin_index The begin index of the substring to be returned
* as long.
* @param end_index The end index of the substring to be returned as long.
* @return A substring of the given string value as long or -1 if substring
* is empty.
*/
private static long sublong (String value, int begin_index, int end_index)
{
String substring = value.substring (begin_index, end_index);
return (substring.length () > 0) ? Long.parseLong (substring) : -1;
}
/**
* Returns true if the given accept header accepts the given value.
*
* @param accept_header The accept header.
* @param to_accept The value to be accepted.
* @return True if the given accept header accepts the given value.
*/
private static boolean accepts (String accept_header, String to_accept)
{
String[] acceptValues = accept_header.split ("\\s*(,|;)\\s*");
Arrays.sort (acceptValues);
return
Arrays.binarySearch (acceptValues, to_accept) > -1 ||
Arrays.binarySearch (acceptValues,
to_accept.replaceAll ("/.*$", "/*")) > -1 ||
Arrays.binarySearch (acceptValues, "*/*") > -1;
}
/**
* Returns string representation of the HTTP defined RFC 1123 date format.
*
* @param date to parse
* @return the long value of date since 1st January 1970
*/
private static String asHttpDate (long date)
{
SimpleDateFormat dateFormat =
new SimpleDateFormat ("EEE, dd MMM yyyy HH:mm:ss z", Locale.US);
return dateFormat.format (new Date (date));
}
/**
* Returns long representation of the HTTP defined RFC 1123 date format.
*
* @param date to parse
* @return the long value of date since 1st January 1970
*/
private static long getHttpDate (String date)
{
SimpleDateFormat dateFormat =
new SimpleDateFormat ("EEE, dd MMM yyyy HH:mm:ss z", Locale.US);
try
{
return dateFormat.parse (date).getTime ();
}
catch (Exception e)
{
return -1;
}
}
/**
* Builds an HTTP Response according to the current http context header. The
* response is suitable for partial transfers and resume. It checks the
* context and properly returns the status. The passed input stream is used
* to seek the current position if required by the header.
*
* @param eTag the hashcode unique for the data to be transfered.
* @param filename the name of data being transfered.
* @param contentType the type of data being transfered.
* @param lastModified timestamp in milliseconds of the last modification
* date of the data to be transfered.
* @param contentLength full size of the input data. The size to be
* transfered data will be adapted according to the range settings defined
* in the header.
* @param context the HTTP context that contains the request header.
* @param stream the stream used for the transfer.
* @return the response header to be transfered for the transfer.
*/
public static ODataResponse prepareMediaResponse (String eTag,
String filename, String contentType, long lastModified,
long contentLength, ODataContext context, InputStream stream)
{
ODataResponse.ODataResponseBuilder builder = ODataResponse.newBuilder ();
// Validate request headers for caching ----------------------------------
// If-None-Match header should contain "*" or ETag. If so, then return 304
String ifNoneMatch = context.getRequestHeader ("If-None-Match");
if (ifNoneMatch != null && matches (ifNoneMatch, eTag))
{
builder.header ("ETag", eTag);
builder.status (HttpStatusCodes.NOT_MODIFIED);
builder.entity (stream);
return builder.build ();
}
/*
* If-Modified-Since header should be greater than LastModified. If so,
* then return 304.
* This header is ignored if any If-None-Match header is specified.
*/
long ifModifiedSince = getHttpDate (
context.getRequestHeader ("If-Modified-Since"));
if ( (ifNoneMatch == null) && (ifModifiedSince != -1) &&
(ifModifiedSince + 1000 > lastModified))
{
builder.header ("ETag", eTag);
builder.status (HttpStatusCodes.NOT_MODIFIED);
builder.entity (stream);
return builder.build ();
}
// Validate request headers for resume -----------------------------------
// If-Match header should contain "*" or ETag. If not, then return 412.
String ifMatch = context.getRequestHeader ("If-Match");
if ( (ifMatch != null) && !matches (ifMatch, eTag))
{
builder.status (HttpStatusCodes.PRECONDITION_FAILED);
builder.entity (stream);
return builder.build ();
}
/*
* If-Unmodified-Since header should be greater than LastModified.
* If not, then return 412.
*/
long ifUnmodifiedSince =
getHttpDate (context.getRequestHeader ("If-Unmodified-Since"));
if ( (ifUnmodifiedSince != -1) &&
(ifUnmodifiedSince + 1000 <= lastModified))
{
builder.status (HttpStatusCodes.PRECONDITION_FAILED);
builder.entity (stream);
return builder.build ();
}
// Validate and process range --------------------------------------------
// Prepare some variables. The full Range represents the complete file.
Range full = new Range (0, contentLength - 1, contentLength);
List<Range> rangeList = new ArrayList<> ();
// Validate and process Range and If-Range headers.
String range = context.getRequestHeader ("Range");
if (range != null)
{
String ifRange = context.getRequestHeader ("If-Range");
if ( (ifRange != null) && !ifRange.equals (eTag))
{
rangeList.add (full);
}
/*
* Range header should match format "bytes=n-n,n-n,n-n...".
* If not, then return 416.
*/
if ( !range.matches ("^bytes=\\d*-\\d*(,\\d*-\\d*)*$"))
{
builder.header ("Content-Range", "bytes */" + contentLength);
builder.status (HttpStatusCodes.REQUESTED_RANGE_NOT_SATISFIABLE);
builder.entity (stream);
return builder.build ();
}
// If any valid If-Range header, then process each part of byte range.
if (rangeList.isEmpty ())
{
for (String part : range.substring (6).split (","))
{
/*
* Assuming a file with length of 100, the following examples
* returns bytes at:
* 50-80 (50 to 80), 40- (40 to length=100),
* -20 (length-20=80 to length=100).
*/
long start = MediaResponseBuilder.sublong (part, 0, part.indexOf ("-"));
long end =
MediaResponseBuilder.sublong (part, part.indexOf ("-") + 1, part.length ());
if (start == -1)
{
start = contentLength - end;
end = contentLength - 1;
}
else
if (end == -1 || end > contentLength - 1)
{
end = contentLength - 1;
}
// Check if Range is syntactically valid. If not, then return 416
if (start > end)
{
builder.header ("Content-Range", "bytes */" + contentLength);
builder.status (
HttpStatusCodes.REQUESTED_RANGE_NOT_SATISFIABLE);
builder.entity (stream);
return builder.build ();
}
// Add range.
rangeList.add (new Range (start, end, contentLength));
}
}
}
// Prepare and initialize response ---------------------------------------
String disposition = "inline";
/*
* If content type is unknown, then set the default value. For all content
* types, see: http://www.w3schools.com/media/media_mimeref.asp To add new
* content types, add new mime-mapping entry in web.xml.
*/
if (contentType == null)
{
contentType = "application/octet-stream";
}
else
{
/*
* Else, expect for images, determine content disposition.
* If content type is supported by the browser, then set to inline,
* else attachment which will pop a 'save as' dialogue.
*/
if (contentType.startsWith ("image"))
{
String acccept = context.getRequestHeader ("Accept");
disposition = (acccept != null && accepts (acccept, contentType)) ?
"inline" : "attachment";
}
}
// Initialize response
builder.header ("Content-Disposition",
disposition + ";filename=\"" + filename + "\"");
builder.header ("Accept-Ranges", "bytes");
builder.header ("ETag", eTag);
builder.header ("Last-Modified", asHttpDate (lastModified));
builder.header ("Expires", asHttpDate (System.currentTimeMillis () + MediaResponseBuilder.DEFAULT_EXPIRE_TIME));
if (rangeList.isEmpty () || rangeList.size () == 1)
{
HttpStatusCodes status=HttpStatusCodes.OK;
Range r;
if (rangeList.isEmpty ())
{
r = full;
}
else
{
r = rangeList.get (0);
status=HttpStatusCodes.PARTIAL_CONTENT;
}
builder.header ("Content-Type", contentType);
builder.header ("Content-Range", "bytes " + r.start + "-" + r.end +
"/" + r.total);
builder.header ("Content-Length", String.valueOf (r.length));
try
{
stream.skip (r.start);
builder.status (status); // 206 or 200
}
catch (IOException e)
{
LOGGER.error ("Cannot skip input stream of " + filename +
" to offset " + r.start);
builder
.status (HttpStatusCodes.REQUESTED_RANGE_NOT_SATISFIABLE);
}
builder.entity (stream);
return builder.build ();
}
else
{
// Return multiple parts of file.
builder.header ("Content-Type", "multipart/byteranges; boundary=" +
MediaResponseBuilder.MULTIPART_BOUNDARY);
builder.status (HttpStatusCodes.NOT_IMPLEMENTED);
LOGGER.error ("MULTIPART NOT SUPPORTED !");
builder.entity (stream);
return builder.build ();
}
}
private static class Range
{
long start;
long end;
long length;
long total;
/**
* Construct a byte range.
*
* @param start Start of the byte range.
* @param end End of the byte range.
* @param total Total length of the byte source.
*/
public Range (long start, long end, long total)
{
this.start = start;
this.end = end;
this.length = end - start + 1;
this.total = total;
}
}
}