diff --git a/src/server/internalServer.cpp b/src/server/internalServer.cpp index 611f36fc..d364e414 100644 --- a/src/server/internalServer.cpp +++ b/src/server/internalServer.cpp @@ -1085,9 +1085,7 @@ std::unique_ptr InternalServer::handle_captured_external(const Request return UrlNotFoundResponse(request); } - auto data = get_default_data(); - data.set("source", source); - return ContentResponse::build(RESOURCE::templates::captured_external_html, data, "text/html; charset=utf-8"); + return BlockExternalLinkResponse(request, m_root, source); } std::unique_ptr InternalServer::handle_catch(const RequestContext& request) @@ -1121,15 +1119,6 @@ InternalServer::search_catalog(const RequestContext& request, namespace { -ParameterizedMessage suggestSearchMsg(const std::string& searchURL, const std::string& pattern) -{ - return ParameterizedMessage("suggest-search", - { - { "PATTERN", pattern }, - { "SEARCH_URL", searchURL } - }); -} - /////////////////////////////////////////////////////////////////////////////// // The content security policy below is set on responses to the /content // endpoint in order to prevent the ZIM content from interfering with the @@ -1183,9 +1172,7 @@ std::unique_ptr InternalServer::handle_content(const RequestContext& r } catch (const std::out_of_range& e) {} if (archive == nullptr) { - const std::string searchURL = m_root + "/search?pattern=" + kiwix::urlEncode(pattern); - return UrlNotFoundResponse(request) - + suggestSearchMsg(searchURL, kiwix::urlDecode(pattern)); + return NewHTTP404Response(request, m_root, m_root + url); } const std::string archiveUuid(archive->getUuid()); @@ -1230,9 +1217,7 @@ std::unique_ptr InternalServer::handle_content(const RequestContext& r if (m_verbose.load()) printf("Failed to find %s\n", urlStr.c_str()); - std::string searchURL = m_root + "/search?content=" + bookName + "&pattern=" + kiwix::urlEncode(pattern); - return UrlNotFoundResponse(request) - + suggestSearchMsg(searchURL, kiwix::urlDecode(pattern)); + return NewHTTP404Response(request, m_root, m_root + url); } } diff --git a/src/server/response.cpp b/src/server/response.cpp index 7520ab1c..aa3eadb1 100644 --- a/src/server/response.cpp +++ b/src/server/response.cpp @@ -243,6 +243,23 @@ public: }; } + static Data fromMsgId(const std::string& nonParameterizedMsgId) + { + return from(nonParameterizedMessage(nonParameterizedMsgId)); + } + + static Data staticMultiParagraphText(const std::string& msgIdPrefix, size_t n) + { + Object paragraphs; + for ( size_t i = 1; i <= n; ++i ) { + std::ostringstream oss; + oss << "p" << i; + const std::string pId = oss.str(); + paragraphs[pId] = fromMsgId(msgIdPrefix + "." + pId); + } + return paragraphs; + } + std::string asJSON() const; void dumpJSON(std::ostream& os) const; @@ -368,6 +385,45 @@ std::unique_ptr ContentResponseBlueprint::generateResponseObjec return r; } +NewHTTP404Response::NewHTTP404Response(const RequestContext& request, + const std::string& root, + const std::string& urlPath) + : ContentResponseBlueprint(&request, + MHD_HTTP_NOT_FOUND, + "text/html; charset=utf-8", + RESOURCE::templates::sexy404_html, + /*includeKiwixResponseData=*/true) +{ + *this->m_data = Data(Data::Object{ + {"root", root }, + {"url_path", urlPath}, + {"PAGE_TITLE", Data::fromMsgId("new-404-page-title")}, + {"PAGE_HEADING", Data::fromMsgId("new-404-page-heading")}, + {"404_img_text", Data::fromMsgId("404-img-text")}, + {"path_was_not_found_msg", Data::fromMsgId("path-was-not-found")}, + {"advice", Data::staticMultiParagraphText("404-advice", 5)}, + }); +} + +BlockExternalLinkResponse::BlockExternalLinkResponse(const RequestContext& request, + const std::string& root, + const std::string& externalUrl) + : ContentResponseBlueprint(&request, + MHD_HTTP_OK, + "text/html; charset=utf-8", + RESOURCE::templates::captured_external_html, + /*includeKiwixResponseData=*/true) +{ + *this->m_data = Data(Data::Object{ + {"root", root }, + {"external_link_detected", Data::fromMsgId("external-link-detected") }, + {"url", externalUrl }, + {"caution_warning", Data::fromMsgId("caution-warning") }, + {"external_link_intro", Data::fromMsgId("external-link-intro") }, + {"advice", Data::staticMultiParagraphText("external-link-advice", 3)}, + }); +} + HTTPErrorResponse::HTTPErrorResponse(const RequestContext& request, int httpStatusCode, const std::string& pageTitleMsgId, @@ -383,8 +439,8 @@ HTTPErrorResponse::HTTPErrorResponse(const RequestContext& request, Data::List emptyList; *this->m_data = Data(Data::Object{ {"CSS_URL", Data::onlyAsNonEmptyValue(cssUrl) }, - {"PAGE_TITLE", Data::from(nonParameterizedMessage(pageTitleMsgId))}, - {"PAGE_HEADING", Data::from(nonParameterizedMessage(headingMsgId))}, + {"PAGE_TITLE", Data::fromMsgId(pageTitleMsgId)}, + {"PAGE_HEADING", Data::fromMsgId(headingMsgId)}, {"details", emptyList} }); } diff --git a/src/server/response.h b/src/server/response.h index b4c9925f..597caa9d 100644 --- a/src/server/response.h +++ b/src/server/response.h @@ -145,6 +145,13 @@ protected: //data std::unique_ptr m_data; }; +struct NewHTTP404Response : ContentResponseBlueprint +{ + NewHTTP404Response(const RequestContext& request, + const std::string& root, + const std::string& urlPath); +}; + struct HTTPErrorResponse : ContentResponseBlueprint { HTTPErrorResponse(const RequestContext& request, @@ -190,6 +197,13 @@ class ItemResponse : public Response { std::string m_mimeType; }; +struct BlockExternalLinkResponse : ContentResponseBlueprint +{ + BlockExternalLinkResponse(const RequestContext& request, + const std::string& root, + const std::string& externalUrl); +}; + } #endif //KIWIXLIB_SERVER_RESPONSE_H diff --git a/static/resources_list.txt b/static/resources_list.txt index faa53cb3..15d0624d 100644 --- a/static/resources_list.txt +++ b/static/resources_list.txt @@ -1,6 +1,8 @@ skin/caret.png skin/bittorrent.png skin/magnet.png +skin/404.svg +skin/blocklink.svg skin/feed.svg skin/langSelector.svg skin/download.png @@ -11,9 +13,11 @@ skin/iso6391To3.js skin/isotope.pkgd.min.js skin/index.js skin/autoComplete/autoComplete.min.js +skin/error.css skin/kiwix.css skin/taskbar.css skin/index.css +skin/fonts/DMSans-Regular.ttf skin/fonts/Poppins.ttf skin/fonts/Roboto.ttf skin/search_results.css @@ -42,6 +46,7 @@ templates/url_of_search_results_css.tmpl templates/viewer_settings.js templates/no_js_library_page.html templates/no_js_download.html +templates/sexy404.html opensearchdescription.xml ft_opensearchdescription.xml catalog_v2_searchdescription.xml diff --git a/static/skin/404.svg b/static/skin/404.svg new file mode 100644 index 00000000..bd9ea641 --- /dev/null +++ b/static/skin/404.svg @@ -0,0 +1 @@ + diff --git a/static/skin/blocklink.svg b/static/skin/blocklink.svg new file mode 100644 index 00000000..03e306af --- /dev/null +++ b/static/skin/blocklink.svg @@ -0,0 +1 @@ + diff --git a/static/skin/error.css b/static/skin/error.css new file mode 100644 index 00000000..efdaa732 --- /dev/null +++ b/static/skin/error.css @@ -0,0 +1,159 @@ +@font-face { + font-family:"DM Sans"; + font-style: normal; + font-weight: 400; + src : url('../skin/fonts/DMSans-Regular.ttf?KIWIXCACHEID'); +} +@font-face { + font-family:"DM Sans Bold"; + font-style: normal; + font-weight: 700; + src : url('../skin/fonts/DMSans-Regular.ttf?KIWIXCACHEID'); +} + +body { + background: linear-gradient(to bottom right, #ffffff, #e6e6e6); + background-repeat: no-repeat; + background-attachment: fixed; +} + +header { + width: 100%; + margin: auto; + text-align: center; + + margin-top: 15%; + margin-bottom: 15%; +} + +header img { + width: 60%; + min-width: 200px; + max-width: 500px; + max-height: 300px; +} + +section { + display: flex; + flex-direction: column; + align-items: center; +} + +header, .intro { + font-family: "DM Sans"; +} + +.intro { + font-size: 1em; + padding: 0 10%; + line-height: 1.2em; + text-align: center; +} + +.intro h1 { + line-height: 1.1em; + font-family: "DM Sans Bold"; + font-size: 1.2em; +} + +.intro code { + font-family: monospace; + font-size: 1.1em; + word-break: break-all; +} + +.intro a, .intro a:active, .intro a:visited { + color: #00b4e4; + text-decoration: none; + word-break: break-all; +} + +.advice { + width: 80%; + margin: auto; + margin-bottom: 15%; + margin-top: 5em; + + background-color: #ffffff; + border-radius: 1rem; + border: 1px solid #b7b7b7; + + padding: 2em; + + font-family: "DM Sans"; + font-size: .9em; + box-sizing: border-box; + + align-items: normal; +} + +.advice p { + margin-bottom: 1em; +} + +.advice p:first-child { + margin-top: 0; +} + +.advice p.list-intro { + margin: 0; +} + +.advice ul { + list-style-type: square; + margin: 0; + padding: 0 1em; +} + +.advice ul li { + line-height: 2em; +} + +.advice p:last-child { + margin-bottom: 0; +} + + +/* sm: 640px+ */ +@media (width >= 40rem) { + header { + margin-bottom: 1em; + margin-top: 5em; + } + + header img { + width: 50%; + } + + .intro h1 { + font-size: 2em; + } + + .advice { + width: 50%; + } +} + +/* xl: 1280px+ */ +@media (width >= 80rem) { + .intro h1 { + font-size: 3.4em; + } +} + +/* 2xl: 1536px+ */ +@media (width >= 96rem) { + header img { + width: 25%; + min-width: 200px; + max-width: 500px; + max-height: 300px; + } + + .advice { + width: 25%; + min-width: 200px; + min-width: 300px; + max-width: 500px; + } +} diff --git a/static/skin/fonts/DMSans-Regular.ttf b/static/skin/fonts/DMSans-Regular.ttf new file mode 100644 index 00000000..c672f980 Binary files /dev/null and b/static/skin/fonts/DMSans-Regular.ttf differ diff --git a/static/skin/i18n.js b/static/skin/i18n.js index ed92d6f3..94aee785 100644 --- a/static/skin/i18n.js +++ b/static/skin/i18n.js @@ -23,7 +23,8 @@ const Translations = { return; const errorMsg = `Error loading translations for language '${lang}': `; - this.promises[lang] = fetch(`./skin/i18n/${lang}.json`).then(async (resp) => { + const translationJsonUrl = import.meta.resolve(`./i18n/${lang}.json`); + this.promises[lang] = fetch(translationJsonUrl).then(async (resp) => { if ( resp.ok ) { this.data[lang] = JSON.parse(await resp.text()); } else { @@ -190,8 +191,40 @@ function initUILanguageSelector(activeLanguage, languageChangeCallback) { languageSelector.onchange = languageChangeCallback; } +function parseDom(html) { + const domParser = new DOMParser(); + return domParser.parseFromString(html, "text/html").documentElement; +} + +function translatePageInWindow(w) { + if ( w.KIWIX_RESPONSE_TEMPLATE && w.KIWIX_RESPONSE_DATA ) { + const template = parseDom(w.KIWIX_RESPONSE_TEMPLATE).textContent; + + // w.KIWIX_RESPONSE_DATA may belong to a different context and running + // I18n.render() on it directly won't work correctly + // because the type checks (obj.__proto__ == ???.prototype) in + // I18n.instantiateParameterizedMessages() will fail (String.prototype + // refers to different objects in different contexts). + // Work arround that issue by copying the object into our context. + const params = JSON.parse(JSON.stringify(w.KIWIX_RESPONSE_DATA)); + + const newHtml = I18n.render(template, params); + w.document.documentElement.innerHTML = parseDom(newHtml).innerHTML; + } +} + +function translateSelf() { + if ( window.KIWIX_RESPONSE_TEMPLATE && window.KIWIX_RESPONSE_DATA ) { + setUserLanguage(getUserLanguage(), () => { + translatePageInWindow(window) + }); + } +}; + window.$t = $t; window.getUserLanguage = getUserLanguage; window.setUserLanguage = setUserLanguage; window.initUILanguageSelector = initUILanguageSelector; +window.translatePageInWindow = translatePageInWindow; window.I18n = I18n; +window.addEventListener('load', translateSelf); diff --git a/static/skin/i18n/en.json b/static/skin/i18n/en.json index 09ca32d1..0212e36c 100644 --- a/static/skin/i18n/en.json +++ b/static/skin/i18n/en.json @@ -20,9 +20,24 @@ , "400-page-heading" : "Invalid request" , "404-page-title" : "Content not found" , "404-page-heading" : "Not Found" + , "new-404-page-title" : "Page not found" + , "new-404-page-heading" : "Oops. Page not found." + , "404-img-text": "Not found!" + , "path-was-not-found": "The requested path was not found:" + , "404-advice.p1": "The content you're looking for may still be available, but it might be located at a different place within the ZIM file." + , "404-advice.p2": "Please:" + , "404-advice.p3": "Try using the search function to find the content you want" + , "404-advice.p4": "Look for keywords or titles related to the information you're seeking" + , "404-advice.p5": "This approach should help you locate the desired content, even if the original link isn't working properly." , "500-page-title" : "Internal Server Error" , "500-page-heading" : "Internal Server Error" , "500-page-text": "An internal server error occured. We are sorry about that :/" + , "external-link-detected" : "External Link Detected" + , "caution-warning" : "Caution!" + , "external-link-intro" : "You are about to leave Kiwix's ZIM reader to go online to" + , "external-link-advice.p1": "The link you're trying to access is not part of your offline package and requires an internet connection." + , "external-link-advice.p2": "If you can go online, you can attempt to open the link." + , "external-link-advice.p3": "You can otherwise return to your ZIM's offline content by using your browser's back button." , "fulltext-search-unavailable" : "Fulltext search unavailable" , "no-search-results": "The fulltext search engine is not available for this content." , "search-results-page-title": "Search: {{SEARCH_PATTERN}}" diff --git a/static/skin/i18n/qqq.json b/static/skin/i18n/qqq.json index 6ddc1453..e4c480ad 100644 --- a/static/skin/i18n/qqq.json +++ b/static/skin/i18n/qqq.json @@ -24,9 +24,24 @@ "400-page-heading": "Heading of the 400 error page", "404-page-title": "Title of the 404 error page", "404-page-heading": "Heading of the 404 error page", + "new-404-page-title": "Title of the 404 error page", + "new-404-page-heading": "Heading of the 404 error page", + "404-img-text": "Fallback text for the image on the 404 error page", + "path-was-not-found": "Message telling that the URL path was not found (to be followed by the actual path)", + "404-advice.p1": "1st paragraph of the multiline advice on the 'Page not found' error page (see 404-advice.p1 through 404-advice.p5 for full text)", + "404-advice.p2": "2nd paragraph of the multiline advice on the 'Page not found' error page (see 404-advice.p1 through 404-advice.p5 for full text)", + "404-advice.p3": "3rd paragraph of the multiline advice on the 'Page not found' error page (see 404-advice.p1 through 404-advice.p5 for full text)", + "404-advice.p4": "4th paragraph of the multiline advice on the 'Page not found' error page (see 404-advice.p1 through 404-advice.p5 for full text)", + "404-advice.p5": "5th paragraph of the multiline advice on the 'Page not found' error page (see 404-advice.p1 through 404-advice.p5 for full text)", "500-page-title": "Title of the 500 error page", "500-page-heading": "Heading of the 500 error page", "500-page-text": "Text of the 500 error page", + "external-link-detected" : "Title & heading of the external link blocker page", + "caution-warning" : "Warning of action that shouldn't be carried out carelessly", + "external-link-intro" : "Message introducing the external link (to be followed by the actual link)", + "external-link-advice.p1": "1st paragraph of the multiline advice on the external link blocker page (see external-link-advice.p1 through external-link-advice.p3 for full text)", + "external-link-advice.p2": "2nd paragraph of the multiline advice on the external link blocker page (see external-link-advice.p1 through external-link-advice.p3 for full text)", + "external-link-advice.p3": "3rd paragraph of the multiline advice on the external link blocker page (see external-link-advice.p1 through external-link-advice.p3 for full text)", "fulltext-search-unavailable": "Title of the error page returned when search is attempted in a book without fulltext search database", "no-search-results": "Text of the error page returned when search is attempted in a book without fulltext search database", "search-results-page-title": "Title of the search results page", diff --git a/static/skin/i18n/test.json b/static/skin/i18n/test.json index bdde1272..98f5a9e8 100644 --- a/static/skin/i18n/test.json +++ b/static/skin/i18n/test.json @@ -13,6 +13,21 @@ , "400-page-heading": "[I18N TESTING] -400 karma for an invalid request" , "404-page-title": "[I18N TESTING] Not Found - Try Again" , "404-page-heading": "[I18N TESTING] Content not found, but at least the server is alive" + , "new-404-page-title" : "Page [I18N] not [TESTING] found" + , "new-404-page-heading" : "[I18N TESTING] Oops. Larry Page could not be reached. He may be on paternity leave." + , "404-img-text": "[I18N] Not found! [TESTING]" + , "path-was-not-found": "[I18N TESTING] The requested path was not found (in fact, nothing was found instead, either):" + , "404-advice.p1": "Sh*t happens. [I18N TESTING] Take it easy!" + , "404-advice.p2": "[I18N TESTING] Try one of the following:" + , "404-advice.p3": "[I18N TESTING] Check the spelling of the URL path" + , "404-advice.p4": "[I18N TESTING] Press the dice button" + , "404-advice.p5": "Good luck! [I18N TESTING]" + , "external-link-detected" : "External [I18] Link [TESTING] Detected" + , "caution-warning" : "[I18N] C5n! [TESTING]" + , "external-link-intro" : "[I18N TESTING] The following link may lead you to a place from which you won't ever be able to return" + , "external-link-advice.p1": "[I18N TESTING] The link you're trying to access points past the end of the ZIM file." + , "external-link-advice.p2": "[I18N TESTING] If you are an optimist, you can attempt to open the link." + , "external-link-advice.p3": "[I18N TESTING] But we strongly recommend you to be reasonable and press your browser's back button." , "library-button-text": "[I18N TESTING] Navigate to the welcome page" , "home-button-text": "[I18N TESTING] Jump to the main page of '{{BOOK_TITLE}}'" , "random-page-button-text": "[I18N TESTING] I am tired of determinism" diff --git a/static/skin/viewer.js b/static/skin/viewer.js index bbdbc74d..0b7a6787 100644 --- a/static/skin/viewer.js +++ b/static/skin/viewer.js @@ -262,22 +262,7 @@ function handle_location_hash_change() { } function translateErrorPageIfNeeded() { - const cw = contentIframe.contentWindow; - if ( cw.KIWIX_RESPONSE_TEMPLATE && cw.KIWIX_RESPONSE_DATA ) { - const template = htmlDecode(cw.KIWIX_RESPONSE_TEMPLATE); - - // cw.KIWIX_RESPONSE_DATA belongs to the iframe context and running - // I18n.render() on it directly in the top context doesn't work correctly - // because the type checks (obj.__proto__ == ???.prototype) in - // I18n.instantiateParameterizedMessages() always fail (String.prototype - // refers to different objects in different contexts). - // Work arround that issue by copying the object into our context. - const params = JSON.parse(JSON.stringify(cw.KIWIX_RESPONSE_DATA)); - - const html = I18n.render(template, params); - const htmlDoc = new DOMParser().parseFromString(html, "text/html"); - cw.document.documentElement.innerHTML = htmlDoc.documentElement.innerHTML; - } + translatePageInWindow(contentIframe.contentWindow); } function handle_content_url_change() { diff --git a/static/templates/captured_external.html b/static/templates/captured_external.html index e3dc8655..83a8a231 100644 --- a/static/templates/captured_external.html +++ b/static/templates/captured_external.html @@ -1,14 +1,32 @@ - - - - External link blocked - - -

External link blocked

-

This instance of Kiwix protects you from accidentally going to external (out-of ZIM) links.

-

If you intend to go to such locations, please click the link below.

-

Go to {{ source }}

-
Powered by Kiwix
- + + + + + {{external_link_detected}} + + + + + +
+ {{caution_warning}} +
+
+

{{external_link_detected}}

+

{{external_link_intro}}

+

{{ url }}

+
+
+

{{advice.p1}}

+

{{advice.p2}}

+

{{advice.p3}}

+
+ diff --git a/static/templates/sexy404.html b/static/templates/sexy404.html new file mode 100644 index 00000000..a2839956 --- /dev/null +++ b/static/templates/sexy404.html @@ -0,0 +1,35 @@ + + + + + + {{PAGE_TITLE}} + +{{#KIWIX_RESPONSE_DATA}} {{/KIWIX_RESPONSE_DATA}} + + +
+ {{404_img_text}} +
+
+

{{PAGE_HEADING}}

+

{{path_was_not_found_msg}}

+

{{url_path}}

+
+
+

{{advice.p1}}

+

{{advice.p2}}

+
    +
  • {{advice.p3}}
  • +
  • {{advice.p4}}
  • +
+

{{advice.p5}}

+
+ + diff --git a/test/server.cpp b/test/server.cpp index e5c85a20..d9a14481 100644 --- a/test/server.cpp +++ b/test/server.cpp @@ -58,8 +58,10 @@ const ResourceCollection resources200Compressible{ { STATIC_CONTENT, "/ROOT%23%3F/skin/autoComplete/autoComplete.min.js?cacheid=1191aaaf" }, { DYNAMIC_CONTENT, "/ROOT%23%3F/skin/autoComplete/css/autoComplete.css" }, { STATIC_CONTENT, "/ROOT%23%3F/skin/autoComplete/css/autoComplete.css?cacheid=f2d376c4" }, + { DYNAMIC_CONTENT, "/ROOT%23%3F/skin/error.css" }, + { STATIC_CONTENT, "/ROOT%23%3F/skin/error.css?cacheid=b3fa90cf" }, { DYNAMIC_CONTENT, "/ROOT%23%3F/skin/i18n.js" }, - { STATIC_CONTENT, "/ROOT%23%3F/skin/i18n.js?cacheid=071abc9a" }, + { STATIC_CONTENT, "/ROOT%23%3F/skin/i18n.js?cacheid=e9a10ac1" }, { DYNAMIC_CONTENT, "/ROOT%23%3F/skin/index.css" }, { STATIC_CONTENT, "/ROOT%23%3F/skin/index.css?cacheid=ae79e41a" }, { DYNAMIC_CONTENT, "/ROOT%23%3F/skin/index.js" }, @@ -75,7 +77,7 @@ const ResourceCollection resources200Compressible{ { DYNAMIC_CONTENT, "/ROOT%23%3F/skin/taskbar.css" }, { STATIC_CONTENT, "/ROOT%23%3F/skin/taskbar.css?cacheid=80d56607" }, { DYNAMIC_CONTENT, "/ROOT%23%3F/skin/viewer.js" }, - { STATIC_CONTENT, "/ROOT%23%3F/skin/viewer.js?cacheid=d6f747f5" }, + { STATIC_CONTENT, "/ROOT%23%3F/skin/viewer.js?cacheid=7f05bf6c" }, { DYNAMIC_CONTENT, "/ROOT%23%3F/skin/fonts/Poppins.ttf" }, { STATIC_CONTENT, "/ROOT%23%3F/skin/fonts/Poppins.ttf?cacheid=af705837" }, { DYNAMIC_CONTENT, "/ROOT%23%3F/skin/fonts/Roboto.ttf" }, @@ -92,6 +94,8 @@ const ResourceCollection resources200Compressible{ { DYNAMIC_CONTENT, "/ROOT%23%3F/catalog/v2/entries" }, { DYNAMIC_CONTENT, "/ROOT%23%3F/catalog/v2/partial_entries" }, + { DYNAMIC_CONTENT, "/ROOT%23%3F/catch/external?source=www.example.com" }, + { DYNAMIC_CONTENT, "/ROOT%23%3F/search?content=zimfile&pattern=a" }, { DYNAMIC_CONTENT, "/ROOT%23%3F/suggest?content=zimfile&term=ray" }, @@ -106,10 +110,14 @@ const ResourceCollection resources200Compressible{ }; const ResourceCollection resources200Uncompressible{ + { DYNAMIC_CONTENT, "/ROOT%23%3F/skin/404.svg" }, + { STATIC_CONTENT, "/ROOT%23%3F/skin/404.svg?cacheid=b6d648af" }, { DYNAMIC_CONTENT, "/ROOT%23%3F/skin/bittorrent.png" }, { STATIC_CONTENT, "/ROOT%23%3F/skin/bittorrent.png?cacheid=4f5c6882" }, { DYNAMIC_CONTENT, "/ROOT%23%3F/skin/blank.html" }, { STATIC_CONTENT, "/ROOT%23%3F/skin/blank.html?cacheid=6b1fa032" }, + { DYNAMIC_CONTENT, "/ROOT%23%3F/skin/blocklink.svg" }, + { STATIC_CONTENT, "/ROOT%23%3F/skin/blocklink.svg?cacheid=bd56b116" }, { DYNAMIC_CONTENT, "/ROOT%23%3F/skin/caret.png" }, { STATIC_CONTENT, "/ROOT%23%3F/skin/caret.png?cacheid=22b942b4" }, { DYNAMIC_CONTENT, "/ROOT%23%3F/skin/download.png" }, @@ -172,8 +180,6 @@ const ResourceCollection resources200Uncompressible{ { DYNAMIC_CONTENT, "/ROOT%23%3F/catalog/v2/searchdescription.xml" }, { DYNAMIC_CONTENT, "/ROOT%23%3F/catalog/v2/illustration/6f1d19d0-633f-087b-fb55-7ac324ff9baf?size=48" }, - { DYNAMIC_CONTENT, "/ROOT%23%3F/catch/external?source=www.example.com" }, - { ZIM_CONTENT, "/ROOT%23%3F/content/zimfile/I/m/Ray_Charles_classic_piano_pose.jpg" }, { ZIM_CONTENT, "/ROOT%23%3F/content/corner_cases%23%26/empty.html" }, @@ -290,7 +296,7 @@ R"EXPECTEDRESULT( href="/ROOT%23%3F/skin/kiwix.css?cacheid=3948b846" - + @@ -325,9 +331,9 @@ R"EXPECTEDRESULT( - + - + const blankPageUrl = root + "/skin/blank.html?cacheid=6b1fa032"; @@ -339,6 +345,21 @@ R"EXPECTEDRESULT( + window.KIWIX_RESPONSE_TEMPLATE = "<!DOCTYPE html>\n<html>\n <head>\n <meta charset="utf-8">\n <meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no" />\n <title>{{PAGE_TITLE}}</title>\n <link type="text/css" href="{{root}}/skin/error.css?cacheid=b3fa90cf" rel="Stylesheet" />\n{{#KIWIX_RESPONSE_DATA}} <script>\n window.KIWIX_RESPONSE_TEMPLATE = "{{KIWIX_RESPONSE_TEMPLATE}}";\n window.KIWIX_RESPONSE_DATA = {{{KIWIX_RESPONSE_DATA}}};\n </script>{{/KIWIX_RESPONSE_DATA}}\n </head>\n <body>\n <header>\n <img src="{{root}}/skin/404.svg?cacheid=b6d648af"\n alt="{{404_img_text}}"\n aria-label="{{404_img_text}}"\n title="{{404_img_text}}">\n </header>\n <section class="intro">\n <h1>{{PAGE_HEADING}}</h1>\n <p>{{path_was_not_found_msg}}</p>\n <p><code>{{url_path}}</code></p>\n </section>\n <section class="advice">\n <p>{{advice.p1}}</p>\n <p class="list-intro">{{advice.p2}}</p>\n <ul>\n <li>{{advice.p3}}</li>\n <li>{{advice.p4}}</li>\n </ul>\n <p>{{advice.p5}}</p>\n </section>\n </body>\n</html>\n"; + + + window.KIWIX_RESPONSE_TEMPLATE = "<!DOCTYPE html>\n<html>\n <head>\n <meta charset="utf-8">\n <meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no" />\n <title>{{external_link_detected}}</title>\n <link type="text/css" href="{{root}}/skin/error.css?cacheid=b3fa90cf" rel="Stylesheet" />\n <script type="module" src="{{root}}/skin/i18n.js?cacheid=e9a10ac1"></script>\n <script>\n window.KIWIX_RESPONSE_TEMPLATE = "{{KIWIX_RESPONSE_TEMPLATE}}";\n window.KIWIX_RESPONSE_DATA = {{{KIWIX_RESPONSE_DATA}}};\n </script>\n </head>\n <body>\n <header>\n <img src="{{root}}/skin/blocklink.svg?cacheid=bd56b116"\n alt="{{caution_warning}}"\n aria-label="{{caution_warning}}"\n title="{{caution_warning}}">\n </header>\n <section class="intro">\n <h1>{{external_link_detected}}</h1>\n <p>{{external_link_intro}}</p>\n <p><a href="{{url}}">{{ url }}</a></p>\n </section>\n <section class="advice">\n <p>{{advice.p1}}</p>\n <p>{{advice.p2}}</p>\n <p>{{advice.p3}}</p>\n </section>\n </body>\n</html>\n"; + )" }, - { /* url */ "/ROOT%23%3F/content/invalid-book/whatever", - expected_kiwix_response_data==R"({ "CSS_URL" : false, "PAGE_HEADING" : { "msgid" : "404-page-heading", "params" : { } }, "PAGE_TITLE" : { "msgid" : "404-page-title", "params" : { } }, "details" : [ { "p" : { "msgid" : "url-not-found", "params" : { "url" : "/ROOT%23%3F/content/invalid-book/whatever" } } }, { "p" : { "msgid" : "suggest-search", "params" : { "PATTERN" : "whatever", "SEARCH_URL" : "/ROOT%23%3F/search?pattern=whatever" } } } ] })" && - expected_body==R"( -

Not Found

-

- The requested URL "/ROOT%23%3F/content/invalid-book/whatever" was not found on this server. -

-

- Make a full text search for whatever -

-)" }, - - { /* url */ "/ROOT%23%3F/content/zimfile/invalid-article", - book_name=="zimfile" && - book_title=="Ray Charles" && - expected_kiwix_response_data==R"({ "CSS_URL" : false, "PAGE_HEADING" : { "msgid" : "404-page-heading", "params" : { } }, "PAGE_TITLE" : { "msgid" : "404-page-title", "params" : { } }, "details" : [ { "p" : { "msgid" : "url-not-found", "params" : { "url" : "/ROOT%23%3F/content/zimfile/invalid-article" } } }, { "p" : { "msgid" : "suggest-search", "params" : { "PATTERN" : "invalid-article", "SEARCH_URL" : "/ROOT%23%3F/search?content=zimfile&pattern=invalid-article" } } } ] })" && - expected_body==R"( -

Not Found

-

- The requested URL "/ROOT%23%3F/content/zimfile/invalid-article" was not found on this server. -

-

- Make a full text search for invalid-article -

-)" }, - - { /* url */ R"(/ROOT%23%3F/content/">)", - expected_kiwix_response_data==R"({ "CSS_URL" : false, "PAGE_HEADING" : { "msgid" : "404-page-heading", "params" : { } }, "PAGE_TITLE" : { "msgid" : "404-page-title", "params" : { } }, "details" : [ { "p" : { "msgid" : "url-not-found", "params" : { "url" : "/ROOT%23%3F/content/\">" } } }, { "p" : { "msgid" : "suggest-search", "params" : { "PATTERN" : "\">", "SEARCH_URL" : "/ROOT%23%3F/search?pattern=%22%3E%3Csvg%20onload%3Dalert(1)%3E" } } } ] })" && - expected_body==R"( -

Not Found

-

- The requested URL "/ROOT%23%3F/content/"><svg onload%3Dalert(1)>" was not found on this server. -

-

- Make a full text search for "><svg onload=alert(1)> -

-)" }, - - { /* url */ R"(/ROOT%23%3F/content/zimfile/">)", - book_name=="zimfile" && - book_title=="Ray Charles" && - expected_kiwix_response_data==R"({ "CSS_URL" : false, "PAGE_HEADING" : { "msgid" : "404-page-heading", "params" : { } }, "PAGE_TITLE" : { "msgid" : "404-page-title", "params" : { } }, "details" : [ { "p" : { "msgid" : "url-not-found", "params" : { "url" : "/ROOT%23%3F/content/zimfile/\">" } } }, { "p" : { "msgid" : "suggest-search", "params" : { "PATTERN" : "\">", "SEARCH_URL" : "/ROOT%23%3F/search?content=zimfile&pattern=%22%3E%3Csvg%20onload%3Dalert(1)%3E" } } } ] })" && - expected_body==R"( -

Not Found

-

- The requested URL "/ROOT%23%3F/content/zimfile/"><svg onload%3Dalert(1)>" was not found on this server. -

-

- Make a full text search for "><svg onload=alert(1)> -

-)" }, - - // XXX: This test case is against a "" string appearing inside - // XXX: javascript code that will confuse the HTML parser - { /* url */ R"(/ROOT%23%3F/content/zimfile/)", - book_name=="zimfile" && - book_title=="Ray Charles" && - expected_kiwix_response_data==R"({ "CSS_URL" : false, "PAGE_HEADING" : { "msgid" : "404-page-heading", "params" : { } }, "PAGE_TITLE" : { "msgid" : "404-page-title", "params" : { } }, "details" : [ { "p" : { "msgid" : "url-not-found", "params" : { "url" : "/ROOT%23%3F/content/zimfile/" } } }, { "p" : { "msgid" : "suggest-search", "params" : { "PATTERN" : "script>", "SEARCH_URL" : "/ROOT%23%3F/search?content=zimfile&pattern=script%3E" } } } ] })" && - expected_body==R"( -

Not Found

-

- The requested URL "/ROOT%23%3F/content/zimfile/</script>" was not found on this server. -

-

- Make a full text search for script> -

-)" }, - - { /* url */ "/ROOT%23%3F/content/zimfile/invalid-article?userlang=test", - expected_page_title=="[I18N TESTING] Not Found - Try Again" && - book_name=="zimfile" && - book_title=="Ray Charles" && - expected_kiwix_response_data==R"({ "CSS_URL" : false, "PAGE_HEADING" : { "msgid" : "404-page-heading", "params" : { } }, "PAGE_TITLE" : { "msgid" : "404-page-title", "params" : { } }, "details" : [ { "p" : { "msgid" : "url-not-found", "params" : { "url" : "/ROOT%23%3F/content/zimfile/invalid-article" } } }, { "p" : { "msgid" : "suggest-search", "params" : { "PATTERN" : "invalid-article", "SEARCH_URL" : "/ROOT%23%3F/search?content=zimfile&pattern=invalid-article" } } } ] })" && - expected_body==R"( -

[I18N TESTING] Content not found, but at least the server is alive

-

- [I18N TESTING] URL not found: /ROOT%23%3F/content/zimfile/invalid-article -

-

- [I18N TESTING] Make a full text search for invalid-article -

-)" }, - { /* url */ "/ROOT%23%3F/raw/no-such-book/meta/Title", expected_kiwix_response_data==R"({ "CSS_URL" : false, "PAGE_HEADING" : { "msgid" : "404-page-heading", "params" : { } }, "PAGE_TITLE" : { "msgid" : "404-page-title", "params" : { } }, "details" : [ { "p" : { "msgid" : "url-not-found", "params" : { "url" : "/ROOT%23%3F/raw/no-such-book/meta/Title" } } }, { "p" : { "msgid" : "no-such-book", "params" : { "BOOK_NAME" : "no-such-book" } } } ] })" && expected_body==R"( @@ -971,6 +909,131 @@ TEST_F(ServerTest, Http404HtmlError) } } +std::string htmlEscape(std::string s) +{ + s = replace(s, "&", "&"); + s = replace(s, "<", "<"); + s = replace(s, ">", ">"); + s = replace(s, "\"", """); + return s; +} + +std::string escapeJsString(std::string s) +{ + s = replace(s, "", ""); + s = replace(s, "\"", "\\\""); + return s; +} + +std::string expectedSexy404ErrorHtml(const std::string& url) +{ + const auto urlWithoutQuery = replace(url, "\\?.*$", ""); + const auto htmlSafeUrl = htmlEscape(urlWithoutQuery); + const auto jsSafeUrl = escapeJsString(urlWithoutQuery); + + const std::string englishText[] = { + "Page not found", + "Not found!", + "Oops. Page not found.", + "The requested path was not found:", + "The content you're looking for may still be available, but it might be located at a different place within the ZIM file.", + "Please:", + "Try using the search function to find the content you want", + "Look for keywords or titles related to the information you're seeking", + "This approach should help you locate the desired content, even if the original link isn't working properly." + }; + + const std::string translatedText[] = { + "Page [I18N] not [TESTING] found", + "[I18N] Not found! [TESTING]", + "[I18N TESTING] Oops. Larry Page could not be reached. He may be on paternity leave.", + "[I18N TESTING] The requested path was not found (in fact, nothing was found instead, either):", + "Sh*t happens. [I18N TESTING] Take it easy!", + "[I18N TESTING] Try one of the following:", + "[I18N TESTING] Check the spelling of the URL path", + "[I18N TESTING] Press the dice button", + "Good luck! [I18N TESTING]" + }; + + const bool shouldTranslate = url.find("userlang=test") != std::string::npos; + const std::string* const t = shouldTranslate ? translatedText : englishText; + + return R"RAWSTRINGLITERAL( + + + + + )RAWSTRINGLITERAL" + t[0] + R"RAWSTRINGLITERAL( + + + + +
+ )RAWSTRINGLITERAL +
+
+

)RAWSTRINGLITERAL" + t[2] + R"RAWSTRINGLITERAL(

+

)RAWSTRINGLITERAL" + t[3] + R"RAWSTRINGLITERAL(

+

)RAWSTRINGLITERAL" + + // inject the URL + htmlSafeUrl // inject the URL + + // inject the URL + R"RAWSTRINGLITERAL(

+
+
+

)RAWSTRINGLITERAL" + t[4] + R"RAWSTRINGLITERAL(

+

)RAWSTRINGLITERAL" + t[5] + R"RAWSTRINGLITERAL(

+
    +
  • )RAWSTRINGLITERAL" + t[6] + R"RAWSTRINGLITERAL(
  • +
  • )RAWSTRINGLITERAL" + t[7] + R"RAWSTRINGLITERAL(
  • +
+

)RAWSTRINGLITERAL" + t[8] + R"RAWSTRINGLITERAL(

+
+ + +)RAWSTRINGLITERAL"; +} + +TEST_F(ServerTest, HttpSexy404HtmlError) +{ + using namespace TestingOfHtmlResponses; + const std::vector testUrls { + // XXX: Nicer 404 error page no longer hints whether the error + // XXX: is because of the missing book/ZIM-file or a missing article + // XXX: inside a valid/existing book/ZIM-file. However it makes sense + // XXX: to preserve both cases. + "/ROOT%23%3F/content/invalid-book/whatever", + "/ROOT%23%3F/content/invalid-book/whatever?userlang=test", + "/ROOT%23%3F/content/zimfile/invalid-article", + "/ROOT%23%3F/content/zimfile/invalid-article?userlang=test", + + // malicious URLs + R"(/ROOT%23%3F/content/">)", + R"(/ROOT%23%3F/content/zimfile/">)", + + // XXX: This test case is against a "" string appearing inside + // XXX: javascript code that will confuse the HTML parser + R"(/ROOT%23%3F/content/zimfile/)", + }; + + for ( const auto& url : testUrls ) { + const TestContext ctx{ {"url", url} }; + const auto r = zfs1_->GET(url.c_str()); + EXPECT_EQ(r->status, 404) << ctx; + EXPECT_EQ(r->body, expectedSexy404ErrorHtml(url)) << ctx; + } +} + TEST_F(ServerTest, Http400HtmlError) { using namespace TestingOfHtmlResponses; @@ -1437,37 +1500,37 @@ TEST_F(ServerTest, UserLanguageControl) "Default user language is English", /*url*/ "/ROOT%23%3F/content/zimfile/invalid-article", /*Accept-Language:*/ "", - /* expected

*/ "Not Found" + /* expected

*/ "Oops. Page not found." }, { "userlang URL query parameter is respected", /*url*/ "/ROOT%23%3F/content/zimfile/invalid-article?userlang=en", /*Accept-Language:*/ "", - /* expected

*/ "Not Found" + /* expected

*/ "Oops. Page not found." }, { "userlang URL query parameter is respected", /*url*/ "/ROOT%23%3F/content/zimfile/invalid-article?userlang=test", /*Accept-Language:*/ "", - /* expected

*/ "[I18N TESTING] Content not found, but at least the server is alive" + /* expected

*/ "[I18N TESTING] Oops. Larry Page could not be reached. He may be on paternity leave." }, { "'Accept-Language: *' is handled", /*url*/ "/ROOT%23%3F/content/zimfile/invalid-article", /*Accept-Language:*/ "*", - /* expected

*/ "Not Found" + /* expected

*/ "Oops. Page not found." }, { "Accept-Language: header is respected", /*url*/ "/ROOT%23%3F/content/zimfile/invalid-article", /*Accept-Language:*/ "test", - /* expected

*/ "[I18N TESTING] Content not found, but at least the server is alive" + /* expected

*/ "[I18N TESTING] Oops. Larry Page could not be reached. He may be on paternity leave." }, { "userlang query parameter takes precedence over Accept-Language", /*url*/ "/ROOT%23%3F/content/zimfile/invalid-article?userlang=en", /*Accept-Language:*/ "test", - /* expected

*/ "Not Found" + /* expected

*/ "Oops. Page not found." }, { "Most suitable language is selected from the Accept-Language header", @@ -1475,7 +1538,7 @@ TEST_F(ServerTest, UserLanguageControl) // with quality values) the most suitable language is selected. /*url*/ "/ROOT%23%3F/content/zimfile/invalid-article", /*Accept-Language:*/ "test;q=0.9, en;q=0.2", - /* expected

*/ "[I18N TESTING] Content not found, but at least the server is alive" + /* expected

*/ "[I18N TESTING] Oops. Larry Page could not be reached. He may be on paternity leave." }, { "Most suitable language is selected from the Accept-Language header", @@ -1483,7 +1546,7 @@ TEST_F(ServerTest, UserLanguageControl) // with quality values) the most suitable language is selected. /*url*/ "/ROOT%23%3F/content/zimfile/invalid-article", /*Accept-Language:*/ "test;q=0.2, en;q=0.9", - /* expected

*/ "Not Found" + /* expected

*/ "Oops. Page not found." }, };