From 8d6123c1264597da1a1026e5216cbef75ba21c2e Mon Sep 17 00:00:00 2001 From: yhirose Date: Thu, 4 Dec 2025 23:15:29 -0500 Subject: [PATCH] Add ETag and Last-Modified handling for If-Range requests --- httplib.h | 34 ++++++++++++++++-- test/test.cc | 100 +++++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 132 insertions(+), 2 deletions(-) diff --git a/httplib.h b/httplib.h index 93d4f1e..c667e5a 100644 --- a/httplib.h +++ b/httplib.h @@ -1257,7 +1257,7 @@ private: bool listen_internal(); bool routing(Request &req, Response &res, Stream &strm); - bool handle_file_request(const Request &req, Response &res); + bool handle_file_request(Request &req, Response &res); bool dispatch_request(Request &req, Response &res, const Handlers &handlers) const; bool dispatch_request_for_content_reader( @@ -3020,6 +3020,12 @@ inline time_t parse_http_date(const std::string &date_str) { #endif } +// Check if the string is an ETag (starts with '"' or 'W/"') +inline bool is_etag(const std::string &s) { + return !s.empty() && + (s[0] == '"' || (s.size() > 2 && s[0] == 'W' && s[1] == '/')); +} + inline size_t to_utf8(int code, char *buff) { if (code < 0x0080) { buff[0] = static_cast(code & 0x7F); @@ -8313,7 +8319,7 @@ inline bool Server::read_content_core( return true; } -inline bool Server::handle_file_request(const Request &req, Response &res) { +inline bool Server::handle_file_request(Request &req, Response &res) { for (const auto &entry : base_dirs_) { // Prefix match if (!req.path.compare(0, entry.mount_point.size(), entry.mount_point)) { @@ -8373,6 +8379,30 @@ inline bool Server::handle_file_request(const Request &req, Response &res) { } } + // Handle If-Range for partial content requests (RFC 9110 + // Section 13.1.5) If-Range is only evaluated when Range header is + // present. If the validator matches, serve partial content; otherwise + // serve full content. + if (!req.ranges.empty() && req.has_header("If-Range")) { + auto if_range = req.get_header_value("If-Range"); + auto valid = false; + + if (detail::is_etag(if_range)) { + // ETag comparison (weak comparison for If-Range per RFC 9110) + valid = (!etag.empty() && if_range == etag); + } else { + // HTTP-date comparison + auto if_range_time = detail::parse_http_date(if_range); + valid = (if_range_time != static_cast(-1) && + mtime <= if_range_time); + } + + if (!valid) { + // Validator doesn't match: ignore Range and serve full content + req.ranges.clear(); + } + } + auto mm = std::make_shared(path.c_str()); if (!mm->is_open()) { output_error_log(Error::OpenFile, &req); diff --git a/test/test.cc b/test/test.cc index 091f684..da6518c 100644 --- a/test/test.cc +++ b/test/test.cc @@ -12848,3 +12848,103 @@ TEST(ETagTest, VaryAcceptEncodingWithCompression) { svr.stop(); t.join(); } + +TEST(ETagTest, IfRangeWithETag) { + using namespace httplib; + + // Create a test file with known content + const char *fname = "if_range_testfile.txt"; + const std::string content = "0123456789ABCDEFGHIJ"; // 20 bytes + { + std::ofstream ofs(fname); + ofs << content; + } + + Server svr; + svr.set_mount_point("/static", "."); + auto t = std::thread([&]() { svr.listen("localhost", 8090); }); + svr.wait_until_ready(); + + Client cli("localhost", 8090); + + // First request: get ETag + auto res1 = cli.Get("/static/if_range_testfile.txt"); + ASSERT_TRUE(res1); + ASSERT_EQ(200, res1->status); + ASSERT_TRUE(res1->has_header("ETag")); + std::string etag = res1->get_header_value("ETag"); + + // Range request with matching If-Range (ETag): should get 206 + Headers h2 = {{"Range", "bytes=0-4"}, {"If-Range", etag}}; + auto res2 = cli.Get("/static/if_range_testfile.txt", h2); + ASSERT_TRUE(res2); + EXPECT_EQ(206, res2->status); + EXPECT_EQ("01234", res2->body); + EXPECT_TRUE(res2->has_header("Content-Range")); + + // Range request with non-matching If-Range (ETag): should get 200 (full + // content) + Headers h3 = {{"Range", "bytes=0-4"}, {"If-Range", "W/\"wrong-etag\""}}; + auto res3 = cli.Get("/static/if_range_testfile.txt", h3); + ASSERT_TRUE(res3); + EXPECT_EQ(200, res3->status); + EXPECT_EQ(content, res3->body); + EXPECT_FALSE(res3->has_header("Content-Range")); + + svr.stop(); + t.join(); + std::remove(fname); +} + +TEST(ETagTest, IfRangeWithDate) { + using namespace httplib; + + // Create a test file + const char *fname = "if_range_date_testfile.txt"; + const std::string content = "ABCDEFGHIJ0123456789"; // 20 bytes + { + std::ofstream ofs(fname); + ofs << content; + } + + Server svr; + svr.set_mount_point("/static", "."); + auto t = std::thread([&]() { svr.listen("localhost", 8091); }); + svr.wait_until_ready(); + + Client cli("localhost", 8091); + + // First request: get Last-Modified + auto res1 = cli.Get("/static/if_range_date_testfile.txt"); + ASSERT_TRUE(res1); + ASSERT_EQ(200, res1->status); + ASSERT_TRUE(res1->has_header("Last-Modified")); + std::string last_modified = res1->get_header_value("Last-Modified"); + + // Range request with matching If-Range (date): should get 206 + Headers h2 = {{"Range", "bytes=5-9"}, {"If-Range", last_modified}}; + auto res2 = cli.Get("/static/if_range_date_testfile.txt", h2); + ASSERT_TRUE(res2); + EXPECT_EQ(206, res2->status); + EXPECT_EQ("FGHIJ", res2->body); + + // Range request with old If-Range date: should get 200 (full content) + Headers h3 = {{"Range", "bytes=5-9"}, + {"If-Range", "Sun, 01 Jan 2000 00:00:00 GMT"}}; + auto res3 = cli.Get("/static/if_range_date_testfile.txt", h3); + ASSERT_TRUE(res3); + EXPECT_EQ(200, res3->status); + EXPECT_EQ(content, res3->body); + + // Range request with future If-Range date: should get 206 + Headers h4 = {{"Range", "bytes=0-4"}, + {"If-Range", "Sun, 01 Jan 2099 00:00:00 GMT"}}; + auto res4 = cli.Get("/static/if_range_date_testfile.txt", h4); + ASSERT_TRUE(res4); + EXPECT_EQ(206, res4->status); + EXPECT_EQ("ABCDE", res4->body); + + svr.stop(); + t.join(); + std::remove(fname); +}