diff --git a/NEWS.md b/NEWS.md index 73d50a55..5718d109 100644 --- a/NEWS.md +++ b/NEWS.md @@ -5,6 +5,8 @@ httpuv 1.5.4.9000 * Fixed #195: Responses required `headers` to be a named list. Now it can also be `NULL`, an empty unnamed list, or it can be unset. (#289) +* Resolved #259: Static files now support common range requests on non-Windows platforms. (#290) + httpuv 1.5.4 ============ diff --git a/src/filedatasource-unix.cpp b/src/filedatasource-unix.cpp index 6db06829..473685e9 100644 --- a/src/filedatasource-unix.cpp +++ b/src/filedatasource-unix.cpp @@ -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 @@ -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) diff --git a/src/filedatasource-win.cpp b/src/filedatasource-win.cpp index e5366966..d84e413f 100644 --- a/src/filedatasource-win.cpp +++ b/src/filedatasource-win.cpp @@ -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) diff --git a/src/filedatasource.h b/src/filedatasource.h index d32ac0b0..c7763678 100644 --- a/src/filedatasource.h +++ b/src/filedatasource.h @@ -20,6 +20,7 @@ class FileDataSource : public DataSource { #else int _fd; off_t _length; + off_t _fsize; #endif std::string _lastErrorMessage; @@ -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. diff --git a/src/webapplication.cpp b/src/webapplication.cpp index 87b19f65..9eb0673f 100644 --- a/src/webapplication.cpp +++ b/src/webapplication.cpp @@ -1,6 +1,7 @@ #include #include #include +#include #include "httpuv.h" #include "filedatasource.h" #include "webapplication.h" @@ -555,6 +556,42 @@ boost::shared_ptr 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 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 // ================================== @@ -570,11 +607,22 @@ boost::shared_ptr 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 pResponse = boost::shared_ptr( @@ -616,6 +664,10 @@ boost::shared_ptr 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; } diff --git a/tests/testthat/test-static-paths.R b/tests/testthat/test-static-paths.R index ca7cee18..84861f42 100644 --- a/tests/testthat/test-static-paths.R +++ b/tests/testthat/test-static-paths.R @@ -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) +})