diff --git a/CMakeLists.txt b/CMakeLists.txt index 544f4df..b01d81e 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -30,7 +30,7 @@ add_link_options(${FLAGS}) add_executable(${PROJECT_NAME} main.cpp numberhelper.cpp config.cpp models.cpp client.cpp servehelper.cpp timeutils.cpp - routes/home.cpp routes/css.cpp routes/user.cpp routes/status.cpp + routes/home.cpp routes/css.cpp routes/user.cpp routes/status.cpp routes/tags.cpp blankie/serializer.cpp blankie/escape.cpp) set_target_properties(${PROJECT_NAME} PROPERTIES diff --git a/client.cpp b/client.cpp index 9acddc3..f6ca938 100644 --- a/client.cpp +++ b/client.cpp @@ -8,6 +8,9 @@ MastodonClient mastodon_client; static void handle_post_server(Post& post, const std::string& host); +static std::string url_encode(const std::string& in); +static inline void hexencode(char c, char out[2]); + static void share_lock(CURL* curl, curl_lock_data data, curl_lock_access access, void* clientp); static void share_unlock(CURL* curl, curl_lock_data data, void* clientp); static size_t curl_write_cb(char* ptr, size_t size, size_t nmemb, void* userdata); @@ -70,7 +73,7 @@ std::optional MastodonClient::get_account_by_username(const std::string using namespace std::string_literals; try { - Account account = this->_send_request("https://"s + host + "/api/v1/accounts/lookup?acct=" + username); + Account account = this->_send_request("https://"s + host + "/api/v1/accounts/lookup?acct=" + url_encode(username)); account.same_server = host == account.server; return account; } catch (const MastodonException& e) { @@ -152,6 +155,22 @@ PostContext MastodonClient::get_post_context(const std::string& host, const std: return context; } +std::vector MastodonClient::get_tag_timeline(const std::string& host, const std::string& tag, std::optional max_id) { + using namespace std::string_literals; + + std::string url = "https://"s + host + "/api/v1/timelines/tag/" + url_encode(tag); + if (max_id) { + url += "?max_id="; + url += std::move(*max_id); + } + + std::vector posts = this->_send_request(url); + for (Post& post : posts) { + handle_post_server(post, host); + } + return posts; +} + CURL* MastodonClient::_get_easy() { CURL* curl = pthread_getspecific(this->_easy_key); if (!curl) { @@ -218,6 +237,42 @@ static void handle_post_server(Post& post, const std::string& host) { } } +static std::string url_encode(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; +} + +static inline void hexencode(char c, char out[2]) { + char nibble1 = (c >> 4) & 0xF; + char nibble2 = c & 0xF; + + auto hexencode = [](char nibble) { + return static_cast(nibble < 10 + ? '0' + nibble + : 'A' + nibble - 10); + }; + out[0] = hexencode(nibble1); + out[1] = hexencode(nibble2); +} + static void share_lock(CURL* curl, curl_lock_data data, curl_lock_access access, void* clientp) { (void)curl; (void)access; diff --git a/client.h b/client.h index 021d246..0e5a13d 100644 --- a/client.h +++ b/client.h @@ -71,6 +71,8 @@ public: std::optional get_post(const std::string& host, const std::string& id); PostContext get_post_context(const std::string& host, const std::string& id); + std::vector get_tag_timeline(const std::string& host, const std::string& tag, std::optional max_id); + private: CURL* _get_easy(); nlohmann::json _send_request(const std::string& url); diff --git a/main.cpp b/main.cpp index b8d3692..bbf1176 100644 --- a/main.cpp +++ b/main.cpp @@ -46,6 +46,9 @@ int main(int argc, char** argv) { serve_redirect(req, res, get_origin(req) + '/' + req.matches.str(1) + "/@" + req.matches.str(2) + '/' + req.matches.str(3), true); }); + // protect against .. + server.Get("/(" DOMAIN_RE ")/tags/(?!\\.|%2E|%2e)(.+)", tags_route); + server.Get("/https://?(.+)", [](const httplib::Request& req, httplib::Response& res) { serve_redirect(req, res, get_origin(req) + '/' + req.matches.str(1), true); }); diff --git a/routes/css.cpp b/routes/css.cpp index 9935314..c6c8c55 100644 --- a/routes/css.cpp +++ b/routes/css.cpp @@ -74,8 +74,8 @@ svg { } /* POST */ -.main_post .post-contents { - font-size: 110%; +.main_post :is(.post-contents, details) { + font-size: 1.1rem; } .post { margin-top: 1em; @@ -230,7 +230,8 @@ svg { text-decoration: underline; } -.user_page-more_posts { +/* USER PAGE and TAGS PAGE */ +.more_posts { margin-top: 1em; text-align: center; /* don't ask why, but making it a block element just works */ diff --git a/routes/home.cpp b/routes/home.cpp index 0529f05..bf7ea76 100644 --- a/routes/home.cpp +++ b/routes/home.cpp @@ -9,6 +9,7 @@ void home_route(const httplib::Request& req, httplib::Response& res) { Element("li", {Element("a", {{"href", get_origin(req) + "/vt.social/@lina"}}, {"Asahi Lina (朝日リナ) // nullptr::live"})}), Element("li", {Element("a", {{"href", get_origin(req) + "/vt.social/@LucydiaLuminous/111290028216105435"}}, {"\"I love kids and their creativity. So I was explain…\""})}), Element("li", {Element("a", {{"href", get_origin(req) + "/ruby.social/@CoralineAda/109951421922797743"}}, {"\"My partner just said \"I'm starting to think nostal…\""})}), + Element("li", {Element("a", {{"href", get_origin(req) + "/pawoo.net/tags/OMORIFANART"}}, {"#OMORIFANART"})}), }), Element("hr"), Element("p", { diff --git a/routes/routes.h b/routes/routes.h index 2f13c31..45a5a76 100644 --- a/routes/routes.h +++ b/routes/routes.h @@ -8,3 +8,4 @@ void home_route(const httplib::Request& req, httplib::Response& res); void css_route(const httplib::Request& req, httplib::Response& res); void user_route(const httplib::Request& req, httplib::Response& res); void status_route(const httplib::Request& req, httplib::Response& res); +void tags_route(const httplib::Request& req, httplib::Response& res); diff --git a/routes/tags.cpp b/routes/tags.cpp new file mode 100644 index 0000000..be90ab0 --- /dev/null +++ b/routes/tags.cpp @@ -0,0 +1,50 @@ +#include "routes.h" +#include "../servehelper.h" +#include "../client.h" +#include "../models.h" + +void tags_route(const httplib::Request& req, httplib::Response& res) { + using namespace std::string_literals; + + std::string server = req.matches.str(1); + std::string tag = req.matches.str(2); + std::optional max_id; + if (req.has_param("max_id")) { + max_id = req.get_param_value("max_id"); + if (max_id->empty() || max_id->find_first_not_of("0123456789") != std::string::npos) { + res.status = 400; + serve_error(req, res, "400: Bad Request", "Invalid max_id query paramter"); + return; + } + } + + std::vector posts; + try { + posts = mastodon_client.get_tag_timeline(server, tag, max_id); + } catch (const std::exception& e) { + res.status = 500; + serve_error(req, res, "500: Internal server error", "Failed to fetch tag timeline", e.what()); + return; + } + + Element body("body", {Element("h2", {"Posts for #", tag})}); + if (!posts.empty()) { + body.nodes.reserve(body.nodes.size() + posts.size() * 2 - 1 + 2); + + for (size_t i = 0; i < posts.size(); i++) { + if (i != 0) { + body.nodes.push_back(Element("hr")); + } + body.nodes.push_back(serialize_post(req, server, posts[i])); + } + + body.nodes.push_back(Element("hr")); + body.nodes.push_back(Element("a", {{"class", "more_posts"}, {"href", "?max_id="s + posts[posts.size() - 1].id}}, {"See more"})); + } else { + body.nodes.push_back(Element("p", {{"class", "more_posts"}}, { + max_id ? "There are no more posts" : "No results found", + })); + } + + serve(req, res, "Posts for #"s + tag, std::move(body)); +} diff --git a/routes/user.cpp b/routes/user.cpp index b944d6f..0bba65d 100644 --- a/routes/user.cpp +++ b/routes/user.cpp @@ -66,9 +66,9 @@ void user_route(const httplib::Request& req, httplib::Response& res) { } if (!posts.empty()) { - body.nodes.push_back(Element("a", {{"class", "user_page-more_posts"}, {"href", "?max_id="s + posts[posts.size() - 1].id + "#user_posts_nav"}}, {"See more"})); + body.nodes.push_back(Element("a", {{"class", "more_posts"}, {"href", "?max_id="s + posts[posts.size() - 1].id + "#user_posts_nav"}}, {"See more"})); } else if (max_id) { - body.nodes.push_back(Element("p", {{"class", "user_page-more_posts"}}, {"There are no more posts"})); + body.nodes.push_back(Element("p", {{"class", "more_posts"}}, {"There are no more posts"})); } serve(req, res, account->display_name + " (@" + account->acct() + ')', std::move(body));