1
0
mirror of synced 2025-12-18 16:34:09 +03:00

Fix #2242: Implement ETag and Last-Modified support for static file responses

This commit is contained in:
yhirose
2025-12-04 22:52:34 -05:00
parent dbd5ca4bf2
commit ce37f8b89a
2 changed files with 261 additions and 0 deletions

100
httplib.h
View File

@@ -2593,6 +2593,8 @@ struct FileStat {
FileStat(const std::string &path); FileStat(const std::string &path);
bool is_file() const; bool is_file() const;
bool is_dir() const; bool is_dir() const;
size_t mtime() const;
size_t size() const;
private: private:
#if defined(_WIN32) #if defined(_WIN32)
@@ -2971,6 +2973,53 @@ inline std::string from_i_to_hex(size_t n) {
return ret; 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<time_t>(-1); }
#ifdef _WIN32
return _mkgmtime(&tm_buf);
#else
return timegm(&tm_buf);
#endif
}
inline size_t to_utf8(int code, char *buff) { inline size_t to_utf8(int code, char *buff) {
if (code < 0x0080) { if (code < 0x0080) {
buff[0] = static_cast<char>(code & 0x7F); buff[0] = static_cast<char>(code & 0x7F);
@@ -3090,6 +3139,14 @@ inline bool FileStat::is_dir() const {
return ret_ >= 0 && S_ISDIR(st_.st_mode); return ret_ >= 0 && S_ISDIR(st_.st_mode);
} }
inline size_t FileStat::mtime() const {
return static_cast<size_t>(st_.st_mtime);
}
inline size_t FileStat::size() const {
return static_cast<size_t>(st_.st_size);
}
inline std::string encode_path(const std::string &s) { inline std::string encode_path(const std::string &s) {
std::string result; std::string result;
result.reserve(s.size()); 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); 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<time_t>(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<time_t>(-1) && mtime <= ims_time) {
res.status = StatusCode::NotModified_304;
return true;
}
}
auto mm = std::make_shared<detail::mmap>(path.c_str()); auto mm = std::make_shared<detail::mmap>(path.c_str());
if (!mm->is_open()) { if (!mm->is_open()) {
output_error_log(Error::OpenFile, &req); 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"); res.set_header("Transfer-Encoding", "chunked");
if (type == detail::EncodingType::Gzip) { if (type == detail::EncodingType::Gzip) {
res.set_header("Content-Encoding", "gzip"); res.set_header("Content-Encoding", "gzip");
res.set_header("Vary", "Accept-Encoding");
} else if (type == detail::EncodingType::Brotli) { } else if (type == detail::EncodingType::Brotli) {
res.set_header("Content-Encoding", "br"); res.set_header("Content-Encoding", "br");
res.set_header("Vary", "Accept-Encoding");
} else if (type == detail::EncodingType::Zstd) { } else if (type == detail::EncodingType::Zstd) {
res.set_header("Content-Encoding", "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.body.swap(compressed);
res.set_header("Content-Encoding", content_encoding); res.set_header("Content-Encoding", content_encoding);
res.set_header("Vary", "Accept-Encoding");
} }
} }
} }

View File

@@ -12687,3 +12687,164 @@ TEST(ErrorHandlingTest, SSLStreamConnectionClosed) {
t.join(); t.join();
} }
#endif #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();
}