diff --git a/NEWS.md b/NEWS.md index f723602a..c39f7361 100644 --- a/NEWS.md +++ b/NEWS.md @@ -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. (#290) + httpuv 1.5.4 ============ diff --git a/src/filedatasource-unix.cpp b/src/filedatasource-unix.cpp index 6db06829..b5793e17 100644 --- a/src/filedatasource-unix.cpp +++ b/src/filedatasource-unix.cpp @@ -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 @@ -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(uint64_t start, uint64_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) { diff --git a/src/filedatasource-win.cpp b/src/filedatasource-win.cpp index e5366966..ba532cea 100644 --- a/src/filedatasource-win.cpp +++ b/src/filedatasource-win.cpp @@ -49,18 +49,39 @@ FileDataSourceResult FileDataSource::initialize(const std::string& path, bool ow } - if (!GetFileSizeEx(_hFile, &_length)) { + if (!GetFileSizeEx(_hFile, &_fsize)) { CloseHandle(_hFile); _hFile = INVALID_HANDLE_VALUE; _lastErrorMessage = "Error retrieving file size for " + path + ": " + toString(GetLastError()) + "\n"; return FDS_ERROR; } + _payloadSize = _fsize.QuadPart; return FDS_OK; } uint64_t FileDataSource::size() const { - return _length.QuadPart; + return _payloadSize; +} + +uint64_t FileDataSource::fileSize() const { + return _fsize.QuadPart; +} + +bool FileDataSource::setRange(uint64_t start, uint64_t end) { + ASSERT_BACKGROUND_THREAD() + if (end > _fsize.QuadPart || end < start) { + return false; + } + LARGE_INTEGER position = {0}; + position.QuadPart = start; + // When using FILE_BEGIN, position is interpreted as an unsigned value. + if (!SetFilePointerEx(_hFile, position, NULL, FILE_BEGIN)) { + err_printf("Error in SetFilePointerEx: %d\n", GetLastError()); + return false; + } + _payloadSize = _fsize.QuadPart - start + 1; + return true; } uv_buf_t FileDataSource::getData(size_t bytesDesired) { diff --git a/src/filedatasource.h b/src/filedatasource.h index d32ac0b0..e93d0035 100644 --- a/src/filedatasource.h +++ b/src/filedatasource.h @@ -16,10 +16,12 @@ enum FileDataSourceResult { class FileDataSource : public DataSource { #ifdef _WIN32 HANDLE _hFile; - LARGE_INTEGER _length; + uint64_t _payloadSize; + LARGE_INTEGER _fsize; #else int _fd; - off_t _length; + off_t _payloadSize; + off_t _fsize; #endif std::string _lastErrorMessage; @@ -31,7 +33,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(uint64_t start, uint64_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 005dbc51..72d5ebec 100644 --- a/src/webapplication.cpp +++ b/src/webapplication.cpp @@ -563,6 +563,70 @@ boost::shared_ptr RWebApplication::staticFileResponse( } } + // Check the HTTP Range header, if it has been specified. + // + // We only support a limited form of the HTTP Range header (something like + // 'bytes=(\\d+)-(\\d+)?'), so some client requests can't be fulfilled and we + // need to fall back on a 200 OK instead. + bool hasRange = false; + uint64_t start = 0; + uint64_t end = pDataSource->fileSize() - 1; + if (pRequest->hasHeader("Range")) { + std::string rangeHeader = pRequest->getHeader("Range"); + size_t cursor = rangeHeader.find("bytes="); + size_t sep = rangeHeader.find("-", cursor + 6); + if (cursor != std::string::npos && sep != std::string::npos && sep != cursor + 6) { + // At this point we can be sure we have something like 'bytes=([^-]+)-.*' + // and we can try parsing the range itself. + char *ptr = ((char *) rangeHeader.c_str()) + cursor + 6; // Skip over 'bytes='. + char *endptr = ptr; + // strtoull() will return 0 when it can't find a number or ULLONG_MAX on + // overflow. Since we can't have a file larger than ULLONG_MAX anyway, + // this is OK because we'll just return HTTP 416 below. + start = strtoull(ptr, &endptr, 10); + if (start == 0 && (endptr == ptr || *endptr != '-')) { + // Either there was no number at all or there was garbage *after* the + // number but before the '-' separator. This is invalid range syntax + // from the client. + return error_response(pRequest, 400); + } + if (start >= pDataSource->fileSize()) { + boost::shared_ptr pResponse = error_response(pRequest, 416); + pResponse->headers().push_back( + std::make_pair("Content-Range", "bytes */" + toString(pDataSource->fileSize())) + ); + return pResponse; + } + ptr = endptr + 1; // Skip the '-'. + if (*ptr != '\0') { + end = strtoull(ptr, &endptr, 10); + if (*endptr != '\0' && *endptr != ',') { + // We hit a non-digit, non-multirange character at some point. + return error_response(pRequest, 400); + } + if (end < start) { + return error_response(pRequest, 400); + } + ptr = endptr; + } + // A comma indicates we're parsing a multipart range, which we don't + // support. So we need to fallback on 200 behaviour instead when we detect + // one. + if (*ptr != ',') { + if (end > pDataSource->fileSize() - 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. This also catches overflow in strtoull() above, which would + // be OK. + // + // See: https://tools.ietf.org/html/rfc7233#section-2.1 + end = pDataSource->fileSize() - 1; + } + hasRange = pDataSource->setRange(start, end); + } + } + } + // ================================== // Create the HTTP response // ================================== @@ -585,6 +649,10 @@ boost::shared_ptr RWebApplication::staticFileResponse( status_code = 304; } + if (hasRange) { + status_code = 206; + } + boost::shared_ptr pResponse = boost::shared_ptr( new HttpResponse(pRequest, status_code, getStatusDescription(status_code), pDataSource2), auto_deleter_background @@ -622,6 +690,14 @@ boost::shared_ptr RWebApplication::staticFileResponse( respHeaders.push_back(std::make_pair("Content-Length", toString(pDataSource->size()))); respHeaders.push_back(std::make_pair("Content-Type", content_type)); respHeaders.push_back(std::make_pair("Last-Modified", http_date_string(pDataSource->getMtime()))); + respHeaders.push_back(std::make_pair("Accept-Ranges", "bytes")); + } + + if (status_code == 206) { + respHeaders.push_back(std::make_pair( + "Content-Range", + "bytes " + toString(start) + "-" + toString(end) + "/" + toString(pDataSource2->fileSize()) + )); } return pResponse; diff --git a/tests/testthat/test-static-paths.R b/tests/testthat/test-static-paths.R index ca7cee18..95975b5b 100644 --- a/tests/testthat/test-static-paths.R +++ b/tests/testthat/test-static-paths.R @@ -808,3 +808,140 @@ 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 headers. + h <- new_handle() + handle_setheaders(h, "Range" = "bytes=500-100") + r <- fetch(local_url("/mtcars.csv", s$getPort()), h) + expect_identical(r$status_code, 400L) + + handle_setheaders(h, "Range" = "bytes=notanumber-500") + r <- fetch(local_url("/mtcars.csv", s$getPort()), h) + expect_identical(r$status_code, 400L) + + handle_setheaders(h, "Range" = "bytes=0-notanumber") + r <- fetch(local_url("/mtcars.csv", s$getPort()), h) + expect_identical(r$status_code, 400L) + + handle_setheaders(h, "Range" = "bytes=500-100, 1000-") + r <- fetch(local_url("/mtcars.csv", s$getPort()), h) + expect_identical(r$status_code, 400L) + + # Range we can't handle (with overflow). + handle_setheaders(h, "Range" = "bytes=2147483648-2147483649") + r <- fetch(local_url("/mtcars.csv", s$getPort()), h) + expect_identical(r$status_code, 416L) + + # 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) + + # Multipart 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) + + # Suffix length ranges, which we also ignore. + handle_setheaders(h, "Range" = "bytes=-500") + r <- fetch(local_url("/mtcars.csv", s$getPort()), h) + expect_identical(r$status_code, 200L) + + # Garbage Range header, which we also ignore. + handle_setheaders(h, "Range" = "bytes=500") + r <- fetch(local_url("/mtcars.csv", s$getPort()), h) + expect_identical(r$status_code, 200L) + + handle_setheaders(h, "Range" = "invalid") + r <- fetch(local_url("/mtcars.csv", s$getPort()), h) + expect_identical(r$status_code, 200L) + + # 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]) + + # Exactly all bytes. + handle_setheaders(h, "Range" = sprintf("bytes=0-%d", 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 0-%d/%d", file_size - 1, file_size) + ) + expect_equal(length(r$content), file_size) + expect_identical(r$content, file_content) + + # 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]) +})