Skip to content

Commit ce37f8b

Browse files
committed
Fix #2242: Implement ETag and Last-Modified support for static file responses
1 parent dbd5ca4 commit ce37f8b

File tree

2 files changed

+261
-0
lines changed

2 files changed

+261
-0
lines changed

httplib.h

Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2593,6 +2593,8 @@ struct FileStat {
25932593
FileStat(const std::string &path);
25942594
bool is_file() const;
25952595
bool is_dir() const;
2596+
size_t mtime() const;
2597+
size_t size() const;
25962598

25972599
private:
25982600
#if defined(_WIN32)
@@ -2971,6 +2973,53 @@ inline std::string from_i_to_hex(size_t n) {
29712973
return ret;
29722974
}
29732975

2976+
inline std::string compute_etag(const FileStat &fs) {
2977+
if (!fs.is_file()) { return std::string(); }
2978+
2979+
size_t mtime = fs.mtime();
2980+
size_t size = fs.size();
2981+
2982+
return std::string("W/\"") + from_i_to_hex(mtime) + "-" +
2983+
from_i_to_hex(size) + "\"";
2984+
}
2985+
2986+
// Format time_t as HTTP-date (RFC 7231): "Sun, 06 Nov 1994 08:49:37 GMT"
2987+
inline std::string file_mtime_to_http_date(time_t mtime) {
2988+
struct tm tm_buf;
2989+
#ifdef _WIN32
2990+
gmtime_s(&tm_buf, &mtime);
2991+
#else
2992+
gmtime_r(&mtime, &tm_buf);
2993+
#endif
2994+
char buf[64];
2995+
strftime(buf, sizeof(buf), "%a, %d %b %Y %H:%M:%S GMT", &tm_buf);
2996+
return std::string(buf);
2997+
}
2998+
2999+
// Parse HTTP-date (RFC 7231) to time_t. Returns -1 on failure.
3000+
inline time_t parse_http_date(const std::string &date_str) {
3001+
struct tm tm_buf;
3002+
memset(&tm_buf, 0, sizeof(tm_buf));
3003+
3004+
// Try RFC 7231 preferred format: "Sun, 06 Nov 1994 08:49:37 GMT"
3005+
const char *p = strptime(date_str.c_str(), "%a, %d %b %Y %H:%M:%S", &tm_buf);
3006+
if (!p) {
3007+
// Try RFC 850 format: "Sunday, 06-Nov-94 08:49:37 GMT"
3008+
p = strptime(date_str.c_str(), "%A, %d-%b-%y %H:%M:%S", &tm_buf);
3009+
}
3010+
if (!p) {
3011+
// Try asctime format: "Sun Nov 6 08:49:37 1994"
3012+
p = strptime(date_str.c_str(), "%a %b %d %H:%M:%S %Y", &tm_buf);
3013+
}
3014+
if (!p) { return static_cast<time_t>(-1); }
3015+
3016+
#ifdef _WIN32
3017+
return _mkgmtime(&tm_buf);
3018+
#else
3019+
return timegm(&tm_buf);
3020+
#endif
3021+
}
3022+
29743023
inline size_t to_utf8(int code, char *buff) {
29753024
if (code < 0x0080) {
29763025
buff[0] = static_cast<char>(code & 0x7F);
@@ -3090,6 +3139,14 @@ inline bool FileStat::is_dir() const {
30903139
return ret_ >= 0 && S_ISDIR(st_.st_mode);
30913140
}
30923141

3142+
inline size_t FileStat::mtime() const {
3143+
return static_cast<size_t>(st_.st_mtime);
3144+
}
3145+
3146+
inline size_t FileStat::size() const {
3147+
return static_cast<size_t>(st_.st_size);
3148+
}
3149+
30933150
inline std::string encode_path(const std::string &s) {
30943151
std::string result;
30953152
result.reserve(s.size());
@@ -8277,6 +8334,45 @@ inline bool Server::handle_file_request(const Request &req, Response &res) {
82778334
res.set_header(kv.first, kv.second);
82788335
}
82798336

8337+
// Compute and set weak ETag based on mtime+size.
8338+
auto etag = detail::compute_etag(stat);
8339+
auto mtime = static_cast<time_t>(stat.mtime());
8340+
auto last_modified = detail::file_mtime_to_http_date(mtime);
8341+
8342+
if (!etag.empty()) { res.set_header("ETag", etag); }
8343+
if (!last_modified.empty()) {
8344+
res.set_header("Last-Modified", last_modified);
8345+
}
8346+
8347+
// Handle conditional GET:
8348+
// 1. If-None-Match takes precedence (RFC 9110 Section 13.1.2)
8349+
// 2. If-Modified-Since is checked only when If-None-Match is absent
8350+
if (req.has_header("If-None-Match")) {
8351+
if (!etag.empty()) {
8352+
auto inm = req.get_header_value("If-None-Match");
8353+
bool matched = false;
8354+
detail::split(inm.data(), inm.data() + inm.size(), ',',
8355+
[&](const char *b, const char *e) {
8356+
if (!matched) {
8357+
auto tag = std::string(b, e);
8358+
matched = tag == "*" || tag == etag;
8359+
}
8360+
});
8361+
8362+
if (matched) {
8363+
res.status = StatusCode::NotModified_304;
8364+
return true;
8365+
}
8366+
}
8367+
} else if (req.has_header("If-Modified-Since")) {
8368+
auto ims = req.get_header_value("If-Modified-Since");
8369+
auto ims_time = detail::parse_http_date(ims);
8370+
if (ims_time != static_cast<time_t>(-1) && mtime <= ims_time) {
8371+
res.status = StatusCode::NotModified_304;
8372+
return true;
8373+
}
8374+
}
8375+
82808376
auto mm = std::make_shared<detail::mmap>(path.c_str());
82818377
if (!mm->is_open()) {
82828378
output_error_log(Error::OpenFile, &req);
@@ -8573,10 +8669,13 @@ inline void Server::apply_ranges(const Request &req, Response &res,
85738669
res.set_header("Transfer-Encoding", "chunked");
85748670
if (type == detail::EncodingType::Gzip) {
85758671
res.set_header("Content-Encoding", "gzip");
8672+
res.set_header("Vary", "Accept-Encoding");
85768673
} else if (type == detail::EncodingType::Brotli) {
85778674
res.set_header("Content-Encoding", "br");
8675+
res.set_header("Vary", "Accept-Encoding");
85788676
} else if (type == detail::EncodingType::Zstd) {
85798677
res.set_header("Content-Encoding", "zstd");
8678+
res.set_header("Vary", "Accept-Encoding");
85808679
}
85818680
}
85828681
}
@@ -8635,6 +8734,7 @@ inline void Server::apply_ranges(const Request &req, Response &res,
86358734
})) {
86368735
res.body.swap(compressed);
86378736
res.set_header("Content-Encoding", content_encoding);
8737+
res.set_header("Vary", "Accept-Encoding");
86388738
}
86398739
}
86408740
}

