Skip to content

Commit

Permalink
feat: add support for Range header to StaticFileServer (#9154)
Browse files Browse the repository at this point in the history
A Range header can specify either a single byte range, e.g. Range: bytes=0-99, or multiple ranges,
e.g. Range: bytes=0-99, 200-299. The ranges do not need to be sequential and can have overlap;
e.g. Range: bytes=200-299, 0-219 is valid; such requests will open a new stream reading from the
beginning of the file for each out-of-order range. The motivation for this feature is to support video
playback on Safari.

Fixes #9083
  • Loading branch information
Johannes Eriksson committed Oct 21, 2020
1 parent 9f0aee6 commit d2c1311
Show file tree
Hide file tree
Showing 2 changed files with 352 additions and 16 deletions.
191 changes: 175 additions & 16 deletions flow-server/src/main/java/com/vaadin/flow/internal/ResponseWriter.java
Original file line number Diff line number Diff line change
Expand Up @@ -22,12 +22,18 @@
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;

import java.io.Closeable;
import java.io.IOException;
import java.io.InputStream;
import java.io.Serializable;
import java.net.MalformedURLException;
import java.net.URL;
import java.net.URLConnection;
import java.util.ArrayList;
import java.util.List;
import java.util.UUID;
import java.util.regex.Matcher;
import java.util.regex.Pattern;

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
Expand All @@ -45,6 +51,9 @@
public class ResponseWriter implements Serializable {
private static final int DEFAULT_BUFFER_SIZE = 32 * 1024;

private static final Pattern RANGE_HEADER_PATTERN = Pattern.compile("^bytes=(([0-9]+-[0-9]+,?\\s*)+)$");
private static final Pattern BYTE_RANGE_PATTERN = Pattern.compile("([0-9]+)-([0-9]+)");

private final int bufferSize;
private final boolean brotliEnabled;

Expand Down Expand Up @@ -84,13 +93,14 @@ public void writeResponseContents(String filenameWithPath, URL resourceUrl,
throws IOException {
writeContentType(filenameWithPath, request, response);

URL url = null;
URLConnection connection = null;
InputStream dataStream = null;

if (brotliEnabled && acceptsBrotliResource(request)) {
String brotliFilenameWithPath = filenameWithPath + ".br";
try {
URL url = getResource(request, brotliFilenameWithPath);
url = getResource(request, brotliFilenameWithPath);
if (url != null) {
connection = url.openConnection();
dataStream = connection.getInputStream();
Expand All @@ -107,7 +117,7 @@ public void writeResponseContents(String filenameWithPath, URL resourceUrl,
// try to serve a gzipped version if available
String gzippedFilenameWithPath = filenameWithPath + ".gz";
try {
URL url = getResource(request, gzippedFilenameWithPath);
url = getResource(request, gzippedFilenameWithPath);
if (url != null) {
connection = url.openConnection();
dataStream = connection.getInputStream();
Expand All @@ -122,31 +132,177 @@ public void writeResponseContents(String filenameWithPath, URL resourceUrl,

if (dataStream == null) {
// compressed resource not available, get non compressed
url = resourceUrl;
connection = resourceUrl.openConnection();
dataStream = connection.getInputStream();
} else {
response.setHeader("Vary", "Accept-Encoding");
}

try {
long length = connection.getContentLengthLong();
if (length >= 0L) {
response.setContentLengthLong(length);
String range = request.getHeader("Range");
if (range != null) {
closeStream(dataStream);
dataStream = null;
writeRangeContents(range, response, url);
} else {
final long contentLength = connection.getContentLengthLong();
if (0 <= contentLength) {
setContentLength(response, contentLength);
}
writeStream(response.getOutputStream(), dataStream,
Long.MAX_VALUE);
}
} catch (IOException e) {
getLogger().debug("Error writing static file to user", e);
} finally {
if (dataStream !=null ) {
closeStream(dataStream);
}
} catch (Exception e) {
getLogger().debug("Error setting the content length", e);
}
}

private void closeStream(Closeable stream) {
try {
writeStream(response.getOutputStream(), dataStream);
stream.close();
} catch (IOException e) {
getLogger().debug("Error writing static file to user", e);
} finally {
getLogger().debug("Error closing input stream for resource", e);
}
}

/**
* Handle a "Header:" request. The handling logic is splits on single or
* multiple ranges: for a single range, send a regular response with
* Content-Length; for multiple ranges, send a "Content-Type:
* multipart/byteranges" response. If the byte ranges are satisfiable, the
* response code is 206, otherwise it is 416. See e.g.
* https://developer.mozilla.org/en-US/docs/Web/HTTP/Range_requests for
* protocol details.
*/
private void writeRangeContents(String range, HttpServletResponse response,
URL resourceURL) throws IOException {
response.setHeader("Accept-Ranges", "bytes");

URLConnection connection = resourceURL.openConnection();

Matcher headerMatcher = RANGE_HEADER_PATTERN.matcher(range);
if (!headerMatcher.matches()) {
response.setContentLengthLong(0L);
response.setStatus(416); // Range Not Satisfiable
return;
}
String byteRanges = headerMatcher.group(1);

long resourceLength = connection.getContentLengthLong();
Matcher rangeMatcher = BYTE_RANGE_PATTERN.matcher(byteRanges);

List<Pair<Long, Long>> ranges = new ArrayList<>();
while (rangeMatcher.find()) {
final long start = Long.parseLong(rangeMatcher.group(1));
final long end = Long.parseLong(rangeMatcher.group(2));
if (end < start
|| (resourceLength >= 0 && start >= resourceLength)) {
// illegal range -> 416
response.setContentLengthLong(0L);
response.setStatus(416);
return;
}
ranges.add(new Pair<>(start, end));
}

response.setStatus(206);

if (ranges.size() == 1) {
ServletOutputStream outputStream = response.getOutputStream();

// single range: calculate Content-Length
long start = ranges.get(0).getFirst();
long end = ranges.get(0).getSecond();
if (resourceLength >= 0) {
end = Math.min(end, resourceLength - 1);
}
setContentLength(response, end - start + 1);
response.setHeader("Content-Range",
createContentRangeHeader(start, end, resourceLength));

final InputStream dataStream = connection.getInputStream();
try {
dataStream.close();
} catch (IOException e) {
getLogger().debug("Error closing input stream for resource", e);
long skipped = dataStream.skip(start);
assert(skipped == start);
writeStream(outputStream, dataStream, end - start + 1);
} finally {
closeStream(dataStream);
}
} else {
writeMultipartRangeContents(ranges, connection, response,
resourceURL);
}
}

/**
* Write a multi-part request with MIME type "multipart/byteranges",
* separated by boundaries and use "Transfer-Encoding: chunked" mode to
* avoid computing "Content-Length".
*/
private void writeMultipartRangeContents(List<Pair<Long, Long>> ranges,
URLConnection connection, HttpServletResponse response,
URL resourceURL) throws IOException {
String partBoundary = UUID.randomUUID().toString();
response.setContentType(String
.format("multipart/byteranges; boundary=%s", partBoundary));
response.setHeader("Transfer-Encoding", "chunked");

long position = 0L;
String mimeType = response.getContentType();
InputStream dataStream = connection.getInputStream();
ServletOutputStream outputStream = response.getOutputStream();
try {
for (Pair<Long, Long> rangePair : ranges) {
outputStream.write(
String.format("\r\n--%s\r\n", partBoundary).getBytes());
long start = rangePair.getFirst();
long end = rangePair.getSecond();
if (mimeType != null) {
outputStream.write(
String.format("Content-Type: %s\r\n", mimeType)
.getBytes());
}
outputStream.write(String
.format("Content-Range: %s\r\n\r\n",
createContentRangeHeader(start, end,
connection.getContentLengthLong()))
.getBytes());

if (position > start) {
// out-of-sequence range -> open new stream to the file
// alternative: use single stream with mark / reset
closeStream(connection.getInputStream());
connection = resourceURL.openConnection();
dataStream = connection.getInputStream();
position = 0L;
}
long skipped = dataStream.skip(start - position);
assert(skipped == start - position);
writeStream(outputStream, dataStream, end - start + 1);
position = end + 1;
}
} finally {
closeStream(dataStream);
}
outputStream.write(String.format("\r\n--%s", partBoundary).getBytes());
}

private String createContentRangeHeader(long start, long end, long size) {
String lengthString = size >= 0 ? Long.toString(size) : "*";
return String.format("bytes %d-%d/%s", start, end, lengthString);
}

private void setContentLength(HttpServletResponse response,
long contentLength) {
try {
response.setContentLengthLong(contentLength);
} catch (Exception e) {
getLogger().debug("Error setting the content length", e);
}
}

Expand Down Expand Up @@ -186,13 +342,16 @@ private boolean isAllowedVAADINBuildUrl(String filenameWithPath) {
}

private void writeStream(ServletOutputStream outputStream,
InputStream inputStream) throws IOException {
InputStream dataStream, long count) throws IOException {
final byte[] buffer = new byte[bufferSize];

long bytesTotal = 0L;
int bytes;
while ((bytes = inputStream.read(buffer)) >= 0) {
while (bytesTotal < count && (bytes = dataStream.read(buffer, 0,
(int)Long.min(bufferSize, count - bytesTotal))) >= 0) {
outputStream.write(buffer, 0, bytes);
bytesTotal += bytes;
}

}

/**
Expand Down
Loading

0 comments on commit d2c1311

Please sign in to comment.