diff --git a/CMakeLists.txt b/CMakeLists.txt index 52ab7e3..b49af94 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -28,8 +28,8 @@ list(APPEND FLAGS -Werror -Wall -Wextra -Wshadow -Wpedantic -Wno-gnu-anonymous-s add_link_options(${FLAGS}) -add_executable(${PROJECT_NAME} main.cpp numberhelper.cpp config.cpp models.cpp client.cpp servehelper.cpp - routes/css.cpp routes/user.cpp +add_executable(${PROJECT_NAME} main.cpp numberhelper.cpp config.cpp models.cpp client.cpp servehelper.cpp timeutils.cpp + routes/css.cpp routes/user.cpp routes/status.cpp blankie/serializer.cpp blankie/escape.cpp) set_target_properties(${PROJECT_NAME} PROPERTIES diff --git a/client.cpp b/client.cpp index 926956c..f423055 100644 --- a/client.cpp +++ b/client.cpp @@ -70,7 +70,29 @@ std::optional MastodonClient::get_account_by_username(const std::string try { std::string resp = this->_send_request("https://"s + host + "/api/v1/accounts/lookup?acct=" + username); - return nlohmann::json::parse(std::move(resp)); + Account res = nlohmann::json::parse(std::move(resp)); + res.same_server = host == res.server; + return res; + } catch (const CurlException& e) { + if (e.code != CURLE_HTTP_RETURNED_ERROR || this->_response_status_code() != 404) { + throw; + } + + return std::nullopt; + } +} + +std::optional MastodonClient::get_post(const std::string& host, const std::string& id) { + using namespace std::string_literals; + + try { + std::string resp = this->_send_request("https://"s + host + "/api/v1/statuses/" + id); + Post post = nlohmann::json::parse(std::move(resp)); + post.account.same_server = host == post.account.server; + if (post.reblog) { + post.reblog->account.same_server = host == post.reblog->account.server; + } + return post; } catch (const CurlException& e) { if (e.code != CURLE_HTTP_RETURNED_ERROR || this->_response_status_code() != 404) { throw; diff --git a/client.h b/client.h index 0580df5..f328679 100644 --- a/client.h +++ b/client.h @@ -7,6 +7,7 @@ #include struct Account; // forward declaration from models.h +struct Post; // forward declaration from models.h class CurlException : public std::exception { public: @@ -50,6 +51,7 @@ public: } std::optional get_account_by_username(const std::string& host, const std::string& username); + std::optional get_post(const std::string& host, const std::string& id); private: CURL* _get_easy(); diff --git a/main.cpp b/main.cpp index 195949f..70bdd75 100644 --- a/main.cpp +++ b/main.cpp @@ -45,6 +45,13 @@ int main(int argc, char** argv) { serve_redirect(req, res, "/"s + req.matches.str(1) + "/@" + req.matches.str(2) + req.matches.str(3), true); }); + server.Get("/(" DOMAIN_RE ")/@" ACCT_RE "/(\\d+)", status_route); + server.Get("/(" DOMAIN_RE ")/users/(" ACCT_RE ")/statuses/(\\d+)", [](const httplib::Request& req, httplib::Response& res) { + using namespace std::string_literals; + + serve_redirect(req, res, "/"s + req.matches.str(1) + "/@" + req.matches.str(2) + '/' + req.matches.str(3), true); + }); + #ifndef NDEBUG server.Get("/debug/exception/known", [](const httplib::Request& req, httplib::Response& res) { throw std::runtime_error("awoo"); diff --git a/models.cpp b/models.cpp index 698aa81..3707edf 100644 --- a/models.cpp +++ b/models.cpp @@ -48,7 +48,35 @@ void from_json(const json& j, Account& account) { if (!std::regex_match(url, sm, host_regex)) { throw std::runtime_error("failed to find host in url: "s + url); } - account.domain_name = sm.str(1); + account.server = sm.str(1); +} + +void from_json(const json& j, Post& post) { + j.at("id").get_to(post.id); + post.created_at = parse_rfc3339(j.at("created_at").get_ref()); + if (!j.at("in_reply_to_id").is_null()) { + post.in_reply_to_id = j["in_reply_to_id"].get(); + } + if (!j.at("in_reply_to_account_id").is_null()) { + post.in_reply_to_account_id = j["in_reply_to_account_id"].get(); + } + j.at("sensitive").get_to(post.sensitive); + j.at("spoiler_text").get_to(post.spoiler_text); + j.at("replies_count").get_to(post.replies_count); + j.at("reblogs_count").get_to(post.reblogs_count); + j.at("favourites_count").get_to(post.favorites_count); + if (!j.at("edited_at").is_null()) { + post.edited_at = parse_rfc3339(j["edited_at"].get_ref()); + } else { + post.edited_at = -1; + } + post.content = j.at("content").get(); + if (!j.at("reblog").is_null()) { + post.reblog = std::make_unique(); + from_json(j["reblog"].get(), *post.reblog.get()); + } + j.at("account").get_to(post.account); + j.at("emojis").get_to(post.emojis); } diff --git a/models.h b/models.h index a71b1eb..68861f2 100644 --- a/models.h +++ b/models.h @@ -2,6 +2,7 @@ #include #include +#include #include #include "blankie/serializer.h" @@ -25,7 +26,8 @@ struct AccountField { struct Account { std::string id; std::string username; - std::string domain_name; + std::string server; + bool same_server; std::string display_name; time_t created_at; blankie::html::HTMLString note_html; @@ -36,8 +38,35 @@ struct Account { uint64_t statuses_count; std::vector emojis; std::vector fields; + + constexpr std::string acct(bool always_show_domain = true) const { + std::string res = this->username; + if (always_show_domain || !this->same_server) { + res += '@'; + res += this->server; + } + return res; + } +}; + +struct Post { + std::string id; + time_t created_at; + std::optional in_reply_to_id; + std::optional in_reply_to_account_id; + bool sensitive; + std::string spoiler_text; + uint64_t replies_count; + uint64_t reblogs_count; + uint64_t favorites_count; + time_t edited_at; // negative is not edited + blankie::html::HTMLString content; + std::unique_ptr reblog; + Account account; + std::vector emojis; }; void from_json(const nlohmann::json& j, Emoji& emoji); void from_json(const nlohmann::json& j, AccountField& field); void from_json(const nlohmann::json& j, Account& account); +void from_json(const nlohmann::json& j, Post& post); diff --git a/routes/css.cpp b/routes/css.cpp index b230831..ffa4886 100644 --- a/routes/css.cpp +++ b/routes/css.cpp @@ -41,6 +41,7 @@ a:hover { color: var(--bright-accent-color); } +/* CUSTOM EMOJI */ .custom_emoji { height: 1em; width: 1em; @@ -48,6 +49,30 @@ a:hover { vertical-align: middle; } +/* POST */ +.post-header, .post-header a { + display: flex; +} +.post-header a { + color: inherit; + text-decoration: none; +} +.post-header a:hover { + text-decoration: underline; +} +.post-avatar { + width: 3em; + height: 3em; +} +.post-header span, .post-header time { + margin-top: auto; + margin-bottom: auto; + margin-left: 0.5em; +} +.post-header a:has(time) { + margin-left: auto; +} + /* ERROR PAGE */ .error { text-align: center; diff --git a/routes/routes.h b/routes/routes.h index d3efe3c..9503190 100644 --- a/routes/routes.h +++ b/routes/routes.h @@ -6,3 +6,4 @@ extern const uint64_t css_hash; 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); diff --git a/routes/status.cpp b/routes/status.cpp new file mode 100644 index 0000000..19087b1 --- /dev/null +++ b/routes/status.cpp @@ -0,0 +1,29 @@ +#include "routes.h" +#include "../servehelper.h" +#include "../client.h" +#include "../models.h" + +void status_route(const httplib::Request& req, httplib::Response& res) { + std::string server = req.matches.str(1); + std::string id = req.matches.str(2); + + std::optional post; + try { + post = mastodon_client.get_post(server, id); + } catch (const std::exception& e) { + res.status = 500; + serve_error(req, res, "500: Internal server error", "Failed to fetch post information", e.what()); + return; + } + + if (!post) { + res.status = 404; + serve_error(req, res, "404: Post not found"); + return; + } + + Element body("body", { + serialize_post(req, server, *post), + }); + serve(req, res, "", std::move(body)); +} diff --git a/routes/user.cpp b/routes/user.cpp index 5fa0786..8999f7e 100644 --- a/routes/user.cpp +++ b/routes/user.cpp @@ -2,14 +2,15 @@ #include "../servehelper.h" #include "../client.h" #include "../models.h" +#include "../timeutils.h" static const char* sorting_method_names[3] = {"Posts", "Posts and replies", "Media"}; static const char* sorting_method_suffixes[3] = {"", "/with_replies", "/media"}; static inline PostSortingMethod get_sorting_method(const std::string& method); -static inline Element user_header(const httplib::Request& req, const Account& account, PostSortingMethod sorting_method); +static inline Element user_header(const httplib::Request& req, const std::string& server, const Account& account, PostSortingMethod sorting_method); static inline Element user_link_field(const httplib::Request& req, const Account& account, const AccountField& field); -static inline Element sorting_method_link(const httplib::Request& req, const Account& account, PostSortingMethod current_method, PostSortingMethod new_method); +static inline Element sorting_method_link(const httplib::Request& req, const std::string& server, const Account& account, PostSortingMethod current_method, PostSortingMethod new_method); void user_route(const httplib::Request& req, httplib::Response& res) { @@ -33,9 +34,9 @@ void user_route(const httplib::Request& req, httplib::Response& res) { } Element body("body", { - user_header(req, *account, sorting_method), + user_header(req, server, *account, sorting_method), }); - serve(req, res, account->display_name + " (" + account->username + '@' + account->domain_name + ')', std::move(body)); + serve(req, res, account->display_name + " (" + account->acct() + ')', std::move(body)); } @@ -50,12 +51,7 @@ static inline PostSortingMethod get_sorting_method(const std::string& method) { } -static inline Element user_header(const httplib::Request& req, const Account& account, PostSortingMethod sorting_method) { - struct tm created_at; - char created_at_str[16]; - gmtime_r(&account.created_at, &created_at); - strftime(created_at_str, 16, "%Y-%m-%d", &created_at); - +static inline Element user_header(const httplib::Request& req, const std::string& server, const Account& account, PostSortingMethod sorting_method) { Element user_links("table", {{"class", "user_page-user_links"}}, {}); user_links.nodes.reserve(account.fields.size()); for (const AccountField& i : account.fields) { @@ -71,9 +67,9 @@ static inline Element user_header(const httplib::Request& req, const Account& ac Element("img", {{"class", "user_page-profile"}, {"alt", "User profile picture"}, {"src", account.avatar}}, {}), }), Element("span", { - Element("b", {preprocess_html(req, account.emojis, account.display_name)}), " (", account.username, "@", account.domain_name, ")", + Element("b", {preprocess_html(req, account.emojis, account.display_name)}), " (", account.acct(), ")", Element("br"), - Element("br"), Element("b", {"Joined: "}), std::string(created_at_str), + Element("br"), Element("b", {"Joined: "}), short_time(account.created_at), Element("br"), Element("b", {std::to_string(account.statuses_count)}), " Posts", " / ", Element("b", {std::to_string(account.following_count)}), " Following", " / ", @@ -82,15 +78,15 @@ static inline Element user_header(const httplib::Request& req, const Account& ac }), Element("div", {{"class", "user_page-user_description"}}, { - Element("div", {{"class", "user_page-user_bio"}}, {preprocess_html(req, account.domain_name, account.emojis, account.note_html)}), + Element("div", {{"class", "user_page-user_bio"}}, {preprocess_html(req, account.server, account.emojis, account.note_html)}), std::move(user_links), }), Element("nav", {{"class", "user_page-user_posts_nav"}}, { - sorting_method_link(req, account, sorting_method, PostSortingMethod::Posts), - sorting_method_link(req, account, sorting_method, PostSortingMethod::PostsAndReplies), - sorting_method_link(req, account, sorting_method, PostSortingMethod::Media), + sorting_method_link(req, server, account, sorting_method, PostSortingMethod::Posts), + sorting_method_link(req, server, account, sorting_method, PostSortingMethod::PostsAndReplies), + sorting_method_link(req, server, account, sorting_method, PostSortingMethod::Media), }), }); return header; @@ -101,27 +97,22 @@ static inline Element user_link_field(const httplib::Request& req, const Account Element tr("tr", { Element("th", {preprocess_html(req, account.emojis, field.name)}), - Element("td", {preprocess_html(req, account.domain_name, account.emojis, field.value_html)}), + Element("td", {preprocess_html(req, account.server, account.emojis, field.value_html)}), }); if (field.verified_at >= 0) { - struct tm verified_at; - char verified_at_str[32]; - gmtime_r(&field.verified_at, &verified_at); - strftime(verified_at_str, 32, "%Y-%m-%d %H:%M:%S %Z", &verified_at); - - tr.attributes = {{"class", "verified"}, {"title", "Verified at "s + verified_at_str}}; + tr.attributes = {{"class", "verified"}, {"title", "Verified at "s + full_time(field.verified_at)}}; } return tr; } -static inline Element sorting_method_link(const httplib::Request& req, const Account& account, PostSortingMethod current_method, PostSortingMethod new_method) { +static inline Element sorting_method_link(const httplib::Request& req, const std::string& server, const Account& account, PostSortingMethod current_method, PostSortingMethod new_method) { const char* method_name = sorting_method_names[new_method]; if (current_method == new_method) { return Element("b", {method_name}); } else { - return Element("a", {{"href", get_origin(req) + '/' + account.domain_name + "/@" + account.username + sorting_method_suffixes[new_method]}}, { + return Element("a", {{"href", get_origin(req) + '/' + server + "/@" + account.acct(false) + sorting_method_suffixes[new_method]}}, { method_name, }); } diff --git a/servehelper.cpp b/servehelper.cpp index d3e039d..fd9f8a8 100644 --- a/servehelper.cpp +++ b/servehelper.cpp @@ -6,6 +6,7 @@ #include "config.h" #include "models.h" +#include "timeutils.h" #include "servehelper.h" #include "lxb_wrapper.h" #include "routes/routes.h" @@ -163,6 +164,33 @@ bool should_send_304(const httplib::Request& req, uint64_t hash) { return pos != std::string::npos && (pos == 0 || header[pos - 1] != '/'); } +Element serialize_post(const httplib::Request& req, const std::string& server, const Post& post) { + using namespace std::string_literals; + + std::string time_title = post.edited_at < 0 + ? full_time(post.created_at) + : "Created: "s + full_time(post.created_at) + "\nEdited: " + full_time(post.edited_at); + const char* time_badge = post.edited_at < 0 ? "" : " (edited)"; + + Element div("div", { + Element("div", {{"class", "post-header"}}, { + Element("a", {{"href", get_origin(req) + '/' + server + "/@" + post.account.acct(false)}}, { + Element("img", {{"class", "post-avatar"}, {"alt", "User profile picture"}, {"src", post.account.avatar}}, {}), + Element("span", { + Element("b", {preprocess_html(req, post.account.emojis, post.account.display_name)}), + Element("br"), "@", post.account.acct(), + }), + }), + Element("a", {{"href", get_origin(req) + '/' + server + "/@" + post.account.acct(false) + '/' + post.id}, {"title", time_title}}, { + Element("time", {{"datetime", to_rfc3339(post.created_at)}}, {relative_time(post.created_at, current_time()), time_badge}), + }), + }), + + preprocess_html(req, server, post.emojis, post.content), + }); + return div; +} + blankie::html::HTMLString preprocess_html(const httplib::Request& req, const std::string& domain_name, const std::vector& emojis, const blankie::html::HTMLString& str) { LXB::HTML::Document document(str.str); preprocess_html(req, domain_name, emojis, document.body_element()); @@ -195,6 +223,7 @@ static inline void preprocess_html(const httplib::Request& req, const std::strin } } +static std::regex mention_class_re("\\bmention\\b"); static inline void preprocess_link(const httplib::Request& req, const std::string& domain_name, lxb_dom_element_t* element) { using namespace std::string_literals; @@ -205,8 +234,14 @@ static inline void preprocess_link(const httplib::Request& req, const std::strin } std::string href(reinterpret_cast(href_c), href_c_len); + size_t cls_c_len; + const lxb_char_t* cls_c = lxb_dom_element_class(element, &cls_c_len); + std::string cls = cls_c ? std::string(reinterpret_cast(cls_c), cls_c_len) : ""; + std::string instance_url_base = "https://"s + domain_name; - if (href.starts_with(instance_url_base + '/') || href == instance_url_base) { + // .mention is used in note and posts + // Instance base is used for link fields + if (std::regex_search(cls, mention_class_re) || href.starts_with(instance_url_base + '/') || href == instance_url_base) { // Proxy this instance's URLs to Coyote href = proxy_mastodon_url(req, std::move(href)); @@ -290,7 +325,7 @@ static inline std::vector emojify(lxb_dom_document_t* document, res.push_back(lxb_dom_interface_node(lxb_dom_document_create_text_node(document, reinterpret_cast(buf.data()), buf.size()))); buf.clear(); - lxb_dom_element_t* img = lxb_dom_element_create(document, reinterpret_cast("img"), 3, nullptr, 0, nullptr, 0, nullptr, 0, false); + lxb_dom_element_t* img = lxb_dom_element_create(document, reinterpret_cast("IMG"), 3, nullptr, 0, nullptr, 0, nullptr, 0, false); lxb_dom_element_set_attribute(img, reinterpret_cast("class"), 5, reinterpret_cast("custom_emoji"), 12); lxb_dom_element_set_attribute(img, reinterpret_cast("alt"), 3, reinterpret_cast(group_0.data()), group_0.size()); lxb_dom_element_set_attribute(img, reinterpret_cast("title"), 5, reinterpret_cast(group_0.data()), group_0.size()); diff --git a/servehelper.h b/servehelper.h index 8bb8fd5..5a77ce1 100644 --- a/servehelper.h +++ b/servehelper.h @@ -4,6 +4,7 @@ #include #include "blankie/serializer.h" +struct Post; // forward declaration from models.h struct Emoji; // forward declaration from models.h using Element = blankie::html::Element; @@ -19,5 +20,7 @@ std::string get_origin(const httplib::Request& req); std::string proxy_mastodon_url(const httplib::Request& req, const std::string& url_str); bool should_send_304(const httplib::Request& req, uint64_t hash); +Element serialize_post(const httplib::Request& req, const std::string& server, const Post& post); + blankie::html::HTMLString preprocess_html(const httplib::Request& req, const std::string& domain_name, const std::vector& emojis, const blankie::html::HTMLString& str); blankie::html::HTMLString preprocess_html(const httplib::Request& req, const std::vector& emojis, const std::string& str); diff --git a/timeutils.cpp b/timeutils.cpp new file mode 100644 index 0000000..6aa7e17 --- /dev/null +++ b/timeutils.cpp @@ -0,0 +1,56 @@ +#include + +#include "timeutils.h" + +time_t current_time() { + struct timespec tp; + + if (clock_gettime(CLOCK_REALTIME, &tp)) { + throw std::system_error(errno, std::generic_category(), "clock_gettime()"); + } + + return tp.tv_sec; +} + +std::string short_time(time_t time) { + struct tm tm; + gmtime_r(&time, &tm); + + char buf[16]; + size_t len = strftime(buf, 16, "%Y-%m-%d", &tm); + return std::string(buf, len); +} + +std::string full_time(time_t time) { + struct tm tm; + gmtime_r(&time, &tm); + + char buf[32]; + size_t len = strftime(buf, 32, "%Y-%m-%d %H:%M:%S GMT", &tm); + return std::string(buf, len); +} + +std::string to_rfc3339(time_t time) { + struct tm tm; + gmtime_r(&time, &tm); + + char buf[32]; + size_t len = strftime(buf, 32, "%Y-%m-%dT%H:%M:%SZ", &tm); + return std::string(buf, len); +} + +std::string relative_time(time_t from, time_t to) { + time_t diff = to - from; + + if (diff < 60) { + return std::to_string(diff) + 's'; + } else if (diff < 60 * 60) { + return std::to_string(diff / 60) + 'm'; + } else if (diff < 60 * 60 * 60) { + return std::to_string(diff / (60 * 60)) + 'h'; + } else if (diff < 24 * 60 * 60 * 60) { + return std::to_string(diff / (24 * 60 * 60)) + 'd'; + } else { + return short_time(from); + } +} diff --git a/timeutils.h b/timeutils.h new file mode 100644 index 0000000..35ae574 --- /dev/null +++ b/timeutils.h @@ -0,0 +1,11 @@ +#pragma once + +#include +#include + +time_t current_time(); + +std::string short_time(time_t time); +std::string full_time(time_t time); +std::string to_rfc3339(time_t time); +std::string relative_time(time_t from, time_t to);