Compare commits

..

4 Commits

Author SHA1 Message Date
blankie 9b21060b3a
Add CSRF protection 2023-12-08 22:27:40 +11:00
blankie 973a0eada2
Split HTML helpers into their own file 2023-12-08 22:27:40 +11:00
blankie d1711a040b
Move hex-related functions into their own file 2023-12-08 22:27:40 +11:00
blankie 9b6f6a4bcc
Relax SameSite 2023-12-08 18:12:51 +11:00
21 changed files with 795 additions and 544 deletions

View File

@ -5,10 +5,14 @@ project(coyote C CXX)
find_package(nlohmann_json REQUIRED)
find_package(CURL REQUIRED)
find_package(OpenSSL REQUIRED)
set(HTTPLIB_REQUIRE_OPENSSL ON)
add_subdirectory(thirdparty/httplib)
set(LEXBOR_BUILD_SHARED OFF)
add_subdirectory(thirdparty/lexbor)
find_package(PkgConfig REQUIRED)
pkg_check_modules(HIREDIS REQUIRED hiredis)
@ -29,7 +33,7 @@ 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 settings.cpp models.cpp client.cpp servehelper.cpp timeutils.cpp hiredis_wrapper.cpp
add_executable(${PROJECT_NAME} main.cpp numberhelper.cpp hex.cpp config.cpp settings.cpp models.cpp client.cpp servehelper.cpp htmlhelper.cpp timeutils.cpp openssl_wrapper.cpp hiredis_wrapper.cpp
routes/home.cpp routes/css.cpp routes/user.cpp routes/status.cpp routes/tags.cpp routes/about.cpp routes/user_settings.cpp
blankie/serializer.cpp blankie/escape.cpp)
set_target_properties(${PROJECT_NAME}
@ -39,6 +43,6 @@ set_target_properties(${PROJECT_NAME}
CXX_EXTENSIONS NO
)
target_include_directories(${PROJECT_NAME} PRIVATE thirdparty ${HIREDIS_INCLUDE_DIRS})
target_link_libraries(${PROJECT_NAME} PRIVATE nlohmann_json::nlohmann_json CURL::libcurl httplib::httplib lexbor_static ${HIREDIS_LINK_LIBRARIES})
target_link_libraries(${PROJECT_NAME} PRIVATE nlohmann_json::nlohmann_json CURL::libcurl OpenSSL::Crypto httplib::httplib lexbor_static ${HIREDIS_LINK_LIBRARIES})
target_compile_definitions(${PROJECT_NAME} PRIVATE ${DEFINITIONS})
target_compile_options(${PROJECT_NAME} PRIVATE ${FLAGS})

View File

@ -15,6 +15,7 @@ Copy `example_config.json` to a file with any name you like
liking. Here's a list of what they are:
- `bind_host` (string): What address to bind to
- `bind_port` (zero or positive integer): What port to bind to
- `hmac_key` (hex string): A secret key to be used; generate with `head -c32 /dev/urandom | basenc --base16`
- `canonical_origin` (string or null): A fallback canonical origin if set, useful if you're, say, running Coyote behind Ngrok
- `redis` (object)
- `enabled` (boolean)

View File

@ -2,6 +2,7 @@
#include <stdexcept>
#include <nlohmann/json.hpp>
#include "hex.h"
#include "client.h"
#include "models.h"
#include "curlu_wrapper.h"
@ -11,8 +12,6 @@ MastodonClient mastodon_client;
static void lowercase(std::string& str);
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);
@ -90,7 +89,7 @@ std::optional<Account> MastodonClient::get_account_by_username(std::string host,
url.set(CURLUPART_SCHEME, "https");
url.set(CURLUPART_HOST, host);
url.set(CURLUPART_PATH, "/api/v1/accounts/lookup");
url.set(CURLUPART_QUERY, "acct="s + url_encode(username));
url.set(CURLUPART_QUERY, "acct="s + percent_encode(username));
try {
Account account = this->_send_request("coyote:"s + host + ":@" + username, url);
account.same_server = host == account.server;
@ -111,7 +110,7 @@ std::vector<Post> MastodonClient::get_pinned_posts(std::string host, const std::
CurlUrl url;
url.set(CURLUPART_SCHEME, "https");
url.set(CURLUPART_HOST, host);
url.set(CURLUPART_PATH, "/api/v1/accounts/"s + url_encode(account_id) + "/statuses");
url.set(CURLUPART_PATH, "/api/v1/accounts/"s + percent_encode(account_id) + "/statuses");
url.set(CURLUPART_QUERY, "pinned=true");
std::vector<Post> posts = this->_send_request("coyote:"s + host + ':' + account_id + ":pinned", url);
@ -129,7 +128,7 @@ std::vector<Post> MastodonClient::get_posts(const std::string& host, const std::
CurlUrl url;
url.set(CURLUPART_SCHEME, "https");
url.set(CURLUPART_HOST, host);
url.set(CURLUPART_PATH, "/api/v1/accounts/"s + url_encode(account_id) + "/statuses");
url.set(CURLUPART_PATH, "/api/v1/accounts/"s + percent_encode(account_id) + "/statuses");
url.set(CURLUPART_QUERY, sorting_parameters[sorting_method]);
if (max_id) {
url.set(CURLUPART_QUERY, "max_id="s + std::move(*max_id), CURLU_URLENCODE | CURLU_APPENDQUERY);
@ -150,7 +149,7 @@ std::optional<Post> MastodonClient::get_post(const std::string& host, std::strin
CurlUrl url;
url.set(CURLUPART_SCHEME, "https");
url.set(CURLUPART_HOST, host);
url.set(CURLUPART_PATH, "/api/v1/statuses/"s + url_encode(std::move(id)));
url.set(CURLUPART_PATH, "/api/v1/statuses/"s + percent_encode(std::move(id)));
try {
Post post = this->_send_request(std::nullopt, url);
handle_post_server(post, host);
@ -170,7 +169,7 @@ PostContext MastodonClient::get_post_context(const std::string& host, std::strin
CurlUrl url;
url.set(CURLUPART_SCHEME, "https");
url.set(CURLUPART_HOST, host);
url.set(CURLUPART_PATH, "/api/v1/statuses/"s + url_encode(std::move(id)) + "/context");
url.set(CURLUPART_PATH, "/api/v1/statuses/"s + percent_encode(std::move(id)) + "/context");
PostContext context = this->_send_request(std::nullopt, url);
for (Post& post : context.ancestors) {
@ -189,7 +188,7 @@ std::vector<Post> MastodonClient::get_tag_timeline(const std::string& host, cons
CurlUrl url;
url.set(CURLUPART_SCHEME, "https");
url.set(CURLUPART_HOST, host);
url.set(CURLUPART_PATH, "/api/v1/timelines/tag/"s + url_encode(tag));
url.set(CURLUPART_PATH, "/api/v1/timelines/tag/"s + percent_encode(tag));
if (max_id) {
url.set(CURLUPART_QUERY, "max_id="s + std::move(*max_id), CURLU_URLENCODE | CURLU_APPENDQUERY);
}
@ -313,42 +312,6 @@ 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<char>(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;

View File

@ -1,6 +1,7 @@
#include <stdexcept>
#include <nlohmann/json.hpp>
#include "hex.h"
#include "file.h"
#include "config.h"
@ -47,6 +48,7 @@ void from_json(const nlohmann::json& j, Config& conf) {
throw std::invalid_argument("Invalid port to bind to: "s + std::to_string(conf.bind_port));
}
conf.hmac_key = hex_decode(j.at("hmac_key").get_ref<const std::string&>());
if (j.at("canonical_origin").is_string()) {
conf.canonical_origin = j["canonical_origin"].get<std::string>();
}

View File

@ -1,6 +1,7 @@
#pragma once
#include <string>
#include <vector>
#include <variant>
#include <optional>
#include <nlohmann/json_fwd.hpp>
@ -22,6 +23,7 @@ struct RedisConfig {
struct Config {
std::string bind_host = "127.0.0.1";
int bind_port = 8080;
std::vector<char> hmac_key;
std::optional<std::string> canonical_origin;
std::optional<RedisConfig> redis_config;
};

View File

@ -1,6 +1,7 @@
{
"bind_host": "127.0.0.1",
"bind_port": 8080,
"hmac_key": "AA",
"canonical_origin": null,
"redis": {
"enabled": true,

82
hex.cpp Normal file
View File

@ -0,0 +1,82 @@
#include <stdexcept>
#include "hex.h"
static void hex_encode(char c, char out[2]);
static inline char hex_decode(char nibble1, char nibble2);
std::string hex_encode(const char* in, size_t in_size) {
std::string out;
out.reserve(in_size * 2);
for (size_t i = 0; i < in_size; i++) {
char encoded[2];
hex_encode(in[i], encoded);
out.append(encoded, 2);
}
return out;
}
std::string percent_encode(std::string_view 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);
hex_encode(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::vector<char> hex_decode(std::string_view in) {
if (in.size() % 2 != 0) {
throw std::invalid_argument("hex_decode(): hex string with an odd size passed");
}
std::vector<char> out;
out.reserve(in.size() / 2);
for (size_t i = 0; i < in.size(); i += 2) {
out.push_back(hex_decode(in[i], in[i + 1]));
}
return out;
}
static void hex_encode(char c, char out[2]) {
char nibble1 = (c >> 4) & 0xF;
char nibble2 = c & 0xF;
auto hex_encode = [](char nibble) {
return static_cast<char>(nibble < 10
? '0' + nibble
: 'A' + nibble - 10);
};
out[0] = hex_encode(nibble1);
out[1] = hex_encode(nibble2);
}
static inline char hex_decode(char nibble1, char nibble2) {
auto hex_decode = [](char nibble) {
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("hex_decode(): invalid nibble");
};
return static_cast<char>((hex_decode(nibble1) << 4) | hex_decode(nibble2));
}

12
hex.h Normal file
View File

@ -0,0 +1,12 @@
#pragma once
#include <string>
#include <vector>
std::string hex_encode(const char* in, size_t in_size);
inline std::string hex_encode(const std::vector<char>& in) {
return hex_encode(in.data(), in.size());
}
std::string percent_encode(std::string_view in);
std::vector<char> hex_decode(std::string_view in);

456
htmlhelper.cpp Normal file
View File

@ -0,0 +1,456 @@
#include "models.h"
#include "settings.h"
#include "timeutils.h"
#include "curlu_wrapper.h"
#include "font_awesome.h"
#include "blankie/escape.h"
#include "htmlhelper.h"
static inline void preprocess_html(const httplib::Request& req, const std::string& domain_name, const std::vector<Emoji>& 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& element_cls);
static inline void get_text_content(lxb_dom_node_t* node, std::string& out);
static inline lxb_dom_node_t* emojify(lxb_dom_node_t* child, const std::vector<Emoji>& emojis);
static inline std::vector<lxb_dom_node*> emojify(lxb_dom_document_t* document, std::string str, const std::vector<Emoji>& 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<PostStatus>& post_status, const Post* reblogged = nullptr);
static inline Element serialize_media(const Media& media);
static inline Element serialize_poll(const httplib::Request& req, const Poll& poll);
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, &post);
} 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);
}
}
std::string get_text_content(lxb_dom_node_t* child) {
std::string out;
get_text_content(child, out);
if (!out.empty()) {
size_t remove_from = out.size();
while (remove_from && out[remove_from - 1] == '\n') {
remove_from--;
}
// Don't engulf everything, otherwise it crashes
// https://ruby.social/@CoralineAda/109951421922797743
if (out.size() > remove_from && remove_from != 0) {
out.erase(remove_from);
}
}
if (!out.empty()) {
size_t remove_to = 0;
while (out.size() > remove_to && out[remove_to] == '\n') {
remove_to++;
}
// Don't engulf everything, otherwise it crashes
// https://ruby.social/@CoralineAda/109951421922797743
if (out.size() > remove_to) {
out.erase(0, remove_to);
}
}
return out;
}
std::string get_text_content(blankie::html::HTMLString str) {
LXB::HTML::Document document(str.str);
return get_text_content(document.body());
}
blankie::html::HTMLString preprocess_html(const httplib::Request& req, const std::string& domain_name, const std::vector<Emoji>& 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<Emoji>& 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<Emoji>& emojis, lxb_dom_element_t* element) {
const char* tag_name = reinterpret_cast<const char*>(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;
// Remove target=...
lxb_status_t status = lxb_dom_element_remove_attribute(element, reinterpret_cast<const lxb_char_t*>("target"), 6);
if (status != LXB_STATUS_OK) {
throw LXB::Exception(status);
}
size_t href_c_len;
const lxb_char_t* href_c = lxb_dom_element_get_attribute(element, reinterpret_cast<const lxb_char_t*>("href"), 4, &href_c_len);
if (!href_c) {
return;
}
std::string href(reinterpret_cast<const char*>(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<const char*>(cls_c), cls_c_len) : "";
try {
CurlUrl href_url;
href_url.set(CURLUPART_URL, get_origin(req));
href_url.set(CURLUPART_PATH, std::string(href_url.get(CURLUPART_PATH).get()) + req.path);
href_url.set(CURLUPART_URL, href);
CurlUrl instance_url_base;
instance_url_base.set(CURLUPART_SCHEME, "https");
instance_url_base.set(CURLUPART_HOST, domain_name);
// .mention is used in note and posts
// Instance base is used for link fields
if (std::regex_search(cls, mention_class_re) || starts_with(href_url, 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<const lxb_char_t*>("href"), 4, reinterpret_cast<const lxb_char_t*>(href.data()), href.size());
}
} catch (const CurlUrlException& e) {
// example: <a href=""></a> on eldritch.cafe/about
if (e.code != CURLUE_MALFORMED_INPUT) {
throw;
}
}
if (should_fix_link(element, cls)) {
// Set the content of each <a> to its href
status = lxb_dom_node_text_content_set(lxb_dom_interface_node(element), reinterpret_cast<const lxb_char_t*>(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& element_cls) {
// https://vt.social/@LucydiaLuminous/111448085044245037
if (std::regex_search(element_cls, unhandled_link_re)) {
return true;
}
auto expected_element = [](lxb_dom_node_t* node, const char* expected_cls) {
if (!node || 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<const char*>(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<const lxb_char_t*>("class"), 5, nullptr);
return cls && strcmp(reinterpret_cast<const char*>(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 void get_text_content(lxb_dom_node_t* node, std::string& out) {
bool is_br = false, is_p = false;
if (node->type == LXB_DOM_NODE_TYPE_TEXT) {
size_t len;
const char* text = reinterpret_cast<const char*>(lxb_dom_node_text_content(node, &len));
out.append(text, len);
} else if (node->type == LXB_DOM_NODE_TYPE_ELEMENT) {
lxb_dom_element_t* element = lxb_dom_interface_element(node);
const char* tag_name = reinterpret_cast<const char*>(lxb_dom_element_tag_name(element, nullptr));
is_p = strncmp(tag_name, "P", 2) == 0;
is_br = strncmp(tag_name, "BR", 3) == 0;
}
if (is_p || is_br) {
out.push_back('\n');
}
lxb_dom_node_t* child = lxb_dom_node_first_child(node);
while (child) {
get_text_content(child, out);
child = lxb_dom_node_next(child);
}
if (is_p) {
out.push_back('\n');
}
}
static inline lxb_dom_node_t* emojify(lxb_dom_node_t* child, const std::vector<Emoji>& emojis) {
std::vector<lxb_dom_node_t*> nodes = emojify(child->owner_document, get_text_content(child), 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<lxb_dom_node_t*> emojify(lxb_dom_document_t* document, std::string str, const std::vector<Emoji>& emojis) {
std::string buf;
std::smatch sm;
std::vector<lxb_dom_node*> 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<const lxb_char_t*>(buf.data()), buf.size())));
buf.clear();
lxb_dom_element_t* img = lxb_dom_element_create(document, reinterpret_cast<const lxb_char_t*>("IMG"), 3, nullptr, 0, nullptr, 0, nullptr, 0, false);
lxb_dom_element_set_attribute(img, reinterpret_cast<const lxb_char_t*>("class"), 5, reinterpret_cast<const lxb_char_t*>("custom_emoji"), 12);
lxb_dom_element_set_attribute(img, reinterpret_cast<const lxb_char_t*>("alt"), 3, reinterpret_cast<const lxb_char_t*>(group_0.data()), group_0.size());
lxb_dom_element_set_attribute(img, reinterpret_cast<const lxb_char_t*>("title"), 5, reinterpret_cast<const lxb_char_t*>(group_0.data()), group_0.size());
lxb_dom_element_set_attribute(img, reinterpret_cast<const lxb_char_t*>("src"), 3, reinterpret_cast<const lxb_char_t*>(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<const lxb_char_t*>(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<PostStatus>& post_status, const Post* reblogged) {
using namespace std::string_literals;
bool user_known = !post.account.id.empty();
bool user_ref_known = !post.account.username.empty() && !post.account.server.empty();
// `reblogged == nullptr` since a malicious server could take down the frontend
// by sending a post that is not a reblog with no account information
std::string post_url = user_known || reblogged == nullptr
? get_origin(req) + '/' + server + "/@" + post.account.acct(false) + '/' + post.id + "#m"
: get_origin(req) + '/' + server + "/@" + reblogged->account.acct(false) + '/' + reblogged->id + "#m";
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)";
blankie::html::HTMLString preprocessed_html = preprocess_html(req, server, post.emojis, post.content);
// Workaround for https://vt.social/@a1ba@suya.place/110552480243348878#m
if (preprocessed_html.str.find("<p>") == std::string::npos) {
preprocessed_html.str.reserve(preprocessed_html.str.size() + 3 + 4);
preprocessed_html.str.insert(0, "<p>");
preprocessed_html.str.append("</p>");
}
Element contents("div", {{"class", "post-contents"}}, {std::move(preprocessed_html)});
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.poll) {
contents.nodes.push_back(serialize_poll(req, *post.poll));
}
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),
});
if (UserSettings(req).auto_open_cw) {
contents.attributes.push_back({"open", ""});
}
}
Element div("div", {{"class", "post"}}, {
Element("div", {{"class", "post-header"}}, {
user_ref_known ? Element("a", {{"href", get_origin(req) + '/' + server + "/@" + post.account.acct(false)}}, {
!post.account.avatar_static.empty()
? Element("img", {{"class", "post-avatar"}, {"alt", "User profile picture"}, {"loading", "lazy"}, {"src", post.account.avatar_static}}, {})
: Node(""),
Element("span", {
Element("b", {preprocess_html(req, post.account.emojis, post.account.display_name)}),
Element("br"), "@", post.account.acct(),
}),
}) : Element("b", {"Unknown user"}),
Element("a", {{"class", "post-time_header"}, {"href", std::move(post_url)}, {"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", {{"loading", "lazy"}, {"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 == "gifv") {
// https://hachyderm.io/@Impossible_PhD/111444541628207638
Element video("video", {{"controls", ""}, {"loop", ""}, {"muted", ""}, {"autoplay", ""}, {"src", media.url}}, {});
if (media.preview_url) {
video.attributes.push_back({"poster", *media.preview_url});
}
return video;
} else if (media.type == "unknown") {
if (media.remote_url) {
// https://botsin.space/@lina@vt.social/111053598696451525
return Element("a", {{"class", "unknown_media"}, {"href", *media.remote_url}}, {"Media is not available from this instance, view externally"});
} else {
return Element("p", {{"class", "unknown_media"}}, {"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;
}
static inline Element serialize_poll(const httplib::Request& req, const Poll& poll) {
using namespace std::string_literals;
uint64_t voters_count = poll.voters_count >= 0 ? static_cast<uint64_t>(poll.voters_count) : poll.votes_count;
Element div("div");
auto pick_form = [](uint64_t count, const char* singular, const char* plural) {
return count == 1 ? singular : plural;
};
div.nodes.reserve(poll.options.size() + 1);
for (const PollOption& option : poll.options) {
std::string percentage = voters_count
? std::to_string(option.votes_count * 100 / voters_count) + '%'
: "0%";
div.nodes.push_back(Element("div", {{"class", "poll-option"}, {"title", std::to_string(option.votes_count) + pick_form(option.votes_count, " vote", " votes")}}, {
Element("b", {{"class", "poll-percentage"}}, {percentage}), " ", preprocess_html(req, poll.emojis, option.title),
Element("object", {{"class", "poll-bar"}, {"width", percentage}}, {}),
}));
}
Element p("p", poll.voters_count >= 0
? std::vector<Node>({std::to_string(voters_count), " ", pick_form(voters_count, "voter", "voters")})
: std::vector<Node>({std::to_string(poll.votes_count), " ", pick_form(poll.votes_count, "vote", "votes")})
);
if (poll.expired) {
p.nodes.push_back(" / ");
p.nodes.push_back(Element("time", {{"datetime", to_rfc3339(poll.expires_at)}, {"title", "Expired on "s + full_time(poll.expires_at)}}, {"Expired"}));
} else if (poll.expires_at >= 0) {
p.nodes.push_back(" / ");
p.nodes.push_back(Element("time", {{"datetime", to_rfc3339(poll.expires_at)}, {"title", full_time(poll.expires_at)}}, {
"Expires in ", relative_time(current_time(), poll.expires_at),
}));
}
div.nodes.push_back(std::move(p));
return div;
}

14
htmlhelper.h Normal file
View File

@ -0,0 +1,14 @@
#pragma once
#include "lxb_wrapper.h"
#include "servehelper.h"
struct Post; // forward declaration from models.h
struct Emoji; // forward declaration from models.h
Element serialize_post(const httplib::Request& req, const std::string& server, const Post& post, bool pinned = false, bool main_post = false);
std::string get_text_content(lxb_dom_node_t* child);
std::string get_text_content(blankie::html::HTMLString str);
blankie::html::HTMLString preprocess_html(const httplib::Request& req, const std::string& domain_name, const std::vector<Emoji>& emojis, const blankie::html::HTMLString& str);
blankie::html::HTMLString preprocess_html(const httplib::Request& req, const std::vector<Emoji>& emojis, const std::string& str);

View File

@ -3,6 +3,7 @@
#include <string>
#include <vector>
#include <memory>
#include <optional>
#include <nlohmann/json_fwd.hpp>
#include "blankie/serializer.h"

35
openssl_wrapper.cpp Normal file
View File

@ -0,0 +1,35 @@
#include <memory>
#include <stdexcept>
#include <openssl/hmac.h>
#include <openssl/rand.h>
#include "openssl_wrapper.h"
std::vector<char> secure_random_bytes(int num) {
if (num < 0) {
throw std::invalid_argument("secure_random_bytes(): num variable out of range (num < 0)");
}
std::vector<char> bytes(static_cast<size_t>(num), 0);
if (RAND_bytes(reinterpret_cast<unsigned char*>(bytes.data()), num) == 1) {
return bytes;
} else {
throw OpenSSLException(ERR_get_error());
}
}
std::array<char, 32> hmac_sha3_256(const std::vector<char>& key, const std::vector<char>& data) {
char hmac[32];
unsigned int md_len;
std::unique_ptr<EVP_MD, decltype(&EVP_MD_free)> md(EVP_MD_fetch(nullptr, "SHA3-256", nullptr), EVP_MD_free);
if (HMAC(md.get(), key.data(), static_cast<int>(key.size()), reinterpret_cast<const unsigned char*>(data.data()), data.size(), reinterpret_cast<unsigned char*>(hmac), &md_len)) {
if (md_len != 32) {
throw std::runtime_error("hmac_sha3_256(): HMAC() returned an unexpected size");
}
return std::to_array(hmac);
} else {
throw OpenSSLException(ERR_get_error());
}
}

24
openssl_wrapper.h Normal file
View File

@ -0,0 +1,24 @@
#pragma once
#include <array>
#include <vector>
#include <exception>
#include <openssl/err.h>
class OpenSSLException : public std::exception {
public:
OpenSSLException(unsigned long e) {
ERR_error_string_n(e, this->_str, 1024);
}
const char* what() const noexcept {
return this->_str;
}
private:
char _str[1024];
};
std::vector<char> secure_random_bytes(int num);
std::array<char, 32> hmac_sha3_256(const std::vector<char>& key, const std::vector<char>& data);

View File

@ -1,5 +1,6 @@
#include "routes.h"
#include "../servehelper.h"
#include "../htmlhelper.h"
#include "../client.h"
#include "../models.h"

View File

@ -1,6 +1,7 @@
#include "routes.h"
#include "../lxb_wrapper.h"
#include "../servehelper.h"
#include "../htmlhelper.h"
#include "../client.h"
#include "../models.h"

View File

@ -1,5 +1,6 @@
#include "routes.h"
#include "../servehelper.h"
#include "../htmlhelper.h"
#include "../client.h"
#include "../models.h"

View File

@ -1,5 +1,6 @@
#include "routes.h"
#include "../servehelper.h"
#include "../htmlhelper.h"
#include "../client.h"
#include "../models.h"
#include "../timeutils.h"

View File

@ -1,23 +1,58 @@
#include "routes.h"
#include "../hex.h"
#include "../config.h"
#include "../servehelper.h"
#include "../settings.h"
#include "../timeutils.h"
#include "../curlu_wrapper.h"
#include "../openssl_wrapper.h"
static void set_cookie(const httplib::Request& req, httplib::Response& res, const char* key, std::string_view value);
static inline std::string generate_csrf_token(void);
static inline bool validate_csrf_token(const httplib::Request& req, httplib::Response& res, std::string_view csrf_token, std::string_view query_csrf_token);
static void set_cookie(const httplib::Request& req, httplib::Response& res, const char* key, std::string_view value, bool session = false);
static bool safe_memcmp(const char* s1, const char* s2, size_t n);
void user_settings_route(const httplib::Request& req, httplib::Response& res) {
UserSettings settings;
Cookies cookies = parse_cookies(req);
std::string csrf_token;
if (req.method == "POST") {
if (!cookies.contains("csrf-token")) {
res.status = 400;
serve_error(req, res, "400: Bad Request", "Missing CSRF token cookie, are cookies enabled?");
return;
}
csrf_token = cookies["csrf-token"];
auto query_csrf_token = req.params.find("csrf-token");
if (query_csrf_token == req.params.end()) {
res.status = 400;
serve_error(req, res, "400: Bad Request", "Missing CSRF token query parameter");
return;
}
if (!validate_csrf_token(req, res, csrf_token, query_csrf_token->second)) {
return;
}
for (const auto& i : req.params) {
settings.set(i.first, i.second);
}
set_cookie(req, res, "auto-open-cw", settings.auto_open_cw ? "true" : "false");
} else {
settings.load_from_cookies(req);
for (auto &[name, value] : cookies) {
settings.set(name, value);
}
if (cookies.contains("csrf-token")) {
csrf_token = cookies["csrf-token"];
} else {
csrf_token = generate_csrf_token();
set_cookie(req, res, "csrf-token", csrf_token, true);
}
}
Element auto_open_cw_checkbox("input", {{"type", "checkbox"}, {"name", "auto-open-cw"}, {"value", "true"}}, {});
@ -33,6 +68,7 @@ void user_settings_route(const httplib::Request& req, httplib::Response& res) {
}),
Element("br"),
Element("input", {{"type", "hidden"}, {"name", "csrf-token"}, {"value", csrf_token}}, {}),
Element("input", {{"type", "submit"}, {"value", "Save"}}, {}),
}),
Element("form", {{"class", "user_settings_page-form"}, {"method", "get"}, {"action", get_origin(req)}}, {
@ -49,16 +85,69 @@ void user_settings_route(const httplib::Request& req, httplib::Response& res) {
}
static void set_cookie(const httplib::Request& req, httplib::Response& res, const char* key, std::string_view value) {
static inline std::string generate_csrf_token(void) {
std::vector<char> raw_token = secure_random_bytes(32);
std::array<char, 32> raw_token_hmac = hmac_sha3_256(config.hmac_key, raw_token);
return hex_encode(raw_token) + '.' + hex_encode(raw_token_hmac.data(), raw_token_hmac.size());
}
static inline bool validate_csrf_token(const httplib::Request& req, httplib::Response& res, std::string_view csrf_token, std::string_view query_csrf_token) {
if (csrf_token.size() != query_csrf_token.size() || !safe_memcmp(csrf_token.data(), query_csrf_token.data(), csrf_token.size())) {
res.status = 400;
serve_error(req, res, "400: Bad Request", "CSRF token cookie and CSRF token query parameter do not match");
return false;
}
if (csrf_token.size() != 64 + 1 + 64 || csrf_token[64] != '.') {
res.status = 400;
serve_error(req, res, "400: Bad Request", "CSRF token is in an unknown format");
return false;
}
std::vector<char> raw_token, raw_token_hmac;
try {
raw_token = hex_decode(csrf_token.substr(0, 64));
raw_token_hmac = hex_decode(csrf_token.substr(64 + 1, 64));
} catch (const std::exception& e) {
res.status = 400;
serve_error(req, res, "400: Bad Request", "Failed to parse CSRF token", e.what());
return false;
}
std::array<char, 32> our_raw_token_hmac = hmac_sha3_256(config.hmac_key, raw_token);
if (!safe_memcmp(raw_token_hmac.data(), our_raw_token_hmac.data(), 32)) {
res.status = 400;
serve_error(req, res, "400: Bad Request", "CSRF token HMAC is not correct");
return false;
}
return true;
}
static void set_cookie(const httplib::Request& req, httplib::Response& res, const char* key, std::string_view value, bool session) {
CurlUrl origin;
origin.set(CURLUPART_URL, get_origin(req));
std::string header = std::string(key) + '=' + std::string(value)
+ "; HttpOnly; SameSite=Strict; Domain=" + origin.get(CURLUPART_HOST).get() + "; Path=" + origin.get(CURLUPART_PATH).get()
+ "; Expires=" + to_web_date(current_time() + 365 * 24 * 60 * 60);
+ "; HttpOnly; SameSite=Lax; Domain=" + origin.get(CURLUPART_HOST).get() + "; Path=" + origin.get(CURLUPART_PATH).get();
if (!session) {
header += "; Expires=";
header += to_web_date(current_time() + 365 * 24 * 60 * 60);
}
if (strcmp(origin.get(CURLUPART_SCHEME).get(), "https") == 0) {
header += "; Secure";
}
res.set_header("Set-Cookie", header);
}
static bool safe_memcmp(const char* s1, const char* s2, size_t n) {
bool equal = true;
for (size_t i = 0; i < n; i++) {
equal &= s1[i] == s2[i];
}
return equal;
}

View File

@ -4,31 +4,14 @@
#include <FastHash.h>
#include <curl/curl.h>
#include "font_awesome.h"
#include "config.h"
#include "settings.h"
#include "models.h"
#include "timeutils.h"
#include "servehelper.h"
#include "lxb_wrapper.h"
#include "curlu_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<Emoji>& 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& element_cls);
static inline void get_text_content(lxb_dom_node_t* node, std::string& out);
static inline lxb_dom_node_t* emojify(lxb_dom_node_t* child, const std::vector<Emoji>& emojis);
static inline std::vector<lxb_dom_node*> emojify(lxb_dom_document_t* document, std::string str, const std::vector<Emoji>& emojis);
static inline void parse_cookies(std::string_view str, Cookies& cookies);
static inline bool lowercase_compare(std::string_view lhs, std::string_view rhs);
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<PostStatus>& post_status, const Post* reblogged = nullptr);
static inline Element serialize_media(const Media& media);
static inline Element serialize_poll(const httplib::Request& req, const Poll& poll);
void serve(const httplib::Request& req, httplib::Response& res, std::string title, Element element, Nodes extra_head) {
@ -169,434 +152,59 @@ 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, bool pinned, bool main_post) {
Cookies parse_cookies(const httplib::Request& req) {
Cookies cookies;
for (const auto& i : req.headers) {
if (lowercase_compare(i.first, "cookie")) {
parse_cookies(i.second, cookies);
}
}
return cookies;
}
static inline void parse_cookies(std::string_view str, Cookies& cookies) {
using namespace std::string_literals;
if (post.reblog) {
PostStatus post_status = {
fa_retweet,
preprocess_html(req, post.account.emojis, post.account.display_name + " boosted"),
size_t offset = 0;
size_t new_offset = 0;
const char* delimiter = "; ";
size_t delimiter_len = strlen(delimiter);
while (offset < str.size()) {
new_offset = str.find(delimiter, offset);
std::string_view item = str.substr(offset, new_offset != std::string_view::npos ? new_offset - offset : std::string_view::npos);
size_t equal_offset = item.find('=');
if (equal_offset == std::string_view::npos) {
throw std::invalid_argument("invalid user setting item: "s + std::string(item));
}
cookies.insert({std::string(item.substr(0, equal_offset)), std::string(item.substr(equal_offset + 1))});
if (new_offset == std::string_view::npos) {
break;
}
offset = new_offset + delimiter_len;
}
}
static inline bool lowercase_compare(std::string_view lhs, std::string_view rhs) {
if (lhs.size() != rhs.size()) {
return false;
}
auto lower = [](char c) {
return c >= 'A' && c <= 'Z' ? c - 'A' + 'a' : c;
};
return serialize_post(req, server, *post.reblog, main_post, post_status, &post);
} 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);
for (size_t i = 0; i < lhs.size(); i++) {
if (lower(lhs[i]) != lower(rhs[i])) {
return false;
}
}
std::string get_text_content(lxb_dom_node_t* child) {
std::string out;
get_text_content(child, out);
if (!out.empty()) {
size_t remove_from = out.size();
while (remove_from && out[remove_from - 1] == '\n') {
remove_from--;
}
// Don't engulf everything, otherwise it crashes
// https://ruby.social/@CoralineAda/109951421922797743
if (out.size() > remove_from && remove_from != 0) {
out.erase(remove_from);
}
}
if (!out.empty()) {
size_t remove_to = 0;
while (out.size() > remove_to && out[remove_to] == '\n') {
remove_to++;
}
// Don't engulf everything, otherwise it crashes
// https://ruby.social/@CoralineAda/109951421922797743
if (out.size() > remove_to) {
out.erase(0, remove_to);
}
}
return out;
}
std::string get_text_content(blankie::html::HTMLString str) {
LXB::HTML::Document document(str.str);
return get_text_content(document.body());
}
blankie::html::HTMLString preprocess_html(const httplib::Request& req, const std::string& domain_name, const std::vector<Emoji>& 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<Emoji>& 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<Emoji>& emojis, lxb_dom_element_t* element) {
const char* tag_name = reinterpret_cast<const char*>(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;
// Remove target=...
lxb_status_t status = lxb_dom_element_remove_attribute(element, reinterpret_cast<const lxb_char_t*>("target"), 6);
if (status != LXB_STATUS_OK) {
throw LXB::Exception(status);
}
size_t href_c_len;
const lxb_char_t* href_c = lxb_dom_element_get_attribute(element, reinterpret_cast<const lxb_char_t*>("href"), 4, &href_c_len);
if (!href_c) {
return;
}
std::string href(reinterpret_cast<const char*>(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<const char*>(cls_c), cls_c_len) : "";
try {
CurlUrl href_url;
href_url.set(CURLUPART_URL, get_origin(req));
href_url.set(CURLUPART_PATH, std::string(href_url.get(CURLUPART_PATH).get()) + req.path);
href_url.set(CURLUPART_URL, href);
CurlUrl instance_url_base;
instance_url_base.set(CURLUPART_SCHEME, "https");
instance_url_base.set(CURLUPART_HOST, domain_name);
// .mention is used in note and posts
// Instance base is used for link fields
if (std::regex_search(cls, mention_class_re) || starts_with(href_url, 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<const lxb_char_t*>("href"), 4, reinterpret_cast<const lxb_char_t*>(href.data()), href.size());
}
} catch (const CurlUrlException& e) {
// example: <a href=""></a> on eldritch.cafe/about
if (e.code != CURLUE_MALFORMED_INPUT) {
throw;
}
}
if (should_fix_link(element, cls)) {
// Set the content of each <a> to its href
status = lxb_dom_node_text_content_set(lxb_dom_interface_node(element), reinterpret_cast<const lxb_char_t*>(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& element_cls) {
// https://vt.social/@LucydiaLuminous/111448085044245037
if (std::regex_search(element_cls, unhandled_link_re)) {
return true;
}
auto expected_element = [](lxb_dom_node_t* node, const char* expected_cls) {
if (!node || 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<const char*>(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<const lxb_char_t*>("class"), 5, nullptr);
return cls && strcmp(reinterpret_cast<const char*>(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 void get_text_content(lxb_dom_node_t* node, std::string& out) {
bool is_br = false, is_p = false;
if (node->type == LXB_DOM_NODE_TYPE_TEXT) {
size_t len;
const char* text = reinterpret_cast<const char*>(lxb_dom_node_text_content(node, &len));
out.append(text, len);
} else if (node->type == LXB_DOM_NODE_TYPE_ELEMENT) {
lxb_dom_element_t* element = lxb_dom_interface_element(node);
const char* tag_name = reinterpret_cast<const char*>(lxb_dom_element_tag_name(element, nullptr));
is_p = strncmp(tag_name, "P", 2) == 0;
is_br = strncmp(tag_name, "BR", 3) == 0;
}
if (is_p || is_br) {
out.push_back('\n');
}
lxb_dom_node_t* child = lxb_dom_node_first_child(node);
while (child) {
get_text_content(child, out);
child = lxb_dom_node_next(child);
}
if (is_p) {
out.push_back('\n');
}
}
static inline lxb_dom_node_t* emojify(lxb_dom_node_t* child, const std::vector<Emoji>& emojis) {
std::vector<lxb_dom_node_t*> nodes = emojify(child->owner_document, get_text_content(child), 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<lxb_dom_node_t*> emojify(lxb_dom_document_t* document, std::string str, const std::vector<Emoji>& emojis) {
std::string buf;
std::smatch sm;
std::vector<lxb_dom_node*> 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<const lxb_char_t*>(buf.data()), buf.size())));
buf.clear();
lxb_dom_element_t* img = lxb_dom_element_create(document, reinterpret_cast<const lxb_char_t*>("IMG"), 3, nullptr, 0, nullptr, 0, nullptr, 0, false);
lxb_dom_element_set_attribute(img, reinterpret_cast<const lxb_char_t*>("class"), 5, reinterpret_cast<const lxb_char_t*>("custom_emoji"), 12);
lxb_dom_element_set_attribute(img, reinterpret_cast<const lxb_char_t*>("alt"), 3, reinterpret_cast<const lxb_char_t*>(group_0.data()), group_0.size());
lxb_dom_element_set_attribute(img, reinterpret_cast<const lxb_char_t*>("title"), 5, reinterpret_cast<const lxb_char_t*>(group_0.data()), group_0.size());
lxb_dom_element_set_attribute(img, reinterpret_cast<const lxb_char_t*>("src"), 3, reinterpret_cast<const lxb_char_t*>(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<const lxb_char_t*>(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<PostStatus>& post_status, const Post* reblogged) {
using namespace std::string_literals;
bool user_known = !post.account.id.empty();
bool user_ref_known = !post.account.username.empty() && !post.account.server.empty();
// `reblogged == nullptr` since a malicious server could take down the frontend
// by sending a post that is not a reblog with no account information
std::string post_url = user_known || reblogged == nullptr
? get_origin(req) + '/' + server + "/@" + post.account.acct(false) + '/' + post.id + "#m"
: get_origin(req) + '/' + server + "/@" + reblogged->account.acct(false) + '/' + reblogged->id + "#m";
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)";
blankie::html::HTMLString preprocessed_html = preprocess_html(req, server, post.emojis, post.content);
// Workaround for https://vt.social/@a1ba@suya.place/110552480243348878#m
if (preprocessed_html.str.find("<p>") == std::string::npos) {
preprocessed_html.str.reserve(preprocessed_html.str.size() + 3 + 4);
preprocessed_html.str.insert(0, "<p>");
preprocessed_html.str.append("</p>");
}
Element contents("div", {{"class", "post-contents"}}, {std::move(preprocessed_html)});
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.poll) {
contents.nodes.push_back(serialize_poll(req, *post.poll));
}
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),
});
if (UserSettings(req).auto_open_cw) {
contents.attributes.push_back({"open", ""});
}
}
Element div("div", {{"class", "post"}}, {
Element("div", {{"class", "post-header"}}, {
user_ref_known ? Element("a", {{"href", get_origin(req) + '/' + server + "/@" + post.account.acct(false)}}, {
!post.account.avatar_static.empty()
? Element("img", {{"class", "post-avatar"}, {"alt", "User profile picture"}, {"loading", "lazy"}, {"src", post.account.avatar_static}}, {})
: Node(""),
Element("span", {
Element("b", {preprocess_html(req, post.account.emojis, post.account.display_name)}),
Element("br"), "@", post.account.acct(),
}),
}) : Element("b", {"Unknown user"}),
Element("a", {{"class", "post-time_header"}, {"href", std::move(post_url)}, {"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", {{"loading", "lazy"}, {"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 == "gifv") {
// https://hachyderm.io/@Impossible_PhD/111444541628207638
Element video("video", {{"controls", ""}, {"loop", ""}, {"muted", ""}, {"autoplay", ""}, {"src", media.url}}, {});
if (media.preview_url) {
video.attributes.push_back({"poster", *media.preview_url});
}
return video;
} else if (media.type == "unknown") {
if (media.remote_url) {
// https://botsin.space/@lina@vt.social/111053598696451525
return Element("a", {{"class", "unknown_media"}, {"href", *media.remote_url}}, {"Media is not available from this instance, view externally"});
} else {
return Element("p", {{"class", "unknown_media"}}, {"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;
}
static inline Element serialize_poll(const httplib::Request& req, const Poll& poll) {
using namespace std::string_literals;
uint64_t voters_count = poll.voters_count >= 0 ? static_cast<uint64_t>(poll.voters_count) : poll.votes_count;
Element div("div");
auto pick_form = [](uint64_t count, const char* singular, const char* plural) {
return count == 1 ? singular : plural;
};
div.nodes.reserve(poll.options.size() + 1);
for (const PollOption& option : poll.options) {
std::string percentage = voters_count
? std::to_string(option.votes_count * 100 / voters_count) + '%'
: "0%";
div.nodes.push_back(Element("div", {{"class", "poll-option"}, {"title", std::to_string(option.votes_count) + pick_form(option.votes_count, " vote", " votes")}}, {
Element("b", {{"class", "poll-percentage"}}, {percentage}), " ", preprocess_html(req, poll.emojis, option.title),
Element("object", {{"class", "poll-bar"}, {"width", percentage}}, {}),
}));
}
Element p("p", poll.voters_count >= 0
? std::vector<Node>({std::to_string(voters_count), " ", pick_form(voters_count, "voter", "voters")})
: std::vector<Node>({std::to_string(poll.votes_count), " ", pick_form(poll.votes_count, "vote", "votes")})
);
if (poll.expired) {
p.nodes.push_back(" / ");
p.nodes.push_back(Element("time", {{"datetime", to_rfc3339(poll.expires_at)}, {"title", "Expired on "s + full_time(poll.expires_at)}}, {"Expired"}));
} else if (poll.expires_at >= 0) {
p.nodes.push_back(" / ");
p.nodes.push_back(Element("time", {{"datetime", to_rfc3339(poll.expires_at)}, {"title", full_time(poll.expires_at)}}, {
"Expires in ", relative_time(current_time(), poll.expires_at),
}));
}
div.nodes.push_back(std::move(p));
return div;
}

View File

@ -1,17 +1,16 @@
#pragma once
#include <optional>
#include <unordered_map>
#include <httplib/httplib.h>
#include "blankie/serializer.h"
#include "lxb_wrapper.h"
struct Post; // forward declaration from models.h
struct Emoji; // forward declaration from models.h
class CurlUrl; // forward declaration from curlu_wrapper.h
using Element = blankie::html::Element;
using Node = blankie::html::Node;
using Nodes = std::vector<Node>;
using Cookies = std::unordered_map<std::string, std::string>;
void serve(const httplib::Request& req, httplib::Response& res, std::string title, Element element, Nodes extra_head = {});
void serve_error(const httplib::Request& req, httplib::Response& res,
@ -23,9 +22,4 @@ 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, bool pinned = false, bool main_post = false);
std::string get_text_content(lxb_dom_node_t* child);
std::string get_text_content(blankie::html::HTMLString str);
blankie::html::HTMLString preprocess_html(const httplib::Request& req, const std::string& domain_name, const std::vector<Emoji>& emojis, const blankie::html::HTMLString& str);
blankie::html::HTMLString preprocess_html(const httplib::Request& req, const std::vector<Emoji>& emojis, const std::string& str);
Cookies parse_cookies(const httplib::Request& req);

View File

@ -1,9 +1,8 @@
#include <string>
#include <stdexcept>
#include "settings.h"
#include "servehelper.h"
static void set_settings(std::string_view str, const char* delimiter, UserSettings& settings);
static inline bool lowercase_compare(std::string_view lhs, std::string_view rhs);
static bool parse_bool(std::string_view value);
@ -14,54 +13,14 @@ void UserSettings::set(std::string_view key, std::string_view value) {
}
void UserSettings::load_from_cookies(const httplib::Request& req) {
for (const auto& i : req.headers) {
if (lowercase_compare(i.first, "cookie")) {
set_settings(i.second, "; ", *this);
}
Cookies cookies = parse_cookies(req);
for (auto &[name, value] : cookies) {
this->set(name, value);
}
}
static void set_settings(std::string_view str, const char* delimiter, UserSettings& settings) {
using namespace std::string_literals;
size_t offset = 0;
size_t new_offset = 0;
size_t delimiter_len = strlen(delimiter);
while (offset < str.size()) {
new_offset = str.find(delimiter, offset);
std::string_view item = str.substr(offset, new_offset != std::string_view::npos ? new_offset - offset : std::string_view::npos);
size_t equal_offset = item.find('=');
if (equal_offset == std::string_view::npos) {
throw std::invalid_argument("invalid user setting item: "s + std::string(item));
}
settings.set(item.substr(0, equal_offset), item.substr(equal_offset + 1));
if (new_offset == std::string_view::npos) {
break;
}
offset = new_offset + delimiter_len;
}
}
static inline bool lowercase_compare(std::string_view lhs, std::string_view rhs) {
if (lhs.size() != rhs.size()) {
return false;
}
auto lower = [](char c) {
return c >= 'A' && c <= 'Z' ? c - 'A' + 'a' : c;
};
for (size_t i = 0; i < lhs.size(); i++) {
if (lower(lhs[i]) != lower(rhs[i])) {
return false;
}
}
return true;
}
static bool parse_bool(std::string_view value) {
using namespace std::string_literals;