Skip to content

Commit

Permalink
Add partial support for the 'Range' header when serving static files.
Browse files Browse the repository at this point in the history
This includes

* Parsing and validation of the most common forms[0] of the `Range`
  header, namely `bytes=0-1000` and `bytes=1001-`, for serving slices of
  files and enabling resumable downloads, etc.

* Support for returning partial files matching these slices on Unix
  platforms.

* Support for returning the appropriate `Content-Range` header and HTTP
  206 responses for these requests.

* Tests for these features (some of which are skipped on Windows).

It does not support Windows as of yet, and there is no support for
multipart ranges[2] or the `If-Range` header[3].

Since `Range` header support is always optional (a server can just
respond with the whole file and a HTTP 200 instead), we just fall back
on existing behaviour in these cases instead of issuing some kind of
error.

Along these same lines, we don't yet advertise that we support the
`Range` header by sending an `Accept-Ranges: bytes` header[4] on other
GET/HEAD requests.

Closes rstudio#259.

[0]: https://tools.ietf.org/html/rfc7233#section-3.1
[2]: https://tools.ietf.org/html/rfc7233#appendix-A
[3]: https://tools.ietf.org/html/rfc7233#section-3.2
[4]: https://tools.ietf.org/html/rfc7233#section-2.3
  • Loading branch information
atheriel committed Dec 3, 2020
1 parent cf5e0de commit eea5ae0
Show file tree
Hide file tree
Showing 6 changed files with 196 additions and 3 deletions.
2 changes: 2 additions & 0 deletions NEWS.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@ httpuv 1.5.4.9000

