Compare commits

..

6 Commits

18 changed files with 411 additions and 104 deletions

View File

@ -29,8 +29,8 @@ list(APPEND FLAGS -Werror -Wall -Wextra -Wshadow -Wpedantic -Wno-gnu-anonymous-s
add_link_options(${FLAGS}) add_link_options(${FLAGS})
add_executable(${PROJECT_NAME} main.cpp numberhelper.cpp config.cpp models.cpp client.cpp servehelper.cpp timeutils.cpp hiredis_wrapper.cpp add_executable(${PROJECT_NAME} main.cpp numberhelper.cpp config.cpp settings.cpp models.cpp client.cpp servehelper.cpp timeutils.cpp hiredis_wrapper.cpp
routes/home.cpp routes/css.cpp routes/user.cpp routes/status.cpp routes/tags.cpp routes/about.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) blankie/serializer.cpp blankie/escape.cpp)
set_target_properties(${PROJECT_NAME} set_target_properties(${PROJECT_NAME}
PROPERTIES PROPERTIES

View File

@ -4,6 +4,7 @@
#include "client.h" #include "client.h"
#include "models.h" #include "models.h"
#include "curlu_wrapper.h"
#include "hiredis_wrapper.h" #include "hiredis_wrapper.h"
MastodonClient mastodon_client; MastodonClient mastodon_client;
@ -85,8 +86,13 @@ std::optional<Account> MastodonClient::get_account_by_username(std::string host,
username.erase(username.size() - host.size() - 1); username.erase(username.size() - host.size() - 1);
} }
CurlUrl url;
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));
try { try {
Account account = this->_send_request("coyote:"s + host + ":@" + username, "https://"s + host + "/api/v1/accounts/lookup?acct=" + url_encode(username)); Account account = this->_send_request("coyote:"s + host + ":@" + username, url);
account.same_server = host == account.server; account.same_server = host == account.server;
return account; return account;
} catch (const MastodonException& e) { } catch (const MastodonException& e) {
@ -102,7 +108,12 @@ std::vector<Post> MastodonClient::get_pinned_posts(std::string host, const std::
using namespace std::string_literals; using namespace std::string_literals;
lowercase(host); lowercase(host);
std::vector<Post> posts = this->_send_request("coyote:"s + host + ':' + account_id + ":pinned", "https://"s + host + "/api/v1/accounts/" + account_id + "/statuses?pinned=true"); 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_QUERY, "pinned=true");
std::vector<Post> posts = this->_send_request("coyote:"s + host + ':' + account_id + ":pinned", url);
for (Post& post : posts) { for (Post& post : posts) {
handle_post_server(post, host); handle_post_server(post, host);
@ -113,22 +124,17 @@ std::vector<Post> MastodonClient::get_pinned_posts(std::string host, const std::
std::vector<Post> MastodonClient::get_posts(const std::string& host, const std::string& account_id, PostSortingMethod sorting_method, std::optional<std::string> max_id) { std::vector<Post> MastodonClient::get_posts(const std::string& host, const std::string& account_id, PostSortingMethod sorting_method, std::optional<std::string> max_id) {
using namespace std::string_literals; using namespace std::string_literals;
const char* sorting_parameters[3] = {"exclude_replies=true", "", "only_media=true"}; const char* sorting_parameters[3] = {"exclude_replies=true", "", "only_media=true"};
std::string query = sorting_parameters[sorting_method];
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_QUERY, sorting_parameters[sorting_method]);
if (max_id) { if (max_id) {
if (!query.empty()) { url.set(CURLUPART_QUERY, "max_id="s + std::move(*max_id), CURLU_URLENCODE | CURLU_APPENDQUERY);
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<Post> posts = this->_send_request(std::nullopt, url); std::vector<Post> posts = this->_send_request(std::nullopt, url);
for (Post& post : posts) { for (Post& post : posts) {
@ -138,11 +144,15 @@ std::vector<Post> MastodonClient::get_posts(const std::string& host, const std::
return posts; return posts;
} }
std::optional<Post> MastodonClient::get_post(const std::string& host, const std::string& id) { std::optional<Post> MastodonClient::get_post(const std::string& host, std::string id) {
using namespace std::string_literals; using namespace std::string_literals;
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)));
try { try {
Post post = this->_send_request(std::nullopt, "https://"s + host + "/api/v1/statuses/" + id); Post post = this->_send_request(std::nullopt, url);
handle_post_server(post, host); handle_post_server(post, host);
return post; return post;
} catch (const MastodonException& e) { } catch (const MastodonException& e) {
@ -154,10 +164,14 @@ std::optional<Post> MastodonClient::get_post(const std::string& host, const std:
} }
} }
PostContext MastodonClient::get_post_context(const std::string& host, const std::string& id) { PostContext MastodonClient::get_post_context(const std::string& host, std::string id) {
using namespace std::string_literals; using namespace std::string_literals;
PostContext context = this->_send_request(std::nullopt, "https://"s + host + "/api/v1/statuses/" + id + "/context"); 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");
PostContext context = this->_send_request(std::nullopt, url);
for (Post& post : context.ancestors) { for (Post& post : context.ancestors) {
handle_post_server(post, host); handle_post_server(post, host);
@ -172,10 +186,12 @@ PostContext MastodonClient::get_post_context(const std::string& host, const std:
std::vector<Post> MastodonClient::get_tag_timeline(const std::string& host, const std::string& tag, std::optional<std::string> max_id) { std::vector<Post> MastodonClient::get_tag_timeline(const std::string& host, const std::string& tag, std::optional<std::string> max_id) {
using namespace std::string_literals; using namespace std::string_literals;
std::string url = "https://"s + host + "/api/v1/timelines/tag/" + url_encode(tag); CurlUrl url;
url.set(CURLUPART_SCHEME, "https");
url.set(CURLUPART_HOST, host);
url.set(CURLUPART_PATH, "/api/v1/timelines/tag/"s + url_encode(tag));
if (max_id) { if (max_id) {
url += "?max_id="; url.set(CURLUPART_QUERY, "max_id="s + std::move(*max_id), CURLU_URLENCODE | CURLU_APPENDQUERY);
url += std::move(*max_id);
} }
std::vector<Post> posts = this->_send_request(std::nullopt, url); std::vector<Post> posts = this->_send_request(std::nullopt, url);
@ -189,7 +205,11 @@ Instance MastodonClient::get_instance(std::string host) {
using namespace std::string_literals; using namespace std::string_literals;
lowercase(host); lowercase(host);
Instance instance = this->_send_request("coyote:"s + host + ":instance", "https://"s + host + "/api/v2/instance"); CurlUrl url;
url.set(CURLUPART_SCHEME, "https");
url.set(CURLUPART_HOST, host);
url.set(CURLUPART_PATH, "/api/v2/instance");
Instance instance = this->_send_request("coyote:"s + host + ":instance", url);
instance.contact_account.same_server = instance.contact_account.server == host; instance.contact_account.same_server = instance.contact_account.server == host;
return instance; return instance;
} }
@ -198,7 +218,11 @@ blankie::html::HTMLString MastodonClient::get_extended_description(std::string h
using namespace std::string_literals; using namespace std::string_literals;
lowercase(host); lowercase(host);
nlohmann::json j = this->_send_request("coyote:"s + host + ":desc", "https://"s + host + "/api/v1/instance/extended_description"); CurlUrl url;
url.set(CURLUPART_SCHEME, "https");
url.set(CURLUPART_HOST, host);
url.set(CURLUPART_PATH, "/api/v1/instance/extended_description");
nlohmann::json j = this->_send_request("coyote:"s + host + ":desc", url);
return blankie::html::HTMLString(j.at("content").get<std::string>()); return blankie::html::HTMLString(j.at("content").get<std::string>());
} }
@ -234,7 +258,7 @@ CURL* MastodonClient::_get_easy() {
return curl; return curl;
} }
nlohmann::json MastodonClient::_send_request(std::optional<std::string> cache_key, const std::string& url) { nlohmann::json MastodonClient::_send_request(std::optional<std::string> cache_key, const CurlUrl& url) {
std::optional<std::string> cached; std::optional<std::string> cached;
if (redis && cache_key && (cached = redis->get(*cache_key))) { if (redis && cache_key && (cached = redis->get(*cache_key))) {
return nlohmann::json::parse(std::move(*cached)); return nlohmann::json::parse(std::move(*cached));
@ -243,10 +267,11 @@ nlohmann::json MastodonClient::_send_request(std::optional<std::string> cache_ke
std::string res; std::string res;
CURL* curl = this->_get_easy(); CURL* curl = this->_get_easy();
setopt(curl, CURLOPT_URL, url.c_str()); setopt(curl, CURLOPT_CURLU, url.get());
setopt(curl, CURLOPT_WRITEFUNCTION, curl_write_cb); setopt(curl, CURLOPT_WRITEFUNCTION, curl_write_cb);
setopt(curl, CURLOPT_WRITEDATA, &res); setopt(curl, CURLOPT_WRITEDATA, &res);
CURLcode code = curl_easy_perform(curl); CURLcode code = curl_easy_perform(curl);
setopt(curl, CURLOPT_CURLU, nullptr);
if (code) { if (code) {
throw CurlException(code); throw CurlException(code);
} }

View File

@ -8,6 +8,7 @@
#include <nlohmann/json.hpp> #include <nlohmann/json.hpp>
#include "models.h" #include "models.h"
class CurlUrl; // forward declaration from curlu_wrapper.h
class CurlException : public std::exception { class CurlException : public std::exception {
public: public:
@ -68,8 +69,8 @@ public:
std::vector<Post> get_pinned_posts(std::string host, const std::string& account_id); std::vector<Post> get_pinned_posts(std::string host, const std::string& account_id);
std::vector<Post> get_posts(const std::string& host, const std::string& account_id, PostSortingMethod sorting_method, std::optional<std::string> max_id); std::vector<Post> get_posts(const std::string& host, const std::string& account_id, PostSortingMethod sorting_method, std::optional<std::string> max_id);
std::optional<Post> get_post(const std::string& host, const std::string& id); std::optional<Post> get_post(const std::string& host, std::string id);
PostContext get_post_context(const std::string& host, const std::string& id); PostContext get_post_context(const std::string& host, std::string id);
std::vector<Post> get_tag_timeline(const std::string& host, const std::string& tag, std::optional<std::string> max_id); std::vector<Post> get_tag_timeline(const std::string& host, const std::string& tag, std::optional<std::string> max_id);
@ -78,7 +79,7 @@ public:
private: private:
CURL* _get_easy(); CURL* _get_easy();
nlohmann::json _send_request(std::optional<std::string> cache_key, const std::string& url); nlohmann::json _send_request(std::optional<std::string> cache_key, const CurlUrl& url);
long _response_status_code(); long _response_status_code();
std::mutex _share_locks[CURL_LOCK_DATA_LAST]; std::mutex _share_locks[CURL_LOCK_DATA_LAST];

77
curlu_wrapper.h Normal file
View File

@ -0,0 +1,77 @@
#pragma once
#include <cstdio>
#include <string>
#include <memory>
#include <exception>
#include <stdexcept>
#include <curl/curl.h>
class CurlUrlException : public std::exception {
public:
CurlUrlException(CURLUcode code_) : code(code_) {
#if !CURL_AT_LEAST_VERSION(7, 80, 0)
snprintf(this->_id_buf, 64, "curl url error %d", this->code);
#endif
}
const char* what() const noexcept {
#if CURL_AT_LEAST_VERSION(7, 80, 0)
return curl_url_strerror(this->code);
#else
return this->_id_buf;
#endif
}
CURLUcode code;
private:
#if !CURL_AT_LEAST_VERSION(7, 80, 0)
char _id_buf[64];
#endif
};
using CurlStr = std::unique_ptr<char, decltype(&curl_free)>;
class CurlUrl {
public:
CurlUrl(const CurlUrl&) = delete;
CurlUrl& operator=(const CurlUrl&) = delete;
CurlUrl() {
this->_ptr = curl_url();
if (!this->_ptr) {
throw std::bad_alloc();
}
}
~CurlUrl() {
curl_url_cleanup(this->_ptr);
}
constexpr CURLU* get() const noexcept {
return this->_ptr;
}
CurlStr get(CURLUPart part, unsigned int flags = 0) const {
char* content;
CURLUcode code = curl_url_get(this->_ptr, part, &content, flags);
if (code) {
throw CurlUrlException(code);
}
return CurlStr(content, curl_free);
}
void set(CURLUPart part, const char* content, unsigned int flags = 0) {
CURLUcode code = curl_url_set(this->_ptr, part, content, flags);
if (code) {
throw CurlUrlException(code);
}
}
void set(CURLUPart part, const std::string& content, unsigned int flags = 0) {
this->set(part, content.c_str(), flags);
}
private:
CURLU* _ptr;
};

View File

@ -40,16 +40,20 @@ int main(int argc, char** argv) {
atexit(MastodonClient::cleanup); atexit(MastodonClient::cleanup);
httplib::Server server; httplib::Server server;
server.set_payload_max_length(8192);
server.Get("/", home_route); server.Get("/", home_route);
server.Get("/style.css", css_route); server.Get("/style.css", css_route);
server.Get("/settings", user_settings_route);
server.Post("/settings", user_settings_route);
server.Get("/(" DOMAIN_RE ")/@(" ACCT_RE ")(|/with_replies|/media)", user_route); server.Get("/(" DOMAIN_RE ")/@(" ACCT_RE ")(|/with_replies|/media)", user_route);
server.Get("/(" DOMAIN_RE ")/users/(" ACCT_RE ")(|/with_replies|/media)", [](const httplib::Request& req, httplib::Response& res) { server.Get("/(" DOMAIN_RE ")/users/(" ACCT_RE ")(|/with_replies|/media)", [](const httplib::Request& req, httplib::Response& res) {
serve_redirect(req, res, get_origin(req) + '/' + req.matches.str(1) + "/@" + req.matches.str(2) + req.matches.str(3), true); serve_redirect(req, res, get_origin(req) + '/' + 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 ")/@" ACCT_RE "/([a-zA-Z0-9]+)", status_route);
server.Get("/(" DOMAIN_RE ")/users/(" ACCT_RE ")/statuses/(\\d+)", [](const httplib::Request& req, httplib::Response& res) { server.Get("/(" DOMAIN_RE ")/users/(" ACCT_RE ")/statuses/([a-zA-Z0-9]+)", [](const httplib::Request& req, httplib::Response& res) {
serve_redirect(req, res, get_origin(req) + '/' + req.matches.str(1) + "/@" + req.matches.str(2) + '/' + req.matches.str(3), true); serve_redirect(req, res, get_origin(req) + '/' + req.matches.str(1) + "/@" + req.matches.str(2) + '/' + req.matches.str(3), true);
}); });

View File

@ -19,7 +19,9 @@ void from_json(const json& j, Emoji& emoji) {
void from_json(const json& j, AccountField& field) { void from_json(const json& j, AccountField& field) {
j.at("name").get_to(field.name); j.at("name").get_to(field.name);
field.value_html = j.at("value").get<std::string>(); field.value_html = j.at("value").get<std::string>();
if (j.at("verified_at").is_null()) { // https://social.kernel.org/@monsieuricon/Ac6oYwtLhess6uil1c
// https://social.kernel.org/@monsieuricon/Ac8RXJRqxTfUrM1xQG
if (!j.contains("verified_at") || j["verified_at"].is_null()) {
field.verified_at = -1; field.verified_at = -1;
} else { } else {
field.verified_at = parse_rfc3339(j["verified_at"].get_ref<const std::string&>()); field.verified_at = parse_rfc3339(j["verified_at"].get_ref<const std::string&>());
@ -104,7 +106,8 @@ void from_json(const json& j, Post& post) {
j.at("replies_count").get_to(post.replies_count); j.at("replies_count").get_to(post.replies_count);
j.at("reblogs_count").get_to(post.reblogs_count); j.at("reblogs_count").get_to(post.reblogs_count);
j.at("favourites_count").get_to(post.favorites_count); j.at("favourites_count").get_to(post.favorites_count);
if (!j.at("edited_at").is_null()) { // https://social.kernel.org/@monsieuricon/Ac6oYwtLhess6uil1c
if (j.contains("edited_at") && !j["edited_at"].is_null()) {
post.edited_at = parse_rfc3339(j["edited_at"].get_ref<const std::string&>()); post.edited_at = parse_rfc3339(j["edited_at"].get_ref<const std::string&>());
} else { } else {
post.edited_at = -1; post.edited_at = -1;
@ -117,7 +120,8 @@ void from_json(const json& j, Post& post) {
j.at("account").get_to(post.account); j.at("account").get_to(post.account);
j.at("media_attachments").get_to(post.media_attachments); j.at("media_attachments").get_to(post.media_attachments);
j.at("emojis").get_to(post.emojis); j.at("emojis").get_to(post.emojis);
if (!j.at("poll").is_null()) { // https://social.kernel.org/@monsieuricon/Ac6oYwtLhess6uil1c
if (j.contains("poll") && !j["poll"].is_null()) {
post.poll = j["poll"].get<Poll>(); post.poll = j["poll"].get<Poll>();
} }
} }

View File

@ -14,6 +14,10 @@ static const constexpr char css[] = R"EOF(
--error-border-color: red; --error-border-color: red;
--error-text-color: white; --error-text-color: white;
--success-background-color: green;
--success-border-color: lightgreen;
--success-text-color: white;
--accent-color: #962AC3; --accent-color: #962AC3;
--bright-accent-color: #DE6DE6; --bright-accent-color: #DE6DE6;
} }
@ -22,9 +26,11 @@ static const constexpr char css[] = R"EOF(
margin: 0; margin: 0;
padding: 0; padding: 0;
} }
p, details { /* .quote-inline and display: block: workaround for vt.social/@lina?max_id=111463476293964258 */
p, details, .quote-inline {
margin-top: 1em; margin-top: 1em;
margin-bottom: 1em; margin-bottom: 1em;
display: block;
} }
ul, ol { ul, ol {
margin: revert; margin: revert;
@ -161,6 +167,20 @@ svg {
padding: revert; padding: revert;
} }
/* SUCCESS BOX */
.success {
text-align: center;
background-color: var(--success-background-color);
color: var(--success-text-color);
border-style: solid;
border-color: var(--success-border-color);
margin-bottom: 0.5em;
}
.success * {
margin: revert;
padding: revert;
}
/* USER PAGE */ /* USER PAGE */
.user_page-header { .user_page-header {
width: 100%; width: 100%;
@ -250,6 +270,18 @@ svg {
margin: revert; margin: revert;
padding: revert; padding: revert;
} }
/* USER SETTINGS PAGE */
.user_settings_page-form {
display: inline;
}
.user_settings_page-form input[type=submit] {
margin-top: 0.5em;
padding: revert;
}
.user_settings_page-form .cancel {
margin-left: 0.25em;
}
)EOF"; )EOF";
// one for \0, one for trailing newline // one for \0, one for trailing newline
#define CSS_LEN sizeof(css) / sizeof(css[0]) - 2 #define CSS_LEN sizeof(css) / sizeof(css[0]) - 2

View File

@ -8,6 +8,7 @@ void home_route(const httplib::Request& req, httplib::Response& res) {
"their posts, posts themselves (and their context), posts with a certain hashtag, " "their posts, posts themselves (and their context), posts with a certain hashtag, "
"and instance details.", "and instance details.",
}), }),
Element("p", {Element("a", {{"href", get_origin(req) + "/settings"}}, {"Configure settings"})}),
Element("h2", {"Example links"}), Element("h2", {"Example links"}),
Element("ul", { Element("ul", {
Element("li", {Element("a", {{"href", get_origin(req) + "/vt.social/@lina"}}, {"Asahi Lina (朝日リナ) // nullptr::live"})}), Element("li", {Element("a", {{"href", get_origin(req) + "/vt.social/@lina"}}, {"Asahi Lina (朝日リナ) // nullptr::live"})}),

View File

@ -10,3 +10,4 @@ void user_route(const httplib::Request& req, httplib::Response& res);
void status_route(const httplib::Request& req, httplib::Response& res); void status_route(const httplib::Request& req, httplib::Response& res);
void tags_route(const httplib::Request& req, httplib::Response& res); void tags_route(const httplib::Request& req, httplib::Response& res);
void about_route(const httplib::Request& req, httplib::Response& res); void about_route(const httplib::Request& req, httplib::Response& res);
void user_settings_route(const httplib::Request& req, httplib::Response& res);

View File

@ -11,11 +11,6 @@ void tags_route(const httplib::Request& req, httplib::Response& res) {
std::optional<std::string> max_id; std::optional<std::string> max_id;
if (req.has_param("max_id")) { if (req.has_param("max_id")) {
max_id = req.get_param_value("max_id"); max_id = req.get_param_value("max_id");
if (max_id->empty() || max_id->find_first_not_of("0123456789") != std::string::npos) {
res.status = 400;
serve_error(req, res, "400: Bad Request", "Invalid max_id query paramter");
return;
}
} }
std::vector<Post> posts; std::vector<Post> posts;

View File

@ -22,11 +22,6 @@ void user_route(const httplib::Request& req, httplib::Response& res) {
std::optional<std::string> max_id; std::optional<std::string> max_id;
if (req.has_param("max_id")) { if (req.has_param("max_id")) {
max_id = req.get_param_value("max_id"); max_id = req.get_param_value("max_id");
if (max_id->empty() || max_id->find_first_not_of("0123456789") != std::string::npos) {
res.status = 400;
serve_error(req, res, "400: Bad Request", "Invalid max_id query paramter");
return;
}
} }
std::optional<Account> account; std::optional<Account> account;
@ -87,6 +82,14 @@ static inline PostSortingMethod get_sorting_method(const std::string& method) {
static inline Element user_header(const httplib::Request& req, const std::string& server, 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) {
blankie::html::HTMLString preprocessed_bio = preprocess_html(req, account.server, account.emojis, account.note_html);
// Workaround for https://social.kernel.org/@monsieuricon
if (preprocessed_bio.str.find("<p>") == std::string::npos) {
preprocessed_bio.str.reserve(preprocessed_bio.str.size() + 3 + 4);
preprocessed_bio.str.insert(0, "<p>");
preprocessed_bio.str.append("</p>");
}
Element user_links("table", {{"class", "user_page-user_links"}}, {}); Element user_links("table", {{"class", "user_page-user_links"}}, {});
user_links.nodes.reserve(account.fields.size()); user_links.nodes.reserve(account.fields.size());
for (const AccountField& i : account.fields) { for (const AccountField& i : account.fields) {
@ -118,7 +121,7 @@ static inline Element user_header(const httplib::Request& req, const std::string
}), }),
Element("div", {{"class", "user_page-user_description"}}, { Element("div", {{"class", "user_page-user_description"}}, {
Element("div", {{"class", "user_page-user_bio"}}, {preprocess_html(req, account.server, account.emojis, account.note_html)}), Element("div", {{"class", "user_page-user_bio"}}, {std::move(preprocessed_bio)}),
std::move(user_links), std::move(user_links),
}), }),

64
routes/user_settings.cpp Normal file
View File

@ -0,0 +1,64 @@
#include "routes.h"
#include "../servehelper.h"
#include "../settings.h"
#include "../timeutils.h"
#include "../curlu_wrapper.h"
static void set_cookie(const httplib::Request& req, httplib::Response& res, const char* key, std::string_view value);
void user_settings_route(const httplib::Request& req, httplib::Response& res) {
UserSettings settings;
if (req.method == "POST") {
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);
}
Element auto_open_cw_checkbox("input", {{"type", "checkbox"}, {"name", "auto-open-cw"}, {"value", "true"}}, {});
if (settings.auto_open_cw) {
auto_open_cw_checkbox.attributes.push_back({"checked", ""});
}
Element body("body", {
Element("form", {{"class", "user_settings_page-form"}, {"method", "post"}}, {
Element("label", {
std::move(auto_open_cw_checkbox),
" Automatically open Content Warnings",
}),
Element("br"),
Element("input", {{"type", "submit"}, {"value", "Save"}}, {}),
}),
Element("form", {{"class", "user_settings_page-form"}, {"method", "get"}, {"action", get_origin(req)}}, {
Element("input", {{"class", "cancel"}, {"type", "submit"}, {"value", "Cancel"}}, {}),
}),
});
if (req.method == "POST") {
body.nodes.insert(body.nodes.begin(), Element("div", {{"class", "success"}}, {
Element("h3", {"Settings saved!"}),
}));
}
serve(req, res, "User settings", std::move(body));
}
static void set_cookie(const httplib::Request& req, httplib::Response& res, const char* key, std::string_view value) {
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);
if (strcmp(origin.get(CURLUPART_SCHEME).get(), "https") == 0) {
header += "; Secure";
}
res.set_header("Set-Cookie", header);
}

View File

@ -6,10 +6,12 @@
#include "font_awesome.h" #include "font_awesome.h"
#include "config.h" #include "config.h"
#include "settings.h"
#include "models.h" #include "models.h"
#include "timeutils.h" #include "timeutils.h"
#include "servehelper.h" #include "servehelper.h"
#include "lxb_wrapper.h" #include "lxb_wrapper.h"
#include "curlu_wrapper.h"
#include "routes/routes.h" #include "routes/routes.h"
#include "blankie/escape.h" #include "blankie/escape.h"
@ -27,30 +29,6 @@ static Element serialize_post(const httplib::Request& req, const std::string& se
static inline Element serialize_media(const Media& media); static inline Element serialize_media(const Media& media);
static inline Element serialize_poll(const httplib::Request& req, const Poll& poll); static inline Element serialize_poll(const httplib::Request& req, const Poll& poll);
class CurlUrlException : public std::exception {
public:
CurlUrlException(CURLUcode code_) : code(code_) {
#if !CURL_AT_LEAST_VERSION(7, 80, 0)
snprintf(this->_id_buf, 64, "curl url error %d", this->code);
#endif
}
const char* what() const noexcept {
#if CURL_AT_LEAST_VERSION(7, 80, 0)
return curl_url_strerror(this->code);
#else
return this->_id_buf;
#endif
}
CURLUcode code;
private:
#if !CURL_AT_LEAST_VERSION(7, 80, 0)
char _id_buf[64];
#endif
};
void serve(const httplib::Request& req, httplib::Response& res, std::string title, Element element, Nodes extra_head) { void serve(const httplib::Request& req, httplib::Response& res, std::string title, Element element, Nodes extra_head) {
using namespace std::string_literals; using namespace std::string_literals;
@ -118,6 +96,21 @@ void serve_redirect(const httplib::Request& req, httplib::Response& res, std::st
bool starts_with(const CurlUrl& url, const CurlUrl& base) {
if (strcmp(url.get(CURLUPART_SCHEME).get(), base.get(CURLUPART_SCHEME).get()) != 0) {
return false;
}
if (strcmp(url.get(CURLUPART_HOST).get(), base.get(CURLUPART_HOST).get()) != 0) {
return false;
}
CurlStr url_path = url.get(CURLUPART_PATH);
CurlStr base_path = base.get(CURLUPART_PATH);
size_t base_path_len = strlen(base_path.get());
return memcpy(url_path.get(), base_path.get(), base_path_len) == 0
&& (url_path.get()[base_path_len] == '/' || url_path.get()[base_path_len] == '\0');
}
std::string get_origin(const httplib::Request& req) { std::string get_origin(const httplib::Request& req) {
if (req.has_header("X-Canonical-Origin")) { if (req.has_header("X-Canonical-Origin")) {
return req.get_header_value("X-Canonical-Origin"); return req.get_header_value("X-Canonical-Origin");
@ -139,43 +132,28 @@ std::string get_origin(const httplib::Request& req) {
} }
std::string proxy_mastodon_url(const httplib::Request& req, const std::string& url_str) { std::string proxy_mastodon_url(const httplib::Request& req, const std::string& url_str) {
using CurlStr = std::unique_ptr<char, decltype(&curl_free)>; CurlUrl url;
url.set(CURLUPART_URL, url_str.c_str());
std::unique_ptr<CURLU, decltype(&curl_url_cleanup)> url(curl_url(), curl_url_cleanup); std::string new_url = get_origin(req) + '/' + url.get(CURLUPART_HOST).get() + url.get(CURLUPART_PATH).get();
if (!url) {
throw std::bad_alloc();
}
// in a block to avoid a (potential) gcc bug where it thinks the lambda below try {
// shadows `code`, even if i do `[&url](...) { ... }` CurlStr query = url.get(CURLUPART_QUERY);
{
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 += '?';
new_url += query.get(); new_url += query.get();
} catch (const CurlUrlException& e) {
if (e.code != CURLUE_NO_QUERY) {
throw;
}
} }
if (fragment) { try {
CurlStr fragment = url.get(CURLUPART_FRAGMENT);
new_url += '#'; new_url += '#';
new_url += fragment.get(); new_url += fragment.get();
} catch (const CurlUrlException& e) {
if (e.code != CURLUE_NO_FRAGMENT) {
throw;
}
} }
return new_url; return new_url;
} }
@ -263,10 +241,14 @@ static inline void preprocess_link(const httplib::Request& req, const std::strin
const lxb_char_t* cls_c = lxb_dom_element_class(element, &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) : ""; std::string cls = cls_c ? std::string(reinterpret_cast<const char*>(cls_c), cls_c_len) : "";
std::string instance_url_base = "https://"s + domain_name; CurlUrl href_url;
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 // .mention is used in note and posts
// Instance base is used for link fields // 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) { if (std::regex_search(cls, mention_class_re) || starts_with(href_url, instance_url_base)) {
// Proxy this instance's URLs to Coyote // Proxy this instance's URLs to Coyote
href = proxy_mastodon_url(req, std::move(href)); href = proxy_mastodon_url(req, std::move(href));
@ -387,7 +369,14 @@ static Element serialize_post(const httplib::Request& req, const std::string& se
: "Created: "s + full_time(post.created_at) + "\nEdited: " + full_time(post.edited_at); : "Created: "s + full_time(post.created_at) + "\nEdited: " + full_time(post.edited_at);
const char* time_badge = post.edited_at < 0 ? "" : " (edited)"; const char* time_badge = post.edited_at < 0 ? "" : " (edited)";
Element contents("div", {{"class", "post-contents"}}, {preprocess_html(req, server, post.emojis, post.content)}); 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"}}, {}); Element post_attachments("div", {{"class", "post-attachments"}}, {});
post_attachments.nodes.reserve(post.media_attachments.size()); post_attachments.nodes.reserve(post.media_attachments.size());
@ -406,6 +395,9 @@ static Element serialize_post(const httplib::Request& req, const std::string& se
Element("summary", {preprocess_html(req, post.emojis, std::move(spoiler_text))}), Element("summary", {preprocess_html(req, post.emojis, std::move(spoiler_text))}),
std::move(contents), std::move(contents),
}); });
if (UserSettings(req).auto_open_cw) {
contents.attributes.push_back({"open", ""});
}
} }
Element div("div", {{"class", "post"}}, { Element div("div", {{"class", "post"}}, {

View File

@ -6,6 +6,7 @@
#include "blankie/serializer.h" #include "blankie/serializer.h"
struct Post; // forward declaration from models.h struct Post; // forward declaration from models.h
struct Emoji; // 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 Element = blankie::html::Element;
using Node = blankie::html::Node; using Node = blankie::html::Node;
@ -16,6 +17,7 @@ void serve_error(const httplib::Request& req, httplib::Response& res,
std::string title, std::optional<std::string> subtitle = std::nullopt, std::optional<std::string> info = std::nullopt); std::string title, std::optional<std::string> subtitle = std::nullopt, std::optional<std::string> info = std::nullopt);
void serve_redirect(const httplib::Request& req, httplib::Response& res, std::string url, bool permanent = false); void serve_redirect(const httplib::Request& req, httplib::Response& res, std::string url, bool permanent = false);
bool starts_with(const CurlUrl& url, const CurlUrl& base);
std::string get_origin(const httplib::Request& req); std::string get_origin(const httplib::Request& req);
std::string proxy_mastodon_url(const httplib::Request& req, const std::string& url_str); 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); bool should_send_304(const httplib::Request& req, uint64_t hash);

75
settings.cpp Normal file
View File

@ -0,0 +1,75 @@
#include <string>
#include <stdexcept>
#include "settings.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);
void UserSettings::set(std::string_view key, std::string_view value) {
if (key == "auto-open-cw") {
this->auto_open_cw = parse_bool(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);
}
}
}
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;
if (value == "true") {
return true;
} else if (value == "false") {
return false;
} else {
throw std::invalid_argument("unknown boolean value: "s + std::string(value));
}
}

16
settings.h Normal file
View File

@ -0,0 +1,16 @@
#pragma once
#include <string_view>
#include <httplib/httplib.h>
struct UserSettings {
bool auto_open_cw = false;
UserSettings() = default;
UserSettings(const httplib::Request& req) {
this->load_from_cookies(req);
}
void set(std::string_view key, std::string_view value);
void load_from_cookies(const httplib::Request& req);
};

View File

@ -1,3 +1,4 @@
#include <cstring>
#include <system_error> #include <system_error>
#include "timeutils.h" #include "timeutils.h"
@ -39,6 +40,19 @@ std::string to_rfc3339(time_t time) {
return std::string(buf, len); return std::string(buf, len);
} }
static const char* day_names[] = {"Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"};
static const char* month_names[] = {"Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"};
std::string to_web_date(time_t time) {
struct tm tm;
gmtime_r(&time, &tm);
char buf[32];
size_t len = strftime(buf, 32, "XXX, %d XXX %Y %H:%M:%S GMT", &tm);
memcpy(buf, day_names[tm.tm_wday], 3);
memcpy(buf + 3 + 2 + 2 + 1, month_names[tm.tm_mon], 3);
return std::string(buf, len);
}
std::string relative_time(time_t from, time_t to) { std::string relative_time(time_t from, time_t to) {
time_t diff = to - from; time_t diff = to - from;

View File

@ -8,4 +8,5 @@ time_t current_time();
std::string short_time(time_t time); std::string short_time(time_t time);
std::string full_time(time_t time); std::string full_time(time_t time);
std::string to_rfc3339(time_t time); std::string to_rfc3339(time_t time);
std::string to_web_date(time_t time);
std::string relative_time(time_t from, time_t to); std::string relative_time(time_t from, time_t to);