diff --git a/httplib.h b/httplib.h index b76a17d07a..d63c9078bd 100644 --- a/httplib.h +++ b/httplib.h @@ -1151,6 +1151,9 @@ class Server { Server &set_payload_max_length(size_t length); + bool get_is_etag_enabled() const; + Server &set_is_etag_enabled(const bool &enabled); + bool bind_to_port(const std::string &host, int port, int socket_flags = 0); int bind_to_any_port(const std::string &host, int socket_flags = 0); bool listen_after_bind(); @@ -1245,6 +1248,7 @@ class Server { std::atomic is_running_{false}; std::atomic is_decommissioned{false}; + std::atomic is_etag_enabled{false}; struct MountPointEntry { std::string mount_point; @@ -2412,6 +2416,7 @@ struct FileStat { FileStat(const std::string &path); bool is_file() const; bool is_dir() const; + std::uint64_t last_modified() const; private: #if defined(_WIN32) @@ -2889,6 +2894,17 @@ inline bool FileStat::is_file() const { inline bool FileStat::is_dir() const { return ret_ >= 0 && S_ISDIR(st_.st_mode); } +inline std::uint64_t FileStat::last_modified() const { + if (is_dir() || is_file()) { +#if defined(_WIN32) + return st_.st_mtime; +#else + return st_.st_mtim.tv_sec; +#endif + } else { + throw std::runtime_error("Invalid directory or file."); + } +} inline std::string encode_path(const std::string &s) { std::string result; @@ -7567,6 +7583,13 @@ inline Server &Server::set_payload_max_length(size_t length) { return *this; } +inline bool Server::get_is_etag_enabled() const { return is_etag_enabled; } + +inline Server &Server::set_is_etag_enabled(const bool &enabled) { + is_etag_enabled = enabled; + return *this; +} + inline bool Server::bind_to_port(const std::string &host, int port, int socket_flags) { auto ret = bind_internal(host, port, socket_flags); @@ -7919,12 +7942,13 @@ inline bool Server::handle_file_request(const Request &req, Response &res) { for (const auto &entry : base_dirs_) { // Prefix match if (!req.path.compare(0, entry.mount_point.size(), entry.mount_point)) { - std::string sub_path = "/" + req.path.substr(entry.mount_point.size()); + const std::string sub_path = + "/" + req.path.substr(entry.mount_point.size()); if (detail::is_valid_path(sub_path)) { auto path = entry.base_dir + sub_path; if (path.back() == '/') { path += "index.html"; } - detail::FileStat stat(path); + const detail::FileStat stat(path); if (stat.is_dir()) { res.set_redirect(sub_path + "/", StatusCode::MovedPermanently_301); @@ -7942,6 +7966,88 @@ inline bool Server::handle_file_request(const Request &req, Response &res) { return false; } + if (is_etag_enabled) { + /* + * The HTTP request header If-Match and If-None-Match can be used + * with other methods where they have the meaning to only execute if + * the resource does not already exist but uploading does not matter + * here. + * + * HTTP response header ETag is only set where content is + * pulled and not pushed as those HTTP response bodies do not have + * to be related to the content. + */ + if (req.method == "GET" || req.method == "HEAD") { + // Value for HTTP response header ETag. + const std::string etag = + R"(")" + detail::from_i_to_hex(stat.last_modified()) + "-" + + detail::from_i_to_hex(mm->size()) + R"(")"; + + /* + * Weak validation is not used in both cases. + * HTTP response header ETag must be set as if normal HTTP + * response was sent. + */ + res.set_header("ETag", etag); + + /* + * If-Match + * If-value exists, the server will send status code 200. + * Else, the server will send status code 412. + * + * If-None-Match + * If value exists, the server will send status code 304. + * * always results in status code 304. + */ + if (req.has_header("If-Match") || + req.has_header("If-None-Match")) { + std::string header_value; + + if (req.has_header("If-Match")) { + header_value = req.get_header_value("If-Match"); + } else if (req.has_header("If-None-Match")) { + header_value = req.get_header_value("If-None-Match"); + } + + std::set etags; + detail::split(header_value.c_str(), + header_value.c_str() + header_value.length(), ',', + [&](const char *b, const char *e) { + std::string etag(b, e); + etag.erase(0, etag.find_first_not_of(" \t")); + etag.erase(etag.find_last_not_of(" \t") + 1); + + // Weak validation is not used in both cases. + // However, do not remove W/ with HTTP request + // header If-Match as such ETags are need to + // result in false when comparing. + if (req.has_header("If-None-Match") && + etag.length() >= 2 && etag.at(0) == 'W' && + etag.at(1) == '/') { + etag.erase(0, 2); + } + + etags.insert(std::move(etag)); + }); + + if (req.has_header("If-Match")) { + if (etags.find("*") == etags.cend() && + etags.find(etag) == etags.cend()) { + res.status = StatusCode::PreconditionFailed_412; + res.body.clear(); + return true; + } + } else if (req.has_header("If-None-Match") && + (etags.find("*") != etags.cend() || + etags.find(etag) != etags.cend())) { + res.status = StatusCode::NotModified_304; + res.body.clear(); + return true; + } + } + } + } + res.set_content_provider( mm->size(), detail::find_content_type(path, file_extension_and_mimetype_map_, diff --git a/test/test.cc b/test/test.cc index c9d6421719..87d9fe8639 100644 --- a/test/test.cc +++ b/test/test.cc @@ -14,6 +14,7 @@ #include #include #include +#include #include #include #include @@ -10580,17 +10581,24 @@ TEST(UniversalClientImplTest, Ipv6LiteralAddress) { EXPECT_EQ(cli.port(), port); } -TEST(FileSystemTest, FileAndDirExistenceCheck) { +TEST(FileSystemTest, FileStatTest) { auto file_path = "./www/dir/index.html"; auto dir_path = "./www/dir"; detail::FileStat stat_file(file_path); EXPECT_TRUE(stat_file.is_file()); EXPECT_FALSE(stat_file.is_dir()); + EXPECT_GT(stat_file.last_modified(), 0); detail::FileStat stat_dir(dir_path); EXPECT_FALSE(stat_dir.is_file()); EXPECT_TRUE(stat_dir.is_dir()); + EXPECT_GT(stat_dir.last_modified(), 0); + + detail::FileStat stat_error("ranipsd"); + EXPECT_FALSE(stat_error.is_file()); + EXPECT_FALSE(stat_error.is_dir()); + EXPECT_THROW(stat_error.last_modified(), std::runtime_error); } TEST(MakeHostAndPortStringTest, VariousPatterns) { @@ -11444,3 +11452,130 @@ TEST(ForwardedHeadersTest, HandlesWhitespaceAroundIPs) { EXPECT_EQ(observed_xff, "198.51.100.23 , 203.0.113.66 , 192.0.2.45"); EXPECT_EQ(observed_remote_addr, "203.0.113.66"); } + +TEST(is_etag_enabled, getter_and_setter) { + httplib::Server svr; + + EXPECT_FALSE(svr.get_is_etag_enabled()); + svr.set_is_etag_enabled(true); + EXPECT_TRUE(svr.get_is_etag_enabled()); +} + +TEST(StaticFileSever, IfMatch) { + const detail::FileStat stat("./www/file"); + ASSERT_TRUE(stat.is_file()); + auto mm = std::make_shared("./www/file"); + ASSERT_TRUE(mm->is_open()); + + const std::string etag = R"(")" + + detail::from_i_to_hex(stat.last_modified()) + "-" + + detail::from_i_to_hex(mm->size()) + R"(")"; + + /* + * 0: is_etag_enabled = false + * 1: is_etag_enabled = true + */ + for (std::uint8_t i = 0; i < 2; ++i) { + for (const std::string &header_if_match : + std::initializer_list{ + R"("wcupin")", " * ", R"("r", *)", R"(*, "x")", etag, + R"("o", )" + etag, etag + R"(, "a")"}) { + httplib::Server svr; + svr.set_mount_point("/", "./www/"); + svr.set_is_etag_enabled(i == 1); + + std::thread t = thread([&]() { svr.listen(HOST, PORT); }); + auto se = detail::scope_exit([&] { + svr.stop(); + t.join(); + ASSERT_FALSE(svr.is_running()); + }); + + svr.wait_until_ready(); + + httplib::Client client(HOST, PORT); + const httplib::Result result = + client.Get("/file", Headers({{"If-Match", header_if_match}})); + + ASSERT_NE(result, nullptr); + EXPECT_EQ(result.error(), Error::Success); + + if (i == 0) { + EXPECT_EQ(result->status, StatusCode::OK_200); + } else if (i == 1) { + if (header_if_match == R"("wcupin")") { + EXPECT_EQ(result->status, StatusCode::PreconditionFailed_412); + } else { + EXPECT_EQ(result->status, StatusCode::OK_200); + } + } + + if (i == 0) { + EXPECT_FALSE(result->has_header("ETag")); + } else if (i == 1) { + EXPECT_TRUE(result->has_header("ETag")); + EXPECT_EQ(result->get_header_value("ETag"), etag); + + if (header_if_match == R"("wcupin")") { + EXPECT_TRUE(result->body.empty()); + } + } + } + } +} + +TEST(StaticFileSever, IfNoneMatch) { + const detail::FileStat stat("./www/file"); + ASSERT_TRUE(stat.is_file()); + auto mm = std::make_shared("./www/file"); + ASSERT_TRUE(mm->is_open()); + + const std::string etag = R"(")" + + detail::from_i_to_hex(stat.last_modified()) + "-" + + detail::from_i_to_hex(mm->size()) + R"(")"; + + /* + * 0: is_etag_enabled = false + * 1: is_etag_enabled = true + */ + for (std::uint8_t i = 0; i < 2; ++i) { + for (const std::string &header_if_none_match : + std::initializer_list{" * ", R"("f", *)", R"(*, "i")", + etag, R"("d", )" + etag, + "W/" + etag + R"(, "g")"}) { + httplib::Server svr; + svr.set_mount_point("/", "./www/"); + svr.set_is_etag_enabled(i == 1); + + std::thread t = thread([&]() { svr.listen(HOST, PORT); }); + auto se = detail::scope_exit([&] { + svr.stop(); + t.join(); + ASSERT_FALSE(svr.is_running()); + }); + + svr.wait_until_ready(); + + httplib::Client client(HOST, PORT); + const httplib::Result result = client.Get( + "/file", Headers({{"If-None-Match", header_if_none_match}})); + + ASSERT_NE(result, nullptr); + EXPECT_EQ(result.error(), Error::Success); + + if (i == 0) { + EXPECT_EQ(result->status, StatusCode::OK_200); + } else if (i == 1) { + EXPECT_EQ(result->status, StatusCode::NotModified_304); + } + + if (i == 0) { + EXPECT_FALSE(result->has_header("ETag")); + } else if (i == 1) { + EXPECT_TRUE(result->has_header("ETag")); + EXPECT_EQ(result->get_header_value("ETag"), etag); + EXPECT_TRUE(result->body.empty()); + } + } + } +}