* Allow responses to omit `body` (or set it as `NULL`) to avoid sending a body or setting the `Content-Length` header. This is intended for use with HTTP 204/304 responses. (#288)

* Resolved #259: Static files now support common range requests on non-Windows platforms. (#290)

httpuv 1.5.4
============

Expand Down
22 changes: 20 additions & 2 deletions src/filedatasource-unix.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,8 @@ FileDataSourceResult FileDataSource::initialize(const std::string& path, bool ow
return FDS_ISDIR;
}

_length = info.st_size;
_payloadSize = info.st_size;
_fsize = info.st_size;

if (owned && unlink(path.c_str())) {
// Print this (on either main or background thread), since we're not
Expand All @@ -49,7 +50,24 @@ FileDataSourceResult FileDataSource::initialize(const std::string& path, bool ow
}

uint64_t FileDataSource::size() const {
return _length;
return _payloadSize;
}

uint64_t FileDataSource::fileSize() const {
return _fsize;
}

bool FileDataSource::setRange(size_t start, size_t end) {
ASSERT_BACKGROUND_THREAD()
if (end > _fsize || end < start) {
return false;
}
if (lseek(_fd, start, SEEK_SET) < 0) {
err_printf("Error in lseek: %d\n", errno);
return false;
}
_payloadSize = end - start + 1;
return true;
}

uv_buf_t FileDataSource::getData(size_t bytesDesired) {
Expand Down
8 changes: 8 additions & 0 deletions src/filedatasource-win.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,14 @@ uint64_t FileDataSource::size() const {
return _length.QuadPart;
}

uint64_t FileDataSource::fileSize() const {
return _length.QuadPart;
}

bool FileDataSource::setRange(size_t start, size_t end) {
return false;
}

uv_buf_t FileDataSource::getData(size_t bytesDesired) {
ASSERT_BACKGROUND_THREAD()
if (bytesDesired == 0)
Expand Down
8 changes: 7 additions & 1 deletion src/filedatasource.h
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,8 @@ class FileDataSource : public DataSource {
LARGE_INTEGER _length;
#else
int _fd;
off_t _length;
off_t _payloadSize;
off_t _fsize;
#endif
std::string _lastErrorMessage;

Expand All @@ -31,7 +32,12 @@ class FileDataSource : public DataSource {
}

FileDataSourceResult initialize(const std::string& path, bool owned);
// Total size of the data in this source, in bytes, after accounting for file
// size and range settings.
uint64_t size() const;
// Total size of the underlying file in this source, in bytes.
uint64_t fileSize() const;
bool setRange(size_t start, size_t end);
uv_buf_t getData(size_t bytesDesired);
void freeData(uv_buf_t buffer);
// Get the mtime of the file. If there's an error, return 0.
Expand Down
52 changes: 52 additions & 0 deletions src/webapplication.cpp
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
#include <boost/bind.hpp>
#include <boost/shared_ptr.hpp>
#include <boost/make_shared.hpp>
#include <regex>
#include "httpuv.h"
#include "filedatasource.h"
#include "webapplication.h"
Expand Down Expand Up @@ -563,6 +564,46 @@ boost::shared_ptr<HttpResponse> RWebApplication::staticFileResponse(
}
}

// Check the HTTP Range header, if it has been specified.
std::smatch rangeMatch;
bool hasRange = false;
size_t start = 0;
size_t end = pDataSource->size() - 1;
if (pRequest->hasHeader("Range")) {
std::string rangeHeader = pRequest->getHeader("Range");
// This is a limited form of the HTTP Range header, so don't complain to the
// client if it's malformed -- just silently ignore it instead.
if (std::regex_search(rangeHeader, rangeMatch, std::regex("bytes=(\\d+)-(\\d+)?$"))) {
start = std::stoi(rangeMatch[1].str().c_str());
if (!rangeMatch[2].str().empty()) {
end = std::stoi(rangeMatch[2].str().c_str());
}
if (end < start) {
// HTTP 416 seems to be for syntactically valid but otherwise
// unfulfillable requests, so use HTTP 400 for bad syntax instead.
return error_response(pRequest, 400);
}
if (start >= pDataSource->size()) {
boost::shared_ptr<HttpResponse> pResponse = error_response(pRequest, 416);
pResponse->headers().push_back(
std::make_pair("Content-Range", std::string("bytes */") + toString(pDataSource->fileSize()))
);
return pResponse;
}
if (end > pDataSource->size() - 1) {
// The client might not know the size, so the range end is supposed to
// be redefined to be the last byte in this case instead of issuing an
// error.
//
// See: https://tools.ietf.org/html/rfc7233#section-2.1
end = pDataSource->size() - 1;
}
if ((start != 0 || end != pDataSource->size() - 1) && pDataSource->setRange(start, end)) {
hasRange = true;
}
}
}

// ==================================
// Create the HTTP response
// ==================================
Expand All @@ -585,6 +626,10 @@ boost::shared_ptr<HttpResponse> RWebApplication::staticFileResponse(
status_code = 304;
}

if (hasRange) {
status_code = 206;
}

boost::shared_ptr<HttpResponse> pResponse = boost::shared_ptr<HttpResponse>(
new HttpResponse(pRequest, status_code, getStatusDescription(status_code), pDataSource2),
auto_deleter_background<HttpResponse>
Expand Down Expand Up @@ -624,6 +669,13 @@ boost::shared_ptr<HttpResponse> RWebApplication::staticFileResponse(
respHeaders.push_back(std::make_pair("Last-Modified", http_date_string(pDataSource->getMtime())));
}

if (status_code == 206) {
respHeaders.push_back(std::make_pair(
"Content-Range",
"bytes " + toString(start) + "-" + toString(end) + "/" + toString(pDataSource2->fileSize())
));
}

return pResponse;
}

Expand Down
107 changes: 107 additions & 0 deletions tests/testthat/test-static-paths.R
Original file line number Diff line number Diff line change
Expand Up @@ -808,3 +808,110 @@ test_that("Paths with non-ASCII characters", {
expect_identical(r$status_code, 200L)
expect_identical(r$content, file_content)
})


test_that("Range headers", {
s <- startServer("127.0.0.1", randomPort(),
list(
staticPaths = list(
"/" = staticPath(test_path("apps/content"))
)
)
)
on.exit(s$stop())

file_size <- file.info(test_path("apps/content/mtcars.csv"))$size
file_content <- raw_file_content(test_path("apps/content/mtcars.csv"))

# Malformed Range header.
h <- new_handle()
handle_setheaders(h, "Range" = "bytes=2000-1000")
r <- fetch(local_url("/mtcars.csv", s$getPort()), h)
expect_identical(r$status_code, 400L)

# Range starts beyond file length.
handle_setheaders(h, "Range" = "bytes=10000-20000")
r <- fetch(local_url("/mtcars.csv", s$getPort()), h)
expect_identical(r$status_code, 416L)
expect_identical(
parse_headers_list(r$headers)$`content-range`,
sprintf("bytes */%d", file_size)
)

# Range starts *exactly* one byte beyond file size.
handle_setheaders(h, "Range" = sprintf("bytes=%d-20000", file_size))
r <- fetch(local_url("/mtcars.csv", s$getPort()), h)
expect_identical(r$status_code, 416L)

# Multiple ranges, which we just ignore.
handle_setheaders(h, "Range" = "bytes=0-500, 1000-")
r <- fetch(local_url("/mtcars.csv", s$getPort()), h)
expect_identical(r$status_code, 200L)

# Negative ranges, which are not allowed and we just ignore.
handle_setheaders(h, "Range" = "bytes=-500-")
r <- fetch(local_url("/mtcars.csv", s$getPort()), h)
expect_identical(r$status_code, 200L)

# Garbage ranges, which we also just ignore.
handle_setheaders(h, "Range" = "bytes=notanumber-500")
r <- fetch(local_url("/mtcars.csv", s$getPort()), h)
expect_identical(r$status_code, 200L)

skip_on_os("windows")

# Start of a file.
handle_setheaders(h, "Range" = "bytes=0-499")
r <- fetch(local_url("/mtcars.csv", s$getPort()), h)
expect_identical(r$status_code, 206L)
expect_identical(
parse_headers_list(r$headers)$`content-range`,
sprintf("bytes 0-499/%d", file_size)
)
expect_equal(length(r$content), 500)
expect_identical(r$content, file_content[1:500])

# Exactly 1 byte.
handle_setheaders(h, "Range" = "bytes=0-0")
r <- fetch(local_url("/mtcars.csv", s$getPort()), h)
expect_identical(r$status_code, 206L)
expect_identical(
parse_headers_list(r$headers)$`content-range`,
sprintf("bytes 0-0/%d", file_size)
)
expect_equal(length(r$content), 1)
expect_identical(r$content, file_content[1])

# End of a file.
handle_setheaders(h, "Range" = "bytes=1000-")
r <- fetch(local_url("/mtcars.csv", s$getPort()), h)
expect_identical(r$status_code, 206L)
expect_identical(
parse_headers_list(r$headers)$`content-range`,
sprintf("bytes 1000-%d/%d", file_size - 1, file_size)
)
expect_equal(length(r$content), (file_size - 1000))
expect_identical(r$content, file_content[1001:file_size])

# End of a smaller file than expected.
handle_setheaders(h, "Range" = "bytes=1000-2000")
r <- fetch(local_url("/mtcars.csv", s$getPort()), h)
expect_identical(r$status_code, 206L)
expect_identical(
parse_headers_list(r$headers)$`content-range`,
sprintf("bytes 1000-%d/%d", file_size - 1, file_size)
)
expect_equal(length(r$content), (file_size - 1000))
expect_identical(r$content, file_content[1001:file_size])

# The last 1 byte.
handle_setheaders(h, "Range" = sprintf("bytes=%d-2000", file_size - 1))
r <- fetch(local_url("/mtcars.csv", s$getPort()), h)
expect_identical(r$status_code, 206L)
expect_identical(
parse_headers_list(r$headers)$`content-range`,
sprintf("bytes %d-%d/%d", file_size - 1, file_size - 1, file_size)
)
expect_equal(length(r$content), 1)
expect_identical(r$content, file_content[file_size])
})

0 comments on commit eea5ae0

Please sign in to comment.