From ce37f8b89a0d6bdd0342a27d60a7607995ffaab2 Mon Sep 17 00:00:00 2001 From: yhirose Date: Thu, 4 Dec 2025 22:52:34 -0500 Subject: [PATCH] Fix #2242: Implement ETag and Last-Modified support for static file responses --- httplib.h | 100 ++++++++++++++++++++++++++++++++ test/test.cc | 161 +++++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 261 insertions(+) diff --git a/httplib.h b/httplib.h index a7296b9..93d4f1e 100644 --- a/httplib.h +++ b/httplib.h @@ -2593,6 +2593,8 @@ struct FileStat { FileStat(const std::string &path); bool is_file() const; bool is_dir() const; + size_t mtime() const; + size_t size() const; private: #if defined(_WIN32) @@ -2971,6 +2973,53 @@ inline std::string from_i_to_hex(size_t n) { return ret; } +inline std::string compute_etag(const FileStat &fs) { + if (!fs.is_file()) { return std::string(); } + + size_t mtime = fs.mtime(); + size_t size = fs.size(); + + return std::string("W/\"") + from_i_to_hex(mtime) + "-" + + from_i_to_hex(size) + "\""; +} + +// Format time_t as HTTP-date (RFC 7231): "Sun, 06 Nov 1994 08:49:37 GMT" +inline std::string file_mtime_to_http_date(time_t mtime) { + struct tm tm_buf; +#ifdef _WIN32 + gmtime_s(&tm_buf, &mtime); +#else + gmtime_r(&mtime, &tm_buf); +#endif + char buf[64]; + strftime(buf, sizeof(buf), "%a, %d %b %Y %H:%M:%S GMT", &tm_buf); + return std::string(buf); +} + +// Parse HTTP-date (RFC 7231) to time_t. Returns -1 on failure. +inline time_t parse_http_date(const std::string &date_str) { + struct tm tm_buf; + memset(&tm_buf, 0, sizeof(tm_buf)); + + // Try RFC 7231 preferred format: "Sun, 06 Nov 1994 08:49:37 GMT" + const char *p = strptime(date_str.c_str(), "%a, %d %b %Y %H:%M:%S", &tm_buf); + if (!p) { + // Try RFC 850 format: "Sunday, 06-Nov-94 08:49:37 GMT" + p = strptime(date_str.c_str(), "%A, %d-%b-%y %H:%M:%S", &tm_buf); + } + if (!p) { + // Try asctime format: "Sun Nov 6 08:49:37 1994" + p = strptime(date_str.c_str(), "%a %b %d %H:%M:%S %Y", &tm_buf); + } + if (!p) { return static_cast(-1); } + +#ifdef _WIN32 + return _mkgmtime(&tm_buf); +#else + return timegm(&tm_buf); +#endif +} + inline size_t to_utf8(int code, char *buff) { if (code < 0x0080) { buff[0] = static_cast(code & 0x7F); @@ -3090,6 +3139,14 @@ inline bool FileStat::is_dir() const { return ret_ >= 0 && S_ISDIR(st_.st_mode); } +inline size_t FileStat::mtime() const { + return static_cast(st_.st_mtime); +} + +inline size_t FileStat::size() const { + return static_cast(st_.st_size); +} + inline std::string encode_path(const std::string &s) { std::string result; result.reserve(s.size()); @@ -8277,6 +8334,45 @@ inline bool Server::handle_file_request(const Request &req, Response &res) { res.set_header(kv.first, kv.second); } + // Compute and set weak ETag based on mtime+size. + auto etag = detail::compute_etag(stat); + auto mtime = static_cast(stat.mtime()); + auto last_modified = detail::file_mtime_to_http_date(mtime); + + if (!etag.empty()) { res.set_header("ETag", etag); } + if (!last_modified.empty()) { + res.set_header("Last-Modified", last_modified); + } + + // Handle conditional GET: + // 1. If-None-Match takes precedence (RFC 9110 Section 13.1.2) + // 2. If-Modified-Since is checked only when If-None-Match is absent + if (req.has_header("If-None-Match")) { + if (!etag.empty()) { + auto inm = req.get_header_value("If-None-Match"); + bool matched = false; + detail::split(inm.data(), inm.data() + inm.size(), ',', + [&](const char *b, const char *e) { + if (!matched) { + auto tag = std::string(b, e); + matched = tag == "*" || tag == etag; + } + }); + + if (matched) { + res.status = StatusCode::NotModified_304; + return true; + } + } + } else if (req.has_header("If-Modified-Since")) { + auto ims = req.get_header_value("If-Modified-Since"); + auto ims_time = detail::parse_http_date(ims); + if (ims_time != static_cast(-1) && mtime <= ims_time) { + res.status = StatusCode::NotModified_304; + return true; + } + } + auto mm = std::make_shared(path.c_str()); if (!mm->is_open()) { output_error_log(Error::OpenFile, &req); @@ -8573,10 +8669,13 @@ inline void Server::apply_ranges(const Request &req, Response &res, res.set_header("Transfer-Encoding", "chunked"); if (type == detail::EncodingType::Gzip) { res.set_header("Content-Encoding", "gzip"); + res.set_header("Vary", "Accept-Encoding"); } else if (type == detail::EncodingType::Brotli) { res.set_header("Content-Encoding", "br"); + res.set_header("Vary", "Accept-Encoding"); } else if (type == detail::EncodingType::Zstd) { res.set_header("Content-Encoding", "zstd"); + res.set_header("Vary", "Accept-Encoding"); } } } @@ -8635,6 +8734,7 @@ inline void Server::apply_ranges(const Request &req, Response &res, })) { res.body.swap(compressed); res.set_header("Content-Encoding", content_encoding); + res.set_header("Vary", "Accept-Encoding"); } } } diff --git a/test/test.cc b/test/test.cc index b6e93d9..091f684 100644 --- a/test/test.cc +++ b/test/test.cc @@ -12687,3 +12687,164 @@ TEST(ErrorHandlingTest, SSLStreamConnectionClosed) { t.join(); } #endif + +TEST(ETagTest, StaticFileETagAndIfNoneMatch) { + using namespace httplib; + + // Create a test file + const char *fname = "etag_testfile.txt"; + const char *content = "etag-content"; + { + std::ofstream ofs(fname); + ofs << content; + } + + Server svr; + svr.set_mount_point("/static", "."); + auto t = std::thread([&]() { svr.listen("localhost", 8087); }); + svr.wait_until_ready(); + + Client cli("localhost", 8087); + + // First request: should get 200 with ETag header + auto res1 = cli.Get("/static/etag_testfile.txt"); + ASSERT_TRUE(res1); + ASSERT_EQ(200, res1->status); + ASSERT_TRUE(res1->has_header("ETag")); + std::string etag = res1->get_header_value("ETag"); + EXPECT_FALSE(etag.empty()); + + // Verify ETag format: W/"hex-hex" + EXPECT_EQ('W', etag[0]); + EXPECT_EQ('/', etag[1]); + EXPECT_EQ('"', etag[2]); + + // Exact match: expect 304 Not Modified + Headers h2 = {{"If-None-Match", etag}}; + auto res2 = cli.Get("/static/etag_testfile.txt", h2); + ASSERT_TRUE(res2); + EXPECT_EQ(304, res2->status); + + // Wildcard match: expect 304 Not Modified + Headers h3 = {{"If-None-Match", "*"}}; + auto res3 = cli.Get("/static/etag_testfile.txt", h3); + ASSERT_TRUE(res3); + EXPECT_EQ(304, res3->status); + + // Non-matching ETag: expect 200 + Headers h4 = {{"If-None-Match", "W/\"deadbeef\""}}; + auto res4 = cli.Get("/static/etag_testfile.txt", h4); + ASSERT_TRUE(res4); + EXPECT_EQ(200, res4->status); + + // Multiple ETags with one matching: expect 304 + Headers h5 = {{"If-None-Match", "W/\"other\", " + etag + ", W/\"another\""}}; + auto res5 = cli.Get("/static/etag_testfile.txt", h5); + ASSERT_TRUE(res5); + EXPECT_EQ(304, res5->status); + + svr.stop(); + t.join(); + std::remove(fname); +} + +TEST(ETagTest, LastModifiedAndIfModifiedSince) { + using namespace httplib; + + // Create a test file + const char *fname = "ims_testfile.txt"; + const char *content = "if-modified-since-test"; + { + std::ofstream ofs(fname); + ofs << content; + } + + Server svr; + svr.set_mount_point("/static", "."); + auto t = std::thread([&]() { svr.listen("localhost", 8088); }); + svr.wait_until_ready(); + + Client cli("localhost", 8088); + + // First request: should get 200 with Last-Modified header + auto res1 = cli.Get("/static/ims_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"); + EXPECT_FALSE(last_modified.empty()); + + // If-Modified-Since with same time: expect 304 + Headers h2 = {{"If-Modified-Since", last_modified}}; + auto res2 = cli.Get("/static/ims_testfile.txt", h2); + ASSERT_TRUE(res2); + EXPECT_EQ(304, res2->status); + + // If-Modified-Since with future time: expect 304 + Headers h3 = {{"If-Modified-Since", "Sun, 01 Jan 2099 00:00:00 GMT"}}; + auto res3 = cli.Get("/static/ims_testfile.txt", h3); + ASSERT_TRUE(res3); + EXPECT_EQ(304, res3->status); + + // If-Modified-Since with past time: expect 200 + Headers h4 = {{"If-Modified-Since", "Sun, 01 Jan 2000 00:00:00 GMT"}}; + auto res4 = cli.Get("/static/ims_testfile.txt", h4); + ASSERT_TRUE(res4); + EXPECT_EQ(200, res4->status); + + // If-None-Match takes precedence over If-Modified-Since + // (send matching ETag with old If-Modified-Since -> should still be 304) + ASSERT_TRUE(res1->has_header("ETag")); + std::string etag = res1->get_header_value("ETag"); + Headers h5 = {{"If-None-Match", etag}, + {"If-Modified-Since", "Sun, 01 Jan 2000 00:00:00 GMT"}}; + auto res5 = cli.Get("/static/ims_testfile.txt", h5); + ASSERT_TRUE(res5); + EXPECT_EQ(304, res5->status); + + svr.stop(); + t.join(); + std::remove(fname); +} + +TEST(ETagTest, VaryAcceptEncodingWithCompression) { + using namespace httplib; + + Server svr; + + // Endpoint that returns compressible content + svr.Get("/compressible", [](const Request &, Response &res) { + // Return a large enough body to trigger compression + std::string body(1000, 'a'); + res.set_content(body, "text/plain"); + }); + + auto t = std::thread([&]() { svr.listen("localhost", 8089); }); + svr.wait_until_ready(); + + Client cli("localhost", 8089); + + // Request with gzip support: should get Vary header when compressed + cli.set_compress(true); + auto res1 = cli.Get("/compressible"); + ASSERT_TRUE(res1); + EXPECT_EQ(200, res1->status); + + // If Content-Encoding is set, Vary should also be set + if (res1->has_header("Content-Encoding")) { + EXPECT_TRUE(res1->has_header("Vary")); + EXPECT_EQ("Accept-Encoding", res1->get_header_value("Vary")); + } + + // Request without Accept-Encoding header: should not have compression + Headers h_no_compress; + auto res2 = cli.Get("/compressible", h_no_compress); + ASSERT_TRUE(res2); + EXPECT_EQ(200, res2->status); + + // Verify Vary header is present when compression is applied + // (the exact behavior depends on server configuration) + + svr.stop(); + t.join(); +}