Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
110 changes: 108 additions & 2 deletions httplib.h
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down Expand Up @@ -1245,6 +1248,7 @@ class Server {

std::atomic<bool> is_running_{false};
std::atomic<bool> is_decommissioned{false};
std::atomic<bool> is_etag_enabled{false};

struct MountPointEntry {
std::string mount_point;
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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);
Expand All @@ -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<std::string> 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_,
Expand Down
137 changes: 136 additions & 1 deletion test/test.cc
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
#include <algorithm>
#include <atomic>
#include <chrono>
#include <cstdint>
#include <cstdio>
#include <fstream>
#include <future>
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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<detail::mmap>("./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<std::string>{
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<detail::mmap>("./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<std::string>{" * ", 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());
}
}
}
}