test/test.cc

Lines changed: 161 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12687,3 +12687,164 @@ TEST(ErrorHandlingTest, SSLStreamConnectionClosed) {
1268712687
t.join();
1268812688
}
1268912689
#endif
12690+
12691+
TEST(ETagTest, StaticFileETagAndIfNoneMatch) {
12692+
using namespace httplib;
12693+
12694+
// Create a test file
12695+
const char *fname = "etag_testfile.txt";
12696+
const char *content = "etag-content";
12697+
{
12698+
std::ofstream ofs(fname);
12699+
ofs << content;
12700+
}
12701+
12702+
Server svr;
12703+
svr.set_mount_point("/static", ".");
12704+
auto t = std::thread([&]() { svr.listen("localhost", 8087); });
12705+
svr.wait_until_ready();
12706+
12707+
Client cli("localhost", 8087);
12708+
12709+
// First request: should get 200 with ETag header
12710+
auto res1 = cli.Get("/static/etag_testfile.txt");
12711+
ASSERT_TRUE(res1);
12712+
ASSERT_EQ(200, res1->status);
12713+
ASSERT_TRUE(res1->has_header("ETag"));
12714+
std::string etag = res1->get_header_value("ETag");
12715+
EXPECT_FALSE(etag.empty());
12716+
12717+
// Verify ETag format: W/"hex-hex"
12718+
EXPECT_EQ('W', etag[0]);
12719+
EXPECT_EQ('/', etag[1]);
12720+
EXPECT_EQ('"', etag[2]);
12721+
12722+
// Exact match: expect 304 Not Modified
12723+
Headers h2 = {{"If-None-Match", etag}};
12724+
auto res2 = cli.Get("/static/etag_testfile.txt", h2);
12725+
ASSERT_TRUE(res2);
12726+
EXPECT_EQ(304, res2->status);
12727+
12728+
// Wildcard match: expect 304 Not Modified
12729+
Headers h3 = {{"If-None-Match", "*"}};
12730+
auto res3 = cli.Get("/static/etag_testfile.txt", h3);
12731+
ASSERT_TRUE(res3);
12732+
EXPECT_EQ(304, res3->status);
12733+
12734+
// Non-matching ETag: expect 200
12735+
Headers h4 = {{"If-None-Match", "W/\"deadbeef\""}};
12736+
auto res4 = cli.Get("/static/etag_testfile.txt", h4);
12737+
ASSERT_TRUE(res4);
12738+
EXPECT_EQ(200, res4->status);
12739+
12740+
// Multiple ETags with one matching: expect 304
12741+
Headers h5 = {{"If-None-Match", "W/\"other\", " + etag + ", W/\"another\""}};
12742+
auto res5 = cli.Get("/static/etag_testfile.txt", h5);
12743+
ASSERT_TRUE(res5);
12744+
EXPECT_EQ(304, res5->status);
12745+
12746+
svr.stop();
12747+
t.join();
12748+
std::remove(fname);
12749+
}
12750+
12751+
TEST(ETagTest, LastModifiedAndIfModifiedSince) {
12752+
using namespace httplib;
12753+
12754+
// Create a test file
12755+
const char *fname = "ims_testfile.txt";
12756+
const char *content = "if-modified-since-test";
12757+
{
12758+
std::ofstream ofs(fname);
12759+
ofs << content;
12760+
}
12761+
12762+
Server svr;
12763+
svr.set_mount_point("/static", ".");
12764+
auto t = std::thread([&]() { svr.listen("localhost", 8088); });
12765+
svr.wait_until_ready();
12766+
12767+
Client cli("localhost", 8088);
12768+
12769+
// First request: should get 200 with Last-Modified header
12770+
auto res1 = cli.Get("/static/ims_testfile.txt");
12771+
ASSERT_TRUE(res1);
12772+
ASSERT_EQ(200, res1->status);
12773+
ASSERT_TRUE(res1->has_header("Last-Modified"));
12774+
std::string last_modified = res1->get_header_value("Last-Modified");
12775+
EXPECT_FALSE(last_modified.empty());
12776+
12777+
// If-Modified-Since with same time: expect 304
12778+
Headers h2 = {{"If-Modified-Since", last_modified}};
12779+
auto res2 = cli.Get("/static/ims_testfile.txt", h2);
12780+
ASSERT_TRUE(res2);
12781+
EXPECT_EQ(304, res2->status);
12782+
12783+
// If-Modified-Since with future time: expect 304
12784+
Headers h3 = {{"If-Modified-Since", "Sun, 01 Jan 2099 00:00:00 GMT"}};
12785+
auto res3 = cli.Get("/static/ims_testfile.txt", h3);
12786+
ASSERT_TRUE(res3);
12787+
EXPECT_EQ(304, res3->status);
12788+
12789+
// If-Modified-Since with past time: expect 200
12790+
Headers h4 = {{"If-Modified-Since", "Sun, 01 Jan 2000 00:00:00 GMT"}};
12791+
auto res4 = cli.Get("/static/ims_testfile.txt", h4);
12792+
ASSERT_TRUE(res4);
12793+
EXPECT_EQ(200, res4->status);
12794+
12795+
// If-None-Match takes precedence over If-Modified-Since
12796+
// (send matching ETag with old If-Modified-Since -> should still be 304)
12797+
ASSERT_TRUE(res1->has_header("ETag"));
12798+
std::string etag = res1->get_header_value("ETag");
12799+
Headers h5 = {{"If-None-Match", etag},
12800+
{"If-Modified-Since", "Sun, 01 Jan 2000 00:00:00 GMT"}};
12801+
auto res5 = cli.Get("/static/ims_testfile.txt", h5);
12802+
ASSERT_TRUE(res5);
12803+
EXPECT_EQ(304, res5->status);
12804+
12805+
svr.stop();
12806+
t.join();
12807+
std::remove(fname);
12808+
}
12809+
12810+
TEST(ETagTest, VaryAcceptEncodingWithCompression) {
12811+
using namespace httplib;
12812+
12813+
Server svr;
12814+
12815+
// Endpoint that returns compressible content
12816+
svr.Get("/compressible", [](const Request &, Response &res) {
12817+
// Return a large enough body to trigger compression
12818+
std::string body(1000, 'a');
12819+
res.set_content(body, "text/plain");
12820+
});
12821+
12822+
auto t = std::thread([&]() { svr.listen("localhost", 8089); });
12823+
svr.wait_until_ready();
12824+
12825+
Client cli("localhost", 8089);
12826+
12827+
// Request with gzip support: should get Vary header when compressed
12828+
cli.set_compress(true);
12829+
auto res1 = cli.Get("/compressible");
12830+
ASSERT_TRUE(res1);
12831+
EXPECT_EQ(200, res1->status);
12832+
12833+
// If Content-Encoding is set, Vary should also be set
12834+
if (res1->has_header("Content-Encoding")) {
12835+
EXPECT_TRUE(res1->has_header("Vary"));
12836+
EXPECT_EQ("Accept-Encoding", res1->get_header_value("Vary"));
12837+
}
12838+
12839+
// Request without Accept-Encoding header: should not have compression
12840+
Headers h_no_compress;
12841+
auto res2 = cli.Get("/compressible", h_no_compress);
12842+
ASSERT_TRUE(res2);
12843+
EXPECT_EQ(200, res2->status);
12844+
12845+
// Verify Vary header is present when compression is applied
12846+
// (the exact behavior depends on server configuration)
12847+
12848+
svr.stop();
12849+
t.join();
12850+
}

0 commit comments

Comments
 (0)