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 2, 2020
1 parent cf5e0de commit 64444d5
Show file tree
Hide file tree
Showing 6 changed files with 139 additions and 1 deletion.
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
14 changes: 13 additions & 1 deletion src/filedatasource-unix.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ FileDataSourceResult FileDataSource::initialize(const std::string& path, bool ow
return FDS_ISDIR;
}

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

if (owned && unlink(path.c_str())) {
// Print this (on either main or background thread), since we're not
Expand All @@ -52,6 +52,18 @@ uint64_t FileDataSource::size() const {
return _length;
}

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

uv_buf_t FileDataSource::getData(size_t bytesDesired) {
ASSERT_BACKGROUND_THREAD()
if (bytesDesired == 0)
Expand Down
4 changes: 4 additions & 0 deletions src/filedatasource-win.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,10 @@ uint64_t FileDataSource::size() 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
2 changes: 2 additions & 0 deletions src/filedatasource.h
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ class FileDataSource : public DataSource {
#else
int _fd;
off_t _length;
off_t _fsize;
#endif
std::string _lastErrorMessage;

Expand All @@ -32,6 +33,7 @@ class FileDataSource : public DataSource {

FileDataSourceResult initialize(const std::string& path, bool owned);
uint64_t size() 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,42 @@ boost::shared_ptr<HttpResponse> RWebApplication::staticFileResponse(
}
}

// Check the HTTP Range header, if it has been specified.
std::smatch rangeMatch;
int start = 0, 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->size()))
);
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;
}
}
}
bool hasRange = start != 0 || end != pDataSource->size() - 1;

// ==================================
// Create the HTTP response
// ==================================
Expand All @@ -578,11 +615,22 @@ boost::shared_ptr<HttpResponse> RWebApplication::staticFileResponse(

if (method == "HEAD") {
pDataSource2.reset();
hasRange = false;
}

if (client_cache_is_valid) {
pDataSource2.reset();
status_code = 304;
hasRange = false;
}

std::ostringstream contentRange;
if (hasRange) {
size_t oldSize = pDataSource2->size();
if (pDataSource2->setRange(start, end)) {
status_code = 206;
contentRange << "bytes " << start << "-" << end << "/" << oldSize;
}
}

boost::shared_ptr<HttpResponse> pResponse = boost::shared_ptr<HttpResponse>(
Expand Down Expand Up @@ -624,6 +672,10 @@ 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", contentRange.str()));
}

return pResponse;
}

Expand Down
66 changes: 66 additions & 0 deletions tests/testthat/test-static-paths.R
Original file line number Diff line number Diff line change
Expand Up @@ -808,3 +808,69 @@ 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

# 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)
)

# 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)

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)

# 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), 303)

# 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), 303)
})

0 comments on commit 64444d5

Please sign in to comment.