#include #include #include #include #include #include "font_awesome.h" #include "config.h" #include "models.h" #include "timeutils.h" #include "servehelper.h" #include "lxb_wrapper.h" #include "routes/routes.h" #include "blankie/escape.h" static inline void preprocess_html(const httplib::Request& req, const std::string& domain_name, const std::vector& emojis, lxb_dom_element_t* element); static inline void preprocess_link(const httplib::Request& req, const std::string& domain_name, lxb_dom_element_t* element); static inline bool should_fix_link(lxb_dom_element_t* element, const std::string& cls); static inline lxb_dom_node_t* emojify(lxb_dom_node_t* child, const std::vector& emojis); static inline std::vector emojify(lxb_dom_document_t* document, std::string str, const std::vector& emojis); struct PostStatus { const char* icon_html; Node info_node; }; static Element serialize_post(const httplib::Request& req, const std::string& server, const Post& post, bool main_post, const std::optional& post_status); static inline Element serialize_media(const Media& media); class CurlUrlException : public std::exception { public: CurlUrlException(CURLUcode code_) : code(code_) {} const char* what() const noexcept { return curl_url_strerror(this->code); } CURLUcode code; }; void serve(const httplib::Request& req, httplib::Response& res, std::string title, Element element, Nodes extra_head) { using namespace std::string_literals; std::string css_url = get_origin(req) + "/style.css"; res.set_header("Content-Security-Policy", "default-src 'none'; img-src https:; media-src https:; style-src "s + css_url); Element head("head", { Element("meta", {{"charset", "utf-8"}}, {}), Element("title", {std::move(title)}), Element("link", {{"rel", "stylesheet"}, {"href", std::move(css_url) + "?v=" + std::to_string(css_hash)}}, {}), Element("meta", {{"name", "viewport"}, {"content", "width=device-width,initial-scale=1"}}, {}) }); head.nodes.reserve(head.nodes.size() + extra_head.size()); head.nodes.insert(head.nodes.end(), extra_head.begin(), extra_head.end()); std::string html = ""s + Element("html", { std::move(head), std::move(element) }).serialize(); uint64_t hash = FastHash(html.data(), html.size(), 0); res.set_header("ETag", std::string(1, '"') + std::to_string(hash) + '"'); if (should_send_304(req, hash)) { res.status = 304; res.set_header("Content-Length", std::to_string(html.size())); res.set_header("Content-Type", "text/html"); } else { res.set_content(std::move(html), "text/html"); } } void serve_error(const httplib::Request& req, httplib::Response& res, std::string title, std::optional subtitle, std::optional info) { Element error_div("div", {{"class", "error"}}, { Element("h2", {title}) }); if (subtitle) { error_div.nodes.push_back(Element("p", { std::move(*subtitle) })); } if (info) { error_div.nodes.push_back(Element("pre", { Element("code", {std::move(*info)}) })); } Element body("body", {std::move(error_div)}); serve(req, res, std::move(title), std::move(body)); } void serve_redirect(const httplib::Request& req, httplib::Response& res, std::string url, bool permanent) { using namespace std::string_literals; Element body("body", { "Redirecting to ", Element("a", {{"href", url}}, {url}), "…" }); res.set_redirect(url, permanent ? 301 : 302); serve(req, res, "Redirecting to "s + std::move(url) + "…", std::move(body)); } std::string get_origin(const httplib::Request& req) { if (req.has_header("X-Canonical-Origin")) { return req.get_header_value("X-Canonical-Origin"); } if (config.canonical_origin) { return *config.canonical_origin; } std::string origin = "http://"; if (req.has_header("Host")) { origin += req.get_header_value("Host"); } else { origin += config.bind_host; if (config.bind_port != 80) { origin += ':' + std::to_string(config.bind_port); } } return origin; } std::string proxy_mastodon_url(const httplib::Request& req, const std::string& url_str) { using CurlStr = std::unique_ptr; std::unique_ptr url(curl_url(), curl_url_cleanup); if (!url) { throw std::bad_alloc(); } CURLUcode code = curl_url_set(url.get(), CURLUPART_URL, url_str.c_str(), 0); if (code) { throw CurlUrlException(code); } auto get_part = [&](CURLUPart part, CURLUcode ignore = CURLUE_OK) { char* content = nullptr; CURLUcode code = curl_url_get(url.get(), part, &content, 0); if (code && code != ignore) { throw CurlUrlException(code); } return CurlStr(content, curl_free); }; CurlStr host = get_part(CURLUPART_HOST); CurlStr path = get_part(CURLUPART_PATH); CurlStr query = get_part(CURLUPART_QUERY, CURLUE_NO_QUERY); CurlStr fragment = get_part(CURLUPART_FRAGMENT, CURLUE_NO_FRAGMENT); std::string new_url = get_origin(req) + '/' + host.get() + path.get(); if (query) { new_url += '?'; new_url += query.get(); } if (fragment) { new_url += '#'; new_url += fragment.get(); } return new_url; } bool should_send_304(const httplib::Request& req, uint64_t hash) { std::string header = req.get_header_value("If-None-Match"); if (header == "*") { return true; } size_t pos = header.find(std::string(1, '"') + std::to_string(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, bool pinned, bool main_post) { using namespace std::string_literals; if (post.reblog) { PostStatus post_status = { fa_retweet, preprocess_html(req, post.account.emojis, post.account.display_name + " boosted"), }; return serialize_post(req, server, *post.reblog, main_post, post_status); } else if (pinned) { PostStatus post_status = { fa_thumbtack, blankie::html::HTMLString("Pinned post"), }; return serialize_post(req, server, post, main_post, post_status); } else if (post.in_reply_to_id && post.in_reply_to_account_id && post.account.id == *post.in_reply_to_account_id) { PostStatus post_status = { fa_reply, preprocess_html(req, post.account.emojis, "Replied to "s + post.account.display_name), }; return serialize_post(req, server, post, main_post, post_status); } else { return serialize_post(req, server, post, main_post, std::nullopt); } } 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()); return blankie::html::HTMLString(document.serialize()); } blankie::html::HTMLString preprocess_html(const httplib::Request& req, const std::vector& emojis, const std::string& str) { return preprocess_html(req, "", emojis, blankie::html::HTMLString(blankie::html::escape(str))); } static inline void preprocess_html(const httplib::Request& req, const std::string& domain_name, const std::vector& emojis, lxb_dom_element_t* element) { const char* tag_name = reinterpret_cast(lxb_dom_element_tag_name(element, nullptr)); if (strncmp(tag_name, "A", 2) == 0) { // Proprocess links preprocess_link(req, domain_name, element); } // Walk through the element's children lxb_dom_node_t* child = lxb_dom_node_first_child(lxb_dom_interface_node(element)); while (child) { if (child->type == LXB_DOM_NODE_TYPE_ELEMENT) { preprocess_html(req, domain_name, emojis, lxb_dom_interface_element(child)); } else if (child->type == LXB_DOM_NODE_TYPE_TEXT) { child = emojify(child, emojis); } child = lxb_dom_node_next(child); } } 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; size_t href_c_len; const lxb_char_t* href_c = lxb_dom_element_get_attribute(element, reinterpret_cast("href"), 4, &href_c_len); if (!href_c) { return; } 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; // .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)); lxb_dom_element_set_attribute(element, reinterpret_cast("href"), 4, reinterpret_cast(href.data()), href.size()); } if (should_fix_link(element, cls)) { // Set the content of each to its href lxb_status_t status = lxb_dom_node_text_content_set(lxb_dom_interface_node(element), reinterpret_cast(href.data()), href.size()); if (status != LXB_STATUS_OK) { throw LXB::Exception(status); } } } static std::regex unhandled_link_re("\\bunhandled-link\\b"); static inline bool should_fix_link(lxb_dom_element_t* element, const std::string& cls) { // https://vt.social/@LucydiaLuminous/111448085044245037 if (std::regex_search(cls, unhandled_link_re)) { return true; } auto expected_element = [](lxb_dom_node_t* node, const char* expected_cls) { if (node->type != LXB_DOM_NODE_TYPE_ELEMENT) { return false; } lxb_dom_element_t* span = lxb_dom_interface_element(node); const char* tag_name = reinterpret_cast(lxb_dom_element_tag_name(span, nullptr)); if (strncmp(tag_name, "SPAN", 5) != 0) { return false; } const lxb_char_t* cls = lxb_dom_element_get_attribute(span, reinterpret_cast("class"), 5, nullptr); return cls && strcmp(reinterpret_cast(cls), expected_cls) == 0; }; lxb_dom_node_t* child = lxb_dom_node_first_child(lxb_dom_interface_node(element)); if (!expected_element(child, "invisible")) { return false; } child = lxb_dom_node_next(child); if (!expected_element(child, "ellipsis") && !expected_element(child, "")) { return false; } child = lxb_dom_node_next(child); if (!expected_element(child, "invisible")) { return false; } child = lxb_dom_node_next(child); return child == nullptr; } static inline lxb_dom_node_t* emojify(lxb_dom_node_t* child, const std::vector& emojis) { size_t text_content_len; const char* text_content = reinterpret_cast(lxb_dom_node_text_content(child, &text_content_len)); std::vector nodes = emojify(child->owner_document, std::string(text_content, text_content_len), emojis); lxb_dom_node_insert_after(child, nodes[0]); lxb_dom_node_destroy(child); child = nodes[0]; for (size_t i = 1; i < nodes.size(); i++) { lxb_dom_node_insert_after(child, nodes[i]); child = nodes[i]; } return child; } static std::regex shortcode_re(":([a-zA-Z0-9_]+):"); static inline std::vector emojify(lxb_dom_document_t* document, std::string str, const std::vector& emojis) { std::string buf; std::smatch sm; std::vector res; while (std::regex_search(str, sm, shortcode_re)) { buf += sm.prefix(); std::string group_0 = sm.str(0); auto emoji = std::find_if(emojis.begin(), emojis.end(), [&](const Emoji& i) { return i.shortcode == sm.str(1); }); if (emoji != emojis.end()) { 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_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()); lxb_dom_element_set_attribute(img, reinterpret_cast("src"), 3, reinterpret_cast(emoji->url.data()), emoji->url.size()); res.push_back(lxb_dom_interface_node(img)); } else { buf += group_0; } str = sm.suffix(); } if (!str.empty()) { buf += std::move(str); } if (!buf.empty()) { res.push_back(lxb_dom_interface_node(lxb_dom_document_create_text_node(document, reinterpret_cast(buf.data()), buf.size()))); } return res; } static Element serialize_post(const httplib::Request& req, const std::string& server, const Post& post, bool main_post, const std::optional& post_status) { 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 contents("div", {{"class", "post-contents"}}, {preprocess_html(req, server, post.emojis, post.content)}); Element post_attachments("div", {{"class", "post-attachments"}}, {}); post_attachments.nodes.reserve(post.media_attachments.size()); for (const Media& media : post.media_attachments) { post_attachments.nodes.push_back(serialize_media(media)); } contents.nodes.push_back(std::move(post_attachments)); if (post.sensitive) { std::string spoiler_text = !post.spoiler_text.empty() ? post.spoiler_text : "See more"; contents = Element("details", { Element("summary", {preprocess_html(req, post.emojis, std::move(spoiler_text))}), std::move(contents), }); } Element div("div", {{"class", "post"}}, { 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_static}}, {}), 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 + "#m"}, {"title", time_title}}, { Element("time", {{"datetime", to_rfc3339(post.created_at)}}, {relative_time(post.created_at, current_time()), time_badge}), }), }), contents, }); if (post_status) { div.nodes.insert(div.nodes.begin(), Element("p", { blankie::html::HTMLString(post_status->icon_html), " ", post_status->info_node, })); } if (main_post) { div.attributes = {{"class", "post main_post"}, {"id", "m"}}; } return div; } static inline Element serialize_media(const Media& media) { Element element = [&]() { if (media.type == "image") { return Element("a", {{"href", media.url}}, { Element("img", {{"src", media.preview_url.value_or(media.url)}}, {}), }); } else if (media.type == "video") { Element video("video", {{"controls", ""}, {"src", media.url}}, {}); if (media.preview_url) { video.attributes.push_back({"poster", *media.preview_url}); } return video; } else if (media.type == "audio") { return Element("audio", {{"controls", ""}, {"src", media.url}}, {}); } else if (media.type == "unknown" && media.remote_url) { if (media.remote_url) { // https://botsin.space/@lina@vt.social/111053598696451525 return Element("a", {{"href", *media.remote_url}}, {"Media is not available from this instance, view externally"}); } else { return Element("p", {"Media is not available from this instance"}); } } else { return Element("p", {"Unsupported media type: ", media.type}); } }(); if (media.description) { element.attributes.push_back({"alt", *media.description}); element.attributes.push_back({"title", *media.description}); } return element; }