diff --git a/src/meson.build b/src/meson.build index 7418fd28e..411f14f28 100644 --- a/src/meson.build +++ b/src/meson.build @@ -21,6 +21,7 @@ kiwix_sources = [ 'tools/otherTools.cpp', 'kiwixserve.cpp', 'name_mapper.cpp', + 'server/etag.cpp', 'server/request_context.cpp', 'server/response.cpp' ] diff --git a/src/server.cpp b/src/server.cpp index 4a99a9609..c9372e899 100644 --- a/src/server.cpp +++ b/src/server.cpp @@ -111,10 +111,11 @@ class InternalServer { bool start(); void stop(); - private: + private: // functions Response handle_request(const RequestContext& request); Response build_500(const std::string& msg); Response build_404(const RequestContext& request, const std::string& zimName); + Response build_304(const RequestContext& request, const ETag& etag) const; Response build_redirect(const std::string& bookName, const kiwix::Entry& entry) const; Response build_homepage(const RequestContext& request); Response handle_skin(const RequestContext& request); @@ -131,6 +132,8 @@ class InternalServer { Response get_default_response() const; std::shared_ptr get_reader(const std::string& bookName) const; + bool etag_not_needed(const RequestContext& r) const; + ETag get_matching_if_none_match_etag(const RequestContext& request) const; private: // data std::string m_addr; @@ -145,6 +148,8 @@ class InternalServer { Library* mp_library; NameMapper* mp_nameMapper; + + std::string m_server_id; }; @@ -252,6 +257,8 @@ bool InternalServer::start() { << std::endl; return false; } + auto server_start_time = std::chrono::system_clock::now().time_since_epoch(); + m_server_id = kiwix::to_string(server_start_time.count()); return true; } @@ -301,7 +308,8 @@ int InternalServer::handlerCallback(struct MHD_Connection* connection, } /* Unexpected method */ if (request.get_method() != RequestMethod::GET - && request.get_method() != RequestMethod::POST) { + && request.get_method() != RequestMethod::POST + && request.get_method() != RequestMethod::HEAD) { printf("Reject request because of unhandled request method.\n"); printf("----------------------\n"); return MHD_NO; @@ -318,6 +326,9 @@ int InternalServer::handlerCallback(struct MHD_Connection* connection, } } + if (response.getReturnCode() == MHD_HTTP_OK && !etag_not_needed(request)) + response.set_server_id(m_server_id); + auto ret = response.send(request, connection); auto end_time = std::chrono::steady_clock::now(); auto time_span = std::chrono::duration_cast>(end_time - start_time); @@ -328,6 +339,14 @@ int InternalServer::handlerCallback(struct MHD_Connection* connection, return ret; } +Response InternalServer::build_304(const RequestContext& request, const ETag& etag) const +{ + auto response = get_default_response(); + response.set_code(MHD_HTTP_NOT_MODIFIED); + response.set_etag(etag); + response.set_content(""); + return response; +} Response InternalServer::handle_request(const RequestContext& request) { @@ -335,6 +354,10 @@ Response InternalServer::handle_request(const RequestContext& request) if (! request.is_valid_url()) return build_404(request, ""); + const ETag etag = get_matching_if_none_match_etag(request); + if ( etag ) + return build_304(request, etag); + if (kiwix::startsWith(request.get_url(), "/skin/")) return handle_skin(request); @@ -427,6 +450,27 @@ MustacheData InternalServer::homepage_data() const return data; } +bool InternalServer::etag_not_needed(const RequestContext& request) const +{ + const std::string url = request.get_url(); + return kiwix::startsWith(url, "/catalog") + || url == "/search" + || url == "/suggest" + || url == "/random" + || url == "/catch/external"; +} + +ETag +InternalServer::get_matching_if_none_match_etag(const RequestContext& r) const +{ + try { + const std::string etag_list = r.get_header(MHD_HTTP_HEADER_IF_NONE_MATCH); + return ETag::match(etag_list, m_server_id); + } catch (const std::out_of_range&) { + return ETag(); + } +} + Response InternalServer::build_homepage(const RequestContext& request) { auto response = get_default_response(); @@ -485,7 +529,7 @@ Response InternalServer::handle_meta(const RequestContext& request) response.set_content(content); response.set_mimeType(mimeType); response.set_compress(false); - response.set_cache(true); + response.set_cacheable(); return response; } @@ -569,7 +613,7 @@ Response InternalServer::handle_skin(const RequestContext& request) } response.set_mimeType(getMimeTypeForFile(resourceName)); response.set_compress(true); - response.set_cache(true); + response.set_cacheable(); return response; } diff --git a/src/server/etag.cpp b/src/server/etag.cpp new file mode 100644 index 000000000..1ba89470f --- /dev/null +++ b/src/server/etag.cpp @@ -0,0 +1,135 @@ +/* + * Copyright 2020 Veloman Yunkan + * + * This program is free software; you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation; either version 3 of the License, or + * any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program; if not, write to the Free Software + * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, + * MA 02110-1301, USA. + */ + + +#include "etag.h" + +#include "tools/stringTools.h" + +#include +#include + +namespace kiwix { + +namespace { + +// Characters in the options part of the ETag could in principle be picked up +// from the latin alphabet in natural order (the character corresponding to +// ETag::Option opt would be 'a'+opt; that would somewhat simplify the code in +// this file). However it is better to have some mnemonics in the option names, +// hence below variable: all_options[opt] corresponds to the character going +// into the ETag for ETag::Option opt. +// IMPORTANT: The characters in all_options must come in sorted order (so that +// IMPORTANT: isValidOptionsString() works correctly). +const char all_options[] = "cz"; + +static_assert(ETag::OPTION_COUNT == sizeof(all_options) - 1, ""); + +bool isValidServerId(const std::string& s) +{ + return !s.empty() && s.find_first_of("\"/") == std::string::npos; +} + +bool isSubsequenceOf(const std::string& s, const std::string& sortedString) +{ + std::string::size_type i = 0; + for ( const char c : s ) + { + const std::string::size_type j = sortedString.find(c, i); + if ( j == std::string::npos ) + return false; + i = j+1; + } + return true; +} + +bool isValidOptionsString(const std::string& s) +{ + return isSubsequenceOf(s, all_options); +} + +} // namespace + + +void ETag::set_option(Option opt) +{ + if ( ! get_option(opt) ) + { + m_options.push_back(all_options[opt]); + std::sort(m_options.begin(), m_options.end()); + } +} + +bool ETag::get_option(Option opt) const +{ + return m_options.find(all_options[opt]) != std::string::npos; +} + +std::string ETag::get_etag() const +{ + if ( m_serverId.empty() ) + return std::string(); + + return "\"" + m_serverId + "/" + m_options + "\""; +} + +ETag::ETag(const std::string& serverId, const std::string& options) +{ + if ( isValidServerId(serverId) && isValidOptionsString(options) ) + { + m_serverId = serverId; + m_options = options; + } +} + +ETag ETag::parse(std::string s) +{ + if ( kiwix::startsWith("W/", s) ) + s = s.substr(2); + + if ( s.front() != '"' || s.back() != '"' ) + return ETag(); + + s = s.substr(1, s.size()-2); + + const std::string::size_type i = s.find('/'); + if ( i == std::string::npos ) + return ETag(); + + return ETag(s.substr(0, i), s.substr(i+1)); +} + +ETag ETag::match(const std::string& etags, const std::string& server_id) +{ + std::istringstream ss(etags); + std::string etag_str; + while ( ss >> etag_str ) + { + if ( etag_str.back() == ',' ) + etag_str.pop_back(); + + const ETag etag = parse(etag_str); + if ( etag && etag.m_serverId == server_id ) + return etag; + } + + return ETag(); +} + +} // namespace kiwix diff --git a/src/server/etag.h b/src/server/etag.h new file mode 100644 index 000000000..49ff0e724 --- /dev/null +++ b/src/server/etag.h @@ -0,0 +1,85 @@ +/* + * Copyright 2020 Veloman Yunkan + * + * This program is free software; you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation; either version 3 of the License, or + * any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program; if not, write to the Free Software + * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, + * MA 02110-1301, USA. + */ + + +#ifndef KIWIXLIB_SERVER_ETAG_H +#define KIWIXLIB_SERVER_ETAG_H + +#include + +namespace kiwix { + +// The ETag string used by Kiwix server (more precisely, its value inside the +// double quotes) consists of two parts: +// +// 1. ServerId - The string obtained on server start up +// +// 2. Options - Zero or more characters encoding the values of some of the +// headers of the response +// +// The two parts are separated with a slash (/) symbol (which is always present, +// even when the the options part is empty). Neither portion of a Kiwix ETag +// may contain the slash symbol. +// Examples of valid Kiwix server ETags (including the double quotes): +// +// "abcdefghijklmn/" +// "1234567890/z" +// "1234567890/cz" +// +// The options part of the Kiwix ETag allows to correctly set the required +// headers when responding to a conditional If-None-Match request with a 304 +// (Not Modified) response without following the full code path that would +// discover the necessary options. + +class ETag +{ + public: // types + enum Option { + CACHEABLE_ENTITY, + COMPRESSED_CONTENT, + OPTION_COUNT + }; + + public: // functions + ETag() {} + + void set_server_id(const std::string& id) { m_serverId = id; } + void set_option(Option opt); + + explicit operator bool() const { return !m_serverId.empty(); } + + bool get_option(Option opt) const; + std::string get_etag() const; + + + static ETag match(const std::string& etags, const std::string& server_id); + + private: // functions + ETag(const std::string& serverId, const std::string& options); + + static ETag parse(std::string s); + + private: // data + std::string m_serverId; + std::string m_options; +}; + +} // namespace kiwix + +#endif // KIWIXLIB_SERVER_ETAG_H diff --git a/src/server/response.cpp b/src/server/response.cpp index 7814f5d75..63ac7e273 100644 --- a/src/server/response.cpp +++ b/src/server/response.cpp @@ -55,7 +55,6 @@ Response::Response(const std::string& root, bool verbose, bool withTaskbar, bool m_withTaskbar(withTaskbar), m_withLibraryButton(withLibraryButton), m_blockExternalLinks(blockExternalLinks), - m_useCache(false), m_addTaskbar(false), m_bookName(""), m_startRange(0), @@ -168,6 +167,14 @@ void Response::inject_externallinks_blocker() script_tag); } +bool +Response::can_compress(const RequestContext& request) const +{ + return request.can_compress() + && is_compressible_mime_type(m_mimeType) + && (m_content.size() > KIWIX_MIN_CONTENT_SIZE_TO_DEFLATE); +} + MHD_Response* Response::create_raw_content_mhd_response(const RequestContext& request) { @@ -178,10 +185,7 @@ Response::create_raw_content_mhd_response(const RequestContext& request) inject_externallinks_blocker(); } - bool shouldCompress = m_compress && request.can_compress(); - shouldCompress &= is_compressible_mime_type(m_mimeType); - shouldCompress &= (m_content.size() > KIWIX_MIN_CONTENT_SIZE_TO_DEFLATE); - + bool shouldCompress = m_compress && can_compress(request); if (shouldCompress) { std::vector compr_buffer(compressBound(m_content.size())); uLongf comprLen = compr_buffer.capacity(); @@ -196,6 +200,7 @@ Response::create_raw_content_mhd_response(const RequestContext& request) It has no incidence on other browsers See http://www.subbu.org/blog/2008/03/ie7-deflate-or-not and comments */ m_content = string((char*)&compr_buffer[2], comprLen - 2); + m_etag.set_option(ETag::COMPRESSED_CONTENT); } else { shouldCompress = false; } @@ -204,9 +209,17 @@ Response::create_raw_content_mhd_response(const RequestContext& request) MHD_Response* response = MHD_create_response_from_buffer( m_content.size(), const_cast(m_content.data()), MHD_RESPMEM_MUST_COPY); - if (shouldCompress) { + // At shis point m_etag.get_option(ETag::COMPRESSED_CONTENT) and + // shouldCompress can have different values. This can happen for a 304 (Not + // Modified) response generated while handling a conditional If-None-Match + // request. In that case the m_etag (together with its COMPRESSED_CONTENT + // option) is obtained from the ETag list of the If-None-Match header and the + // response has no body (which shouldn't be compressed). + if ( m_etag.get_option(ETag::COMPRESSED_CONTENT) ) { MHD_add_response_header( response, MHD_HTTP_HEADER_VARY, "Accept-Encoding"); + } + if (shouldCompress) { MHD_add_response_header( response, MHD_HTTP_HEADER_CONTENT_ENCODING, "deflate"); } @@ -267,7 +280,10 @@ int Response::send(const RequestContext& request, MHD_Connection* connection) MHD_add_response_header(response, "Access-Control-Allow-Origin", "*"); MHD_add_response_header(response, MHD_HTTP_HEADER_CACHE_CONTROL, - m_useCache ? "max-age=2723040, public" : "no-cache, no-store, must-revalidate"); + m_etag.get_option(ETag::CACHEABLE_ENTITY) ? "max-age=2723040, public" : "no-cache, no-store, must-revalidate"); + const std::string etag = m_etag.get_etag(); + if ( ! etag.empty() ) + MHD_add_response_header(response, MHD_HTTP_HEADER_ETAG, etag.c_str()); if (m_returnCode == MHD_HTTP_OK && request.has_range()) m_returnCode = MHD_HTTP_PARTIAL_CONTENT; @@ -301,7 +317,7 @@ void Response::set_entry(const Entry& entry, const RequestContext& request) { const std::string mimeType = get_mime_type(entry); set_mimeType(mimeType); - set_cache(true); + set_cacheable(); if ( is_compressible_mime_type(mimeType) ) { zim::Blob raw_content = entry.getBlob(); diff --git a/src/server/response.h b/src/server/response.h index 71364c638..bd15f9543 100644 --- a/src/server/response.h +++ b/src/server/response.h @@ -25,6 +25,7 @@ #include #include "entry.h" +#include "etag.h" extern "C" { #include @@ -55,18 +56,22 @@ class Response { void set_mimeType(const std::string& mimeType) { m_mimeType = mimeType; } void set_code(int code) { m_returnCode = code; } - void set_cache(bool cache) { m_useCache = cache; } + void set_cacheable() { m_etag.set_option(ETag::CACHEABLE_ENTITY); } + void set_server_id(const std::string& id) { m_etag.set_server_id(id); } + void set_etag(const ETag& etag) { m_etag = etag; } void set_compress(bool compress) { m_compress = compress; } void set_taskbar(const std::string& bookName, const std::string& bookTitle); void set_range_first(uint64_t start) { m_startRange = start; } void set_range_len(uint64_t len) { m_lenRange = len; } - int getReturnCode() { return m_returnCode; } + int getReturnCode() const { return m_returnCode; } std::string get_mimeType() const { return m_mimeType; } void introduce_taskbar(); void inject_externallinks_blocker(); + bool can_compress(const RequestContext& request) const; + private: // functions MHD_Response* create_mhd_response(const RequestContext& request); MHD_Response* create_raw_content_mhd_response(const RequestContext& request); @@ -84,13 +89,13 @@ class Response { bool m_withTaskbar; bool m_withLibraryButton; bool m_blockExternalLinks; - bool m_useCache; bool m_compress; bool m_addTaskbar; std::string m_bookName; std::string m_bookTitle; uint64_t m_startRange; uint64_t m_lenRange; + ETag m_etag; }; } diff --git a/test/server.cpp b/test/server.cpp index 58c8450d5..9d5dcd834 100644 --- a/test/server.cpp +++ b/test/server.cpp @@ -6,10 +6,48 @@ #include "./httplib.h" +using TestContextImpl = std::vector >; +struct TestContext : TestContextImpl { + TestContext(const std::initializer_list& il) + : TestContextImpl(il) + {} +}; + +std::ostream& operator<<(std::ostream& out, const TestContext& ctx) +{ + out << "Test context:\n"; + for ( const auto& kv : ctx ) + out << "\t" << kv.first << ": " << kv.second << "\n"; + out << std::endl; + return out; +} + +bool is_valid_etag(const std::string& etag) +{ + return etag.size() >= 2 && + etag.front() == '"' && + etag.back() == '"'; +} + +template +T1 concat(T1 a, const T2& b) +{ + a.insert(a.end(), b.begin(), b.end()); + return a; +} + +typedef httplib::Headers Headers; + +Headers invariantHeaders(Headers headers) +{ + headers.erase("Date"); + return headers; +} + + class ZimFileServer { public: // types - typedef httplib::Headers Headers; typedef std::shared_ptr Response; public: // functions @@ -77,8 +115,12 @@ protected: } }; +const bool WITH_ETAG = true; +const bool NO_ETAG = false; + struct Resource { + bool etag_expected; const char* url; }; @@ -88,69 +130,59 @@ std::ostream& operator<<(std::ostream& out, const Resource& r) return out; } -Resource resources200Compressible[] = { - { "/" }, +typedef std::vector ResourceCollection; - { "/skin/jquery-ui/jquery-ui.structure.min.css" }, - { "/skin/jquery-ui/jquery-ui.min.js" }, - { "/skin/jquery-ui/external/jquery/jquery.js" }, - { "/skin/jquery-ui/jquery-ui.theme.min.css" }, - { "/skin/jquery-ui/jquery-ui.min.css" }, - { "/skin/taskbar.js" }, - { "/skin/taskbar.css" }, - { "/skin/block_external.js" }, +const ResourceCollection resources200Compressible{ + { WITH_ETAG, "/" }, - { "/search?content=zimfile&pattern=abcd" }, + { WITH_ETAG, "/skin/jquery-ui/jquery-ui.structure.min.css" }, + { WITH_ETAG, "/skin/jquery-ui/jquery-ui.min.js" }, + { WITH_ETAG, "/skin/jquery-ui/external/jquery/jquery.js" }, + { WITH_ETAG, "/skin/jquery-ui/jquery-ui.theme.min.css" }, + { WITH_ETAG, "/skin/jquery-ui/jquery-ui.min.css" }, + { WITH_ETAG, "/skin/taskbar.js" }, + { WITH_ETAG, "/skin/taskbar.css" }, + { WITH_ETAG, "/skin/block_external.js" }, - { "/suggest?content=zimfile&term=ray" }, + { NO_ETAG, "/search?content=zimfile&pattern=abcd" }, - { "/catch/external?source=www.example.com" }, + { NO_ETAG, "/suggest?content=zimfile&term=ray" }, - { "/zimfile/A/index" }, - { "/zimfile/A/Ray_Charles" }, + { NO_ETAG, "/catch/external?source=www.example.com" }, + + { WITH_ETAG, "/zimfile/A/index" }, + { WITH_ETAG, "/zimfile/A/Ray_Charles" }, }; -Resource resources200Uncompressible[] = { - { "/skin/jquery-ui/images/ui-bg_flat_0_aaaaaa_40x100.png" }, - { "/skin/jquery-ui/images/ui-bg_flat_75_ffffff_40x100.png" }, - { "/skin/jquery-ui/images/ui-icons_222222_256x240.png" }, - { "/skin/jquery-ui/images/ui-bg_glass_55_fbf9ee_1x400.png" }, - { "/skin/jquery-ui/images/ui-bg_highlight-soft_75_cccccc_1x100.png" }, - { "/skin/jquery-ui/images/ui-bg_glass_65_ffffff_1x400.png" }, - { "/skin/jquery-ui/images/ui-icons_2e83ff_256x240.png" }, - { "/skin/jquery-ui/images/ui-icons_cd0a0a_256x240.png" }, - { "/skin/jquery-ui/images/ui-icons_888888_256x240.png" }, - { "/skin/jquery-ui/images/ui-bg_glass_75_e6e6e6_1x400.png" }, - { "/skin/jquery-ui/images/animated-overlay.gif" }, - { "/skin/jquery-ui/images/ui-bg_glass_75_dadada_1x400.png" }, - { "/skin/jquery-ui/images/ui-icons_454545_256x240.png" }, - { "/skin/jquery-ui/images/ui-bg_glass_95_fef1ec_1x400.png" }, - { "/skin/caret.png" }, +const ResourceCollection resources200Uncompressible{ + { WITH_ETAG, "/skin/jquery-ui/images/animated-overlay.gif" }, + { WITH_ETAG, "/skin/caret.png" }, - { "/catalog/root.xml" }, - { "/catalog/searchdescription.xml" }, - { "/catalog/search" }, + { NO_ETAG, "/catalog/root.xml" }, + { NO_ETAG, "/catalog/searchdescription.xml" }, + { NO_ETAG, "/catalog/search" }, - { "/meta?content=zimfile&name=title" }, - { "/meta?content=zimfile&name=description" }, - { "/meta?content=zimfile&name=language" }, - { "/meta?content=zimfile&name=name" }, - { "/meta?content=zimfile&name=tags" }, - { "/meta?content=zimfile&name=date" }, - { "/meta?content=zimfile&name=creator" }, - { "/meta?content=zimfile&name=publisher" }, - { "/meta?content=zimfile&name=favicon" }, + { WITH_ETAG, "/meta?content=zimfile&name=title" }, + { WITH_ETAG, "/meta?content=zimfile&name=description" }, + { WITH_ETAG, "/meta?content=zimfile&name=language" }, + { WITH_ETAG, "/meta?content=zimfile&name=name" }, + { WITH_ETAG, "/meta?content=zimfile&name=tags" }, + { WITH_ETAG, "/meta?content=zimfile&name=date" }, + { WITH_ETAG, "/meta?content=zimfile&name=creator" }, + { WITH_ETAG, "/meta?content=zimfile&name=publisher" }, + { WITH_ETAG, "/meta?content=zimfile&name=favicon" }, - { "/zimfile/I/m/Ray_Charles_classic_piano_pose.jpg" }, + { WITH_ETAG, "/zimfile/I/m/Ray_Charles_classic_piano_pose.jpg" }, }; +ResourceCollection all200Resources() +{ + return concat(resources200Compressible, resources200Uncompressible); +} TEST_F(ServerTest, 200) { - for ( const Resource& res : resources200Compressible ) - EXPECT_EQ(200, zfs1_->GET(res.url)->status) << "res.url: " << res.url; - - for ( const Resource& res : resources200Uncompressible ) + for ( const Resource& res : all200Resources() ) EXPECT_EQ(200, zfs1_->GET(res.url)->status) << "res.url: " << res.url; } @@ -221,3 +253,156 @@ TEST_F(ServerTest, BookMainPageIsRedirectedToArticleIndex) ASSERT_TRUE(g->has_header("Location")); ASSERT_EQ("/zimfile/A/index", g->get_header_value("Location")); } + +TEST_F(ServerTest, HeadMethodIsSupported) +{ + for ( const Resource& res : all200Resources() ) + EXPECT_EQ(200, zfs1_->HEAD(res.url)->status) << res; +} + +TEST_F(ServerTest, TheResponseToHeadRequestHasNoBody) +{ + for ( const Resource& res : all200Resources() ) + EXPECT_TRUE(zfs1_->HEAD(res.url)->body.empty()) << res; +} + +TEST_F(ServerTest, HeadersAreTheSameInResponsesToHeadAndGetRequests) +{ + for ( const Resource& res : all200Resources() ) { + httplib::Headers g = zfs1_->GET(res.url)->headers; + httplib::Headers h = zfs1_->HEAD(res.url)->headers; + EXPECT_EQ(invariantHeaders(g), invariantHeaders(h)) << res; + } +} + +TEST_F(ServerTest, ETagHeaderIsSetAsNeeded) +{ + for ( const Resource& res : all200Resources() ) { + const auto responseToGet = zfs1_->GET(res.url); + EXPECT_EQ(res.etag_expected, responseToGet->has_header("ETag")) << res; + if ( res.etag_expected ) + EXPECT_TRUE(is_valid_etag(responseToGet->get_header_value("ETag"))); + } +} + +TEST_F(ServerTest, ETagIsTheSameInResponsesToDifferentRequestsOfTheSameURL) +{ + for ( const Resource& res : all200Resources() ) { + const auto h1 = zfs1_->HEAD(res.url); + const auto h2 = zfs1_->HEAD(res.url); + EXPECT_EQ(h1->get_header_value("ETag"), h2->get_header_value("ETag")); + } +} + +TEST_F(ServerTest, ETagIsTheSameAcrossHeadAndGet) +{ + for ( const Resource& res : all200Resources() ) { + const auto g = zfs1_->GET(res.url); + const auto h = zfs1_->HEAD(res.url); + EXPECT_EQ(h->get_header_value("ETag"), g->get_header_value("ETag")); + } +} + +TEST_F(ServerTest, DifferentServerInstancesProduceDifferentETags) +{ + ZimFileServer zfs2(PORT + 1, ZIMFILE); + for ( const Resource& res : all200Resources() ) { + if ( !res.etag_expected ) continue; + const auto h1 = zfs1_->HEAD(res.url); + const auto h2 = zfs2.HEAD(res.url); + EXPECT_NE(h1->get_header_value("ETag"), h2->get_header_value("ETag")); + } +} + +TEST_F(ServerTest, CompressionInfluencesETag) +{ + for ( const Resource& res : resources200Compressible ) { + if ( ! res.etag_expected ) continue; + const auto g1 = zfs1_->GET(res.url); + const auto g2 = zfs1_->GET(res.url, { {"Accept-Encoding", ""} } ); + const auto g3 = zfs1_->GET(res.url, { {"Accept-Encoding", "deflate"} } ); + const auto etag = g1->get_header_value("ETag"); + EXPECT_EQ(etag, g2->get_header_value("ETag")); + EXPECT_NE(etag, g3->get_header_value("ETag")); + } +} + +TEST_F(ServerTest, ETagOfUncompressibleContentIsNotAffectedByAcceptEncoding) +{ + for ( const Resource& res : resources200Uncompressible ) { + if ( ! res.etag_expected ) continue; + const auto g1 = zfs1_->GET(res.url); + const auto g2 = zfs1_->GET(res.url, { {"Accept-Encoding", ""} } ); + const auto g3 = zfs1_->GET(res.url, { {"Accept-Encoding", "deflate"} } ); + const auto etag = g1->get_header_value("ETag"); + EXPECT_EQ(etag, g2->get_header_value("ETag")) << res; + EXPECT_EQ(etag, g3->get_header_value("ETag")) << res; + } +} + +// Pick from the response those headers that are required to be present in the +// 304 (Not Modified) response if they would be set in the 200 (OK) response. +// NOTE: The "Date" header (which should belong to that list as required +// NOTE: by RFC 7232) is not included (since the result of this function +// NOTE: will be used to check the equality of headers from the 200 and 304 +// NOTe: responses). +Headers special304Headers(const httplib::Response& r) +{ + Headers result; + std::copy_if( + r.headers.begin(), r.headers.end(), + std::inserter(result, result.end()), + [](const Headers::value_type& x) { + return x.first == "Cache-Control" + || x.first == "Content-Location" + || x.first == "ETag" + || x.first == "Expires" + || x.first == "Vary"; + }); + return result; +} + +// make a list of three etags with the given one in the middle +std::string make_etag_list(const std::string& etag) +{ + return "\"x" + etag.substr(1) + ", " + + etag + ", " + + etag.substr(0, etag.size()-2) + "\""; +} + +TEST_F(ServerTest, IfNoneMatchRequestsWithMatchingETagResultIn304Responses) +{ + const char* const encodings[] = { "", "deflate" }; + for ( const Resource& res : all200Resources() ) { + for ( const char* enc: encodings ) { + if ( ! res.etag_expected ) continue; + const TestContext ctx{ {"url", res.url}, {"encoding", enc} }; + + const auto g = zfs1_->GET(res.url, { {"Accept-Encoding", enc} }); + const auto etag = g->get_header_value("ETag"); + + const std::string etags = make_etag_list(etag); + const Headers headers{{"If-None-Match", etags}, {"Accept-Encoding", enc}}; + const auto g2 = zfs1_->GET(res.url, headers ); + const auto h = zfs1_->HEAD(res.url, headers ); + EXPECT_EQ(304, h->status) << ctx; + EXPECT_EQ(304, g2->status) << ctx; + EXPECT_EQ(special304Headers(*g), special304Headers(*g2)) << ctx; + EXPECT_EQ(special304Headers(*g2), special304Headers(*h)) << ctx; + EXPECT_TRUE(g2->body.empty()) << ctx; + } + } +} + +TEST_F(ServerTest, IfNoneMatchRequestsWithMismatchingETagResultIn200Responses) +{ + for ( const Resource& res : all200Resources() ) { + const auto g = zfs1_->GET(res.url); + const auto etag = g->get_header_value("ETag"); + const auto etag2 = etag.substr(0, etag.size() - 1) + "x\""; + const auto h = zfs1_->HEAD(res.url, { {"If-None-Match", etag2} } ); + const auto g2 = zfs1_->GET(res.url, { {"If-None-Match", etag2} } ); + EXPECT_EQ(200, h->status); + EXPECT_EQ(200, g2->status); + } +}