Coverage Summary for Class: RangeStreamContentHandler (org.kitodo.production.handler)
Class |
Class, %
|
Method, %
|
Line, %
|
RangeStreamContentHandler |
100%
(1/1)
|
100%
(7/7)
|
65%
(76/117)
|
/*
* (c) Kitodo. Key to digital objects e. V. <contact@kitodo.org>
*
* This file is part of the Kitodo project.
*
* It is licensed under GNU General Public License version 3 or later.
*
* For the full copyright and license information, please read the
* GPL3-License.txt file that was distributed with this source code.
*/
package org.kitodo.production.handler;
import static org.kitodo.production.helper.RangeStreamHelper.DEFAULT_BUFFER_SIZE;
import static org.kitodo.production.helper.RangeStreamHelper.copy;
import static org.kitodo.production.helper.RangeStreamHelper.sublong;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import javax.el.ValueExpression;
import javax.faces.context.ExternalContext;
import javax.faces.context.FacesContext;
import javax.servlet.ServletOutputStream;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;
import org.apache.logging.log4j.message.ParameterizedMessage;
import org.kitodo.production.beans.Range;
import org.primefaces.application.resource.BaseDynamicContentHandler;
import org.primefaces.model.StreamedContent;
import org.primefaces.util.Constants;
/**
* This handler allows to stream files e.g. video and audio partially to the browser. Only parts of the file that are
* requested by the browser are returned. This means, for example, that a video does not have to be loaded completely,
* but byte parts are returned as a response of a so-called HTTP range requests.
*
* <p>For example, in HTML video component you can jump back and forth in the player without the file being completely
* loaded or the player is jumping back to the beginning. At the current requested position we preload the file with a
* small buffer size.</p>
*/
public class RangeStreamContentHandler extends BaseDynamicContentHandler {
private static final String MULTIPART_BOUNDARY = "MULTIPART_BYTERANGES";
private static final String CLIENT_ABORT_EXCEPTION_CANONICAL_NAME = "org.apache.catalina.connector.ClientAbortException";
private static final Logger logger = LogManager.getLogger(RangeStreamContentHandler.class);
@Override
public void handle(FacesContext context) throws IOException {
Map<String, String> params = context.getExternalContext().getRequestParameterMap();
String library = params.get("ln");
String resourceKey = params.get(Constants.DYNAMIC_CONTENT_PARAM);
if (Objects.nonNull(resourceKey) && Objects.nonNull(library) && library.equals(Constants.LIBRARY)) {
StreamedContent streamedContent = null;
boolean cache = Boolean.parseBoolean(params.get(Constants.DYNAMIC_CONTENT_CACHE_PARAM));
ExternalContext externalContext = context.getExternalContext();
try {
Map<String, Object> session = externalContext.getSessionMap();
Map<String, String> dynamicResourcesMapping = (Map) session.get(Constants.DYNAMIC_RESOURCES_MAPPING);
if (Objects.nonNull(dynamicResourcesMapping)) {
String dynamicContentEL = dynamicResourcesMapping.get(resourceKey);
if (Objects.nonNull(dynamicContentEL)) {
ValueExpression ve = context.getApplication().getExpressionFactory()
.createValueExpression(context.getELContext(), dynamicContentEL, StreamedContent.class);
streamedContent = (StreamedContent) ve.getValue(context.getELContext());
if (isErrorResponse(streamedContent, externalContext)) {
return;
}
handleCache(externalContext, cache);
process(streamedContent, externalContext);
}
}
externalContext.responseFlushBuffer();
context.responseComplete();
} catch (Exception e) {
if (CLIENT_ABORT_EXCEPTION_CANONICAL_NAME.equals(e.getClass()
.getCanonicalName()) && externalContext.getRequest() instanceof HttpServletRequest) {
final HttpServletRequest request = (HttpServletRequest) externalContext.getRequest();
logger.warn(new ParameterizedMessage("ClientAbortException generated by request {} {}",
request.getMethod(), request.getRequestURL().toString()));
} else {
logger.error("Error in streaming dynamic resource.");
throw new IOException(e);
}
} finally {
if (Objects.nonNull(streamedContent) && Objects.nonNull(streamedContent.getStream())) {
streamedContent.getStream().close();
}
}
}
}
private boolean isErrorResponse(StreamedContent streamedContent, ExternalContext externalContext)
throws IOException {
if (Objects.isNull(streamedContent) || Objects.isNull(streamedContent.getStream())) {
if (externalContext.getRequest() instanceof HttpServletRequest) {
externalContext.responseSendError(HttpServletResponse.SC_NOT_FOUND,
((HttpServletRequest) externalContext.getRequest()).getRequestURI());
} else {
externalContext.responseSendError(HttpServletResponse.SC_NOT_FOUND, null);
}
return true;
}
return false;
}
private void process(StreamedContent streamedContent, ExternalContext externalContext) throws IOException {
externalContext.setResponseStatus(HttpServletResponse.SC_OK);
if (Objects.nonNull(streamedContent.getContentEncoding())) {
externalContext.setResponseHeader("Content-Encoding", streamedContent.getContentEncoding());
}
// Adapt implementation of Warren Dew
// (https://stackoverflow.com/questions/28427339/how-to-implement-http-byte-range-requests-in-spring-mvc)
// using org.primefaces.application.resource.StreamedContentHandler
HttpServletResponse response = (HttpServletResponse) externalContext.getResponse();
if (Objects.nonNull(streamedContent.getName())) {
response.setHeader("Content-Disposition", "inline;filename=\"" + streamedContent.getName() + "\"");
response.setHeader("ETag", streamedContent.getName());
}
response.setHeader("Accept-Ranges", "bytes");
response.setBufferSize(DEFAULT_BUFFER_SIZE);
processInputStreamToOutputStream((HttpServletRequest) externalContext.getRequest(), response, streamedContent,
externalContext.getResponseOutputStream());
}
private void processInputStreamToOutputStream(HttpServletRequest request, HttpServletResponse response,
StreamedContent streamedContent, OutputStream outputStream) throws IOException {
InputStream inputStream = streamedContent.getStream();
// Prepare some variables. The full Range represents the complete file.
int length = inputStream.available(); // Length of file
Range full = new Range(0, length - 1, length);
List<Range> ranges = getRanges(request, response, length, streamedContent.getName());
if (ranges.isEmpty() || Objects.equals(ranges.get(0), full)) {
// Return full file.
logger.info("Return full file");
response.setContentType(streamedContent.getContentType());
response.setHeader("Content-Range",
"bytes " + full.getStart() + "-" + full.getEnd() + "/" + full.getTotal());
response.setHeader("Content-Length", String.valueOf(full.getLength()));
copy(inputStream, outputStream, length, full.getStart(), full.getLength());
} else if (ranges.size() == 1) {
// Return single part of file.
Range r = ranges.get(0);
logger.info("Returning part of file : from (" + r.getStart() + ") to (" + r.getEnd() + ")");
response.setContentType(streamedContent.getContentType());
response.setHeader("Content-Range", "bytes " + r.getStart() + "-" + r.getEnd() + "/" + r.getTotal());
response.setHeader("Content-Length", String.valueOf(r.getLength()));
response.setStatus(HttpServletResponse.SC_PARTIAL_CONTENT); // 206.
// Copy single part range.
copy(inputStream, outputStream, length, r.getStart(), r.getLength());
} else {
// Return multiple parts of file.
response.setContentType("multipart/byteranges; boundary=" + MULTIPART_BOUNDARY);
response.setStatus(HttpServletResponse.SC_PARTIAL_CONTENT); // 206.
// Cast back to ServletOutputStream to get the easy println methods.
ServletOutputStream servletOutputStream = (ServletOutputStream) outputStream;
// Copy multi part range.
for (Range r : ranges) {
logger.info("Return multi part of file : from (" + r.getStart() + ") to (" + r.getEnd() + ")");
// Add multipart boundary and header fields for every range.
servletOutputStream.println();
servletOutputStream.println("--" + MULTIPART_BOUNDARY);
servletOutputStream.println("Content-Type: " + streamedContent.getContentType());
servletOutputStream.println(
"Content-Range: bytes " + r.getStart() + "-" + r.getEnd() + "/" + r.getTotal());
// Copy single part range of multipart range.
copy(inputStream, outputStream, length, r.getStart(), r.getLength());
}
// End with multipart boundary.
servletOutputStream.println();
servletOutputStream.println("--" + MULTIPART_BOUNDARY + "--");
}
}
private static List<Range> getRanges(HttpServletRequest request, HttpServletResponse response, int length,
String fileName) throws IOException {
Range full = new Range(0, length - 1, length);
List<Range> ranges = new ArrayList<>();
// Validate and process Range and If-Range headers.
String range = request.getHeader("Range");
if (Objects.nonNull(range)) {
// 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*)*$")) {
response.setHeader("Content-Range", "bytes */" + length); // Required in 416.
response.sendError(HttpServletResponse.SC_REQUESTED_RANGE_NOT_SATISFIABLE);
return ranges;
}
String ifRange = request.getHeader("If-Range");
if (Objects.nonNull(ifRange) && !ifRange.equals(fileName)) {
try {
long ifRangeTime = request.getDateHeader("If-Range"); // Throws IAE if invalid.
if (ifRangeTime != -1) {
ranges.add(full);
}
} catch (IllegalArgumentException ignore) {
ranges.add(full);
}
}
// If any valid If-Range header, then process each part of byte range.
if (ranges.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 = sublong(part, 0, part.indexOf("-"));
long end = sublong(part, part.indexOf("-") + 1, part.length());
if (start == -1) {
start = length - end;
end = length - 1;
} else if (end == -1 || end > length - 1) {
end = length - 1;
}
// Check if Range is syntactically valid. If not, then return 416.
if (start > end) {
response.setHeader("Content-Range", "bytes */" + length); // Required in 416.
response.sendError(HttpServletResponse.SC_REQUESTED_RANGE_NOT_SATISFIABLE);
return ranges;
}
// Add range.
ranges.add(new Range(start, end, length));
}
}
}
return ranges;
}
}