diff --git a/CMakeLists.txt b/CMakeLists.txt index 6b1c675..3f97f18 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -25,7 +25,7 @@ add_link_options(${FLAGS}) add_executable(${PROJECT_NAME} main.cpp misc.cpp config.cpp servehelper.cpp numberhelper.cpp pixivclient.cpp pixivmodels.cpp blankie/serializer.cpp blankie/escape.cpp blankie/murl.cpp - routes/home.cpp routes/css.cpp routes/artworks.cpp routes/users/common.cpp routes/users/illustrations.cpp) + routes/home.cpp routes/css.cpp routes/artworks.cpp routes/tags.cpp routes/users/common.cpp routes/users/illustrations.cpp) set_target_properties(${PROJECT_NAME} PROPERTIES CXX_STANDARD 20 diff --git a/blankie/murl.cpp b/blankie/murl.cpp index 0c93405..2dddfc0 100644 --- a/blankie/murl.cpp +++ b/blankie/murl.cpp @@ -38,6 +38,8 @@ "(?:\\?" QUERY ")?" \ "(?:#" FRAGMENT ")?" +static inline void hexencode(char c, char out[2]); +static inline char hexdecode(char nibble1, char nibble2); static void handle_segment(std::vector& segments, const std::string& str, size_t offset, size_t length); static std::string tolower(std::string str); @@ -93,6 +95,52 @@ bool Url::is_host_equal(std::string other) const { return tolower(this->hostname) == tolower(std::move(other)); } +std::string escape(const std::string& in) { + std::string out; + char encoded[2]; + size_t pos = 0; + size_t last_pos = 0; + + out.reserve(in.size()); + while ((pos = in.find_first_not_of("abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789", pos)) != std::string::npos) { + out.append(in, last_pos, pos - last_pos); + hexencode(in[pos], encoded); + out += '%'; + out.append(encoded, 2); + pos++; + last_pos = pos; + } + + if (in.size() > last_pos) { + out.append(in, last_pos); + } + + return out; +} + +std::string unescape(const std::string& in) { + std::string out; + size_t pos = 0; + size_t last_pos = 0; + + out.reserve(in.size()); + while ((pos = in.find('%', pos)) != std::string::npos) { + if (pos + 2 >= in.size()) { + throw std::invalid_argument("String abruptly terminated while finding percent-encoded nibbles"); + } + out.append(in, last_pos, pos - last_pos); + out += hexdecode(in[pos + 1], in[pos + 2]); + pos += 3; + last_pos = pos; + } + + if (in.size() > last_pos) { + out.append(in, last_pos); + } + + return out; +} + std::string normalize_path(const std::string& str) { std::vector segments; std::string res; @@ -137,6 +185,30 @@ std::string normalize_path(const std::string& str) { } // namespace murl } // namespace blankie +static inline void hexencode(char c, char out[2]) { + char nibble1 = (c >> 4) & 0xF; + char nibble2 = c & 0xF; + + auto hexencode = [](char nibble) -> char { + return nibble < 10 + ? '0' + nibble + : 'A' + nibble - 10; + }; + out[0] = hexencode(nibble1); + out[1] = hexencode(nibble2); +} + +static inline char hexdecode(char nibble1, char nibble2) { + auto hexdecode = [](char nibble) -> char { + if (nibble >= '0' && nibble <= '9') return nibble - '0'; + if (nibble >= 'A' && nibble <= 'F') return nibble - 'A' + 10; + if (nibble >= 'a' && nibble <= 'f') return nibble - 'a' + 10; + throw std::invalid_argument("Invalid percent-encoded nibble received"); + }; + + return static_cast((hexdecode(nibble1) << 4) | hexdecode(nibble2)); +} + static void handle_segment(std::vector& segments, const std::string& str, size_t offset, size_t length) { if (length == 2 && str[offset] == '.' && str[offset + 1] == '.') { if (segments.empty()) { diff --git a/blankie/murl.h b/blankie/murl.h index 4d819c7..b675c6f 100644 --- a/blankie/murl.h +++ b/blankie/murl.h @@ -38,6 +38,8 @@ struct Url { std::string to_string() const; }; +std::string escape(const std::string& in); +std::string unescape(const std::string& in); std::string normalize_path(const std::string& str); } // namespace murl diff --git a/main.cpp b/main.cpp index 89d168d..d5e745d 100644 --- a/main.cpp +++ b/main.cpp @@ -37,6 +37,12 @@ int main(int argc, char** argv) { server.Get("/artworks/(\\d+)", [&](const httplib::Request& req, httplib::Response& res) { artworks_route(req, res, config, pixiv_client); }); + server.Get("/tags/([^/]+)", [&](const httplib::Request& req, httplib::Response& res) { + serve_redirect(req, res, config, get_origin(req, config) + "/tags/" + req.matches.str(1) + "/illustrations"); + }); + server.Get("/tags/([^/]+)/illustrations", [&](const httplib::Request& req, httplib::Response& res) { + tags_route(req, res, config, pixiv_client); + }); server.Get("/member\\.php", [&](const httplib::Request& req, httplib::Response& res) { std::string id = req.get_param_value("id"); diff --git a/routes/css.cpp b/routes/css.cpp index 3189e88..2bd4b41 100644 --- a/routes/css.cpp +++ b/routes/css.cpp @@ -37,6 +37,11 @@ void css_route(const httplib::Request& req, httplib::Response& res) { max-width: 100%; } + .center { + text-align: center; + display: block; + } + /* USER PAGE (and a tiny bit for illustrations page) */ .cover { width: 100%; @@ -61,10 +66,6 @@ void css_route(const httplib::Request& req, httplib::Response& res) { } /* USER ILLUSTRATIONS PAGE (and illustrations page) */ - .center { - text-align: center; - display: block; - } .grid { display: flex; flex-wrap: wrap; diff --git a/routes/routes.h b/routes/routes.h index 69532de..d10a45e 100644 --- a/routes/routes.h +++ b/routes/routes.h @@ -9,3 +9,4 @@ void home_route(const httplib::Request& req, httplib::Response& res, const Confi void css_route(const httplib::Request& req, httplib::Response& res); void user_illustrations_route(const httplib::Request& req, httplib::Response& res, const Config& config, PixivClient& pixiv_client); void artworks_route(const httplib::Request& req, httplib::Response& res, const Config& config, PixivClient& pixiv_client); +void tags_route(const httplib::Request& req, httplib::Response& res, const Config& config, PixivClient& pixiv_client); diff --git a/routes/tags.cpp b/routes/tags.cpp new file mode 100644 index 0000000..10df63e --- /dev/null +++ b/routes/tags.cpp @@ -0,0 +1,114 @@ +#include "routes.h" +#include "../numberhelper.h" +#include "../servehelper.h" +#include "../pixivclient.h" + +static inline Element generate_header(const httplib::Request& req, const Config& config, + const SearchResults& search_results, const std::string& query, const std::vector& tags, const std::string& order); + +static std::string tags_to_string(const std::unordered_map& tag_translations, const std::vector& tags); +static inline std::vector split(const std::string& str, char c); + +void tags_route(const httplib::Request& req, httplib::Response& res, const Config& config, PixivClient& pixiv_client) { + std::string query = blankie::murl::unescape(req.matches.str(1)); + std::string order = req.has_param("order") ? req.get_param_value("order") : "date_d"; + unsigned long long page = req.has_param("p") ? to_ull(req.get_param_value("p")) - 1 : 0; + std::vector tags = split(query, ' '); + + if (tags.empty()) { + res.status = 400; + serve_error(req, res, config, "400: Bad Request", "Empty search query"); + return; + } + + SearchResults search_results; + try { + search_results = pixiv_client.search_illusts(query, page, order); + } catch (const std::exception& e) { + res.status = 500; + serve_error(req, res, config, "500: Internal server error", "Failed to search for illusts", e.what()); + return; + } + + Element body("body", { + generate_header(req, config, search_results, query, tags, order), + Element("br"), + generate_illusts_pager(req, config, search_results.illusts, page, "illusts") + }); + serve(req, res, config, tags_to_string(std::move(search_results.tag_translations), std::move(tags)), std::move(body)); +} + + + +static inline Element generate_header(const httplib::Request& req, const Config& config, + const SearchResults& search_results, const std::string& query, const std::vector& tags, const std::string& order) { + auto sort_element = [&](const char* title, const char* new_order) { + std::string url = get_origin(req, config) + "/tags/" + blankie::murl::escape(query) + "/illustrations?order=" + new_order; + Element ret("a", {{"href", std::move(url)}}, {title}); + if (new_order == order) { + ret = Element("b", {std::move(ret)}); + } + return ret; + }; + + Element header("header", {{"class", "center"}}, { + Element("form", {{"method", "get"}, {"action", get_origin(req, config) + "/search"}}, { + Element("input", {{"name", "q"}, {"required", ""}, {"value", query}}, {}), + " ", + Element("button", {"Search for illustrations"}) + }), + Element("br") + }); + if (search_results.illusts.total_illusts != 1) { + header.nodes.push_back("There are "); + header.nodes.push_back(std::to_string(search_results.illusts.total_illusts)); + header.nodes.push_back(" illustrations"); + } else { + header.nodes.push_back("There is 1 illustration"); + } + header.nodes.push_back(" of "); + header.nodes.push_back(Element("b", {tags_to_string(search_results.tag_translations, tags)})); + + header.nodes.push_back(Element("br")); + header.nodes.push_back("Sort by: "); + header.nodes.push_back(sort_element("Newest", "date_d")); + header.nodes.push_back(" "); + header.nodes.push_back(sort_element("Oldest", "date")); + + return header; +} + + + +static std::string tags_to_string(const std::unordered_map& tag_translations, const std::vector& tags) { + std::string str; + for (const std::string& tag : tags) { + if (!str.empty()) { + str += ' '; + } + str += '#'; + + auto translated_tag = tag_translations.find(tag); + str += translated_tag != tag_translations.cend() ? translated_tag->second : tag; + } + return str; +} + +static inline std::vector split(const std::string& str, char c) { + std::vector ret; + size_t pos = 0; + size_t last_pos = 0; + + while ((pos = str.find(c, pos)) != std::string::npos) { + if (pos - last_pos > 0) { + ret.push_back(str.substr(last_pos, pos - last_pos)); + } + pos++; + last_pos = pos; + } + if (str.size() > last_pos) { + ret.push_back(str.substr(last_pos)); + } + + return ret; +}