#include #include #include #include "client.h" #include "models.h" #include "hiredis_wrapper.h" 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); static size_t curl_write_cb(char* ptr, size_t size, size_t nmemb, void* userdata); template void setopt(CURLSH* share, CURLSHoption option, A parameter) { CURLSHcode code = curl_share_setopt(share, option, parameter); if (code) { throw CurlShareException(code); } } template void setopt(CURL* curl, CURLoption option, A parameter) { CURLcode code = curl_easy_setopt(curl, option, parameter); if (code) { throw CurlException(code); } } MastodonClient::MastodonClient() { this->_share = curl_share_init(); if (!this->_share) { throw std::bad_alloc(); } try { setopt(this->_share, CURLSHOPT_SHARE, CURL_LOCK_DATA_DNS); setopt(this->_share, CURLSHOPT_SHARE, CURL_LOCK_DATA_SSL_SESSION); #if CURL_AT_LEAST_VERSION(7, 57, 0) setopt(this->_share, CURLSHOPT_SHARE, CURL_LOCK_DATA_CONNECT); #endif #if CURL_AT_LEAST_VERSION(7, 61, 0) setopt(this->_share, CURLSHOPT_SHARE, CURL_LOCK_DATA_PSL); #endif #if CURL_AT_LEAST_VERSION(7, 88, 0) setopt(this->_share, CURLSHOPT_SHARE, CURL_LOCK_DATA_HSTS); #endif setopt(this->_share, CURLSHOPT_LOCKFUNC, share_lock); setopt(this->_share, CURLSHOPT_UNLOCKFUNC, share_unlock); setopt(this->_share, CURLSHOPT_USERDATA, this->_share_locks); } catch (const std::exception& e) { curl_share_cleanup(this->_share); throw; } int err = pthread_key_create(&this->_easy_key, curl_easy_cleanup); if (err) { curl_share_cleanup(this->_share); throw std::system_error(err, std::generic_category(), "pthread_key_create()"); } } MastodonClient::~MastodonClient() { curl_share_cleanup(this->_share); int err = pthread_key_delete(this->_easy_key); if (err) { perror("pthread_key_delete()"); } } std::optional MastodonClient::get_account_by_username(std::string host, std::string username) { using namespace std::string_literals; lowercase(host); lowercase(username); if (username.size() > host.size() && username.ends_with(host) && username[username.size() - host.size() - 1] == '@') { username.erase(username.size() - host.size() - 1); } try { Account account = this->_send_request("coyote:"s + host + ":@" + username, "https://"s + host + "/api/v1/accounts/lookup?acct=" + url_encode(username)); account.same_server = host == account.server; return account; } catch (const MastodonException& e) { if (e.response_code != 404) { throw; } return std::nullopt; } } std::vector MastodonClient::get_pinned_posts(std::string host, const std::string& account_id) { using namespace std::string_literals; lowercase(host); std::vector posts = this->_send_request("coyote:"s + host + ':' + account_id + ":pinned", "https://"s + host + "/api/v1/accounts/" + account_id + "/statuses?pinned=true"); for (Post& post : posts) { handle_post_server(post, host); } return posts; } std::vector MastodonClient::get_posts(const std::string& host, const std::string& account_id, PostSortingMethod sorting_method, std::optional max_id) { using namespace std::string_literals; const char* sorting_parameters[3] = {"exclude_replies=true", "", "only_media=true"}; std::string query = sorting_parameters[sorting_method]; if (max_id) { if (!query.empty()) { query += '&'; } query += "max_id="; query += std::move(*max_id); } std::string url = "https://"s + host + "/api/v1/accounts/" + account_id + "/statuses"; if (!query.empty()) { url += '?'; url += query; } std::vector posts = this->_send_request(std::nullopt, url); for (Post& post : posts) { handle_post_server(post, host); } return posts; } std::optional MastodonClient::get_post(const std::string& host, const std::string& id) { using namespace std::string_literals; try { Post post = this->_send_request(std::nullopt, "https://"s + host + "/api/v1/statuses/" + id); handle_post_server(post, host); return post; } catch (const MastodonException& e) { if (e.response_code != 404) { throw; } return std::nullopt; } } PostContext MastodonClient::get_post_context(const std::string& host, const std::string& id) { using namespace std::string_literals; PostContext context = this->_send_request(std::nullopt, "https://"s + host + "/api/v1/statuses/" + id + "/context"); for (Post& post : context.ancestors) { handle_post_server(post, host); } for (Post& post : context.descendants) { handle_post_server(post, host); } return context; } std::vector MastodonClient::get_tag_timeline(const std::string& host, const std::string& tag, std::optional max_id) { using namespace std::string_literals; std::string url = "https://"s + host + "/api/v1/timelines/tag/" + url_encode(tag); if (max_id) { url += "?max_id="; url += std::move(*max_id); } std::vector posts = this->_send_request(std::nullopt, url); for (Post& post : posts) { handle_post_server(post, host); } return posts; } Instance MastodonClient::get_instance(std::string host) { using namespace std::string_literals; lowercase(host); Instance instance = this->_send_request("coyote:"s + host + ":instance", "https://"s + host + "/api/v2/instance"); instance.contact_account.same_server = instance.contact_account.server == host; return instance; } blankie::html::HTMLString MastodonClient::get_extended_description(std::string host) { using namespace std::string_literals; lowercase(host); nlohmann::json j = this->_send_request("coyote:"s + host + ":desc", "https://"s + host + "/api/v1/instance/extended_description"); return blankie::html::HTMLString(j.at("content").get()); } CURL* MastodonClient::_get_easy() { CURL* curl = pthread_getspecific(this->_easy_key); if (!curl) { curl = curl_easy_init(); if (!curl) { throw std::bad_alloc(); } try { setopt(curl, CURLOPT_TIMEOUT_MS, 10000L); #if CURL_AT_LEAST_VERSION(7, 85, 0) setopt(curl, CURLOPT_PROTOCOLS_STR, "https"); #else setopt(curl, CURLOPT_PROTOCOLS, CURLPROTO_HTTPS); #endif setopt(curl, CURLOPT_USERAGENT, "Coyote (https://gitlab.com/blankX/coyote; blankie@nixnetmail.com)"); setopt(curl, CURLOPT_SHARE, this->_share); } catch (const std::exception& e) { curl_easy_cleanup(curl); throw; } int err = pthread_setspecific(this->_easy_key, curl); if (err) { curl_easy_cleanup(curl); throw std::system_error(err, std::generic_category(), "pthread_setspecific()"); } } return curl; } nlohmann::json MastodonClient::_send_request(std::optional cache_key, const std::string& url) { std::optional cached; if (redis && cache_key && (cached = redis->get(*cache_key))) { return nlohmann::json::parse(std::move(*cached)); } std::string res; CURL* curl = this->_get_easy(); setopt(curl, CURLOPT_URL, url.c_str()); setopt(curl, CURLOPT_WRITEFUNCTION, curl_write_cb); setopt(curl, CURLOPT_WRITEDATA, &res); CURLcode code = curl_easy_perform(curl); if (code) { throw CurlException(code); } long response_code = this->_response_status_code(); nlohmann::json j = nlohmann::json::parse(res); if (response_code != 200) { throw MastodonException(response_code, j.at("error").get()); } if (redis && cache_key) { redis->set(*cache_key, std::move(res), 60 * 60); } return j; } long MastodonClient::_response_status_code() { long response_code; CURLcode code = curl_easy_getinfo(this->_get_easy(), CURLINFO_RESPONSE_CODE, &response_code); if (code) { throw CurlException(code); } return response_code; } static void lowercase(std::string& str) { for (size_t i = 0; i < str.size(); i++) { if (str[i] >= 'A' && str[i] <= 'Z') { str[i] = static_cast(str[i] - 'A' + 'a'); } } } static void handle_post_server(Post& post, const std::string& host) { post.account.same_server = host == post.account.server; if (post.reblog) { post.reblog->account.same_server = host == post.reblog->account.server; } } static std::string url_encode(const std::string& in) { std::string out; char encoded[2]; size_t pos = 0; size_t last_pos = 0; out.reserve(in.size()); while ((pos = in.find_first_not_of("abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789", pos)) != std::string::npos) { out.append(in, last_pos, pos - last_pos); hexencode(in[pos], encoded); out += '%'; out.append(encoded, 2); pos++; last_pos = pos; } if (in.size() > last_pos) { out.append(in, last_pos); } return out; } static inline void hexencode(char c, char out[2]) { char nibble1 = (c >> 4) & 0xF; char nibble2 = c & 0xF; auto hexencode = [](char nibble) { return static_cast(nibble < 10 ? '0' + nibble : 'A' + nibble - 10); }; out[0] = hexencode(nibble1); out[1] = hexencode(nibble2); } static void share_lock(CURL* curl, curl_lock_data data, curl_lock_access access, void* clientp) { (void)curl; (void)access; std::mutex* mutexes = reinterpret_cast(clientp); assert(CURL_LOCK_DATA_LAST > data); mutexes[data].lock(); } static void share_unlock(CURL* curl, curl_lock_data data, void* clientp) { (void)curl; std::mutex* mutexes = reinterpret_cast(clientp); assert(CURL_LOCK_DATA_LAST > data); mutexes[data].unlock(); } static size_t curl_write_cb(char* ptr, size_t size, size_t nmemb, void* userdata) { size_t realsize = size * nmemb; std::string* str = reinterpret_cast(userdata); // XXX should we bother with exceptions str->append(ptr, realsize); return realsize; }