Add rudimentary post support

This commit is contained in:
blankie 2023-11-23 17:05:17 +11:00
parent 1808df8f75
commit b8f037765b
Signed by: blankie
GPG Key ID: CC15FC822C7F61F5
14 changed files with 271 additions and 32 deletions

View File

@ -28,8 +28,8 @@ list(APPEND FLAGS -Werror -Wall -Wextra -Wshadow -Wpedantic -Wno-gnu-anonymous-s
add_link_options(${FLAGS})
add_executable(${PROJECT_NAME} main.cpp numberhelper.cpp config.cpp models.cpp client.cpp servehelper.cpp
routes/css.cpp routes/user.cpp
add_executable(${PROJECT_NAME} main.cpp numberhelper.cpp config.cpp models.cpp client.cpp servehelper.cpp timeutils.cpp
routes/css.cpp routes/user.cpp routes/status.cpp
blankie/serializer.cpp blankie/escape.cpp)
set_target_properties(${PROJECT_NAME}
PROPERTIES

View File

@ -70,7 +70,29 @@ std::optional<Account> MastodonClient::get_account_by_username(const std::string
try {
std::string resp = this->_send_request("https://"s + host + "/api/v1/accounts/lookup?acct=" + username);
return nlohmann::json::parse(std::move(resp));
Account res = nlohmann::json::parse(std::move(resp));
res.same_server = host == res.server;
return res;
} catch (const CurlException& e) {
if (e.code != CURLE_HTTP_RETURNED_ERROR || this->_response_status_code() != 404) {
throw;
}
return std::nullopt;
}
}
std::optional<Post> MastodonClient::get_post(const std::string& host, const std::string& id) {
using namespace std::string_literals;
try {
std::string resp = this->_send_request("https://"s + host + "/api/v1/statuses/" + id);
Post post = nlohmann::json::parse(std::move(resp));
post.account.same_server = host == post.account.server;
if (post.reblog) {
post.reblog->account.same_server = host == post.reblog->account.server;
}
return post;
} catch (const CurlException& e) {
if (e.code != CURLE_HTTP_RETURNED_ERROR || this->_response_status_code() != 404) {
throw;

View File

@ -7,6 +7,7 @@
#include <curl/curl.h>
struct Account; // forward declaration from models.h
struct Post; // forward declaration from models.h
class CurlException : public std::exception {
public:
@ -50,6 +51,7 @@ public:
}
std::optional<Account> get_account_by_username(const std::string& host, const std::string& username);
std::optional<Post> get_post(const std::string& host, const std::string& id);
private:
CURL* _get_easy();

View File

@ -45,6 +45,13 @@ int main(int argc, char** argv) {
serve_redirect(req, res, "/"s + req.matches.str(1) + "/@" + req.matches.str(2) + req.matches.str(3), true);
});
server.Get("/(" DOMAIN_RE ")/@" ACCT_RE "/(\\d+)", status_route);
server.Get("/(" DOMAIN_RE ")/users/(" ACCT_RE ")/statuses/(\\d+)", [](const httplib::Request& req, httplib::Response& res) {
using namespace std::string_literals;
serve_redirect(req, res, "/"s + req.matches.str(1) + "/@" + req.matches.str(2) + '/' + req.matches.str(3), true);
});
#ifndef NDEBUG
server.Get("/debug/exception/known", [](const httplib::Request& req, httplib::Response& res) {
throw std::runtime_error("awoo");

View File

@ -48,7 +48,35 @@ void from_json(const json& j, Account& account) {
if (!std::regex_match(url, sm, host_regex)) {
throw std::runtime_error("failed to find host in url: "s + url);
}
account.domain_name = sm.str(1);
account.server = sm.str(1);
}
void from_json(const json& j, Post& post) {
j.at("id").get_to(post.id);
post.created_at = parse_rfc3339(j.at("created_at").get_ref<const std::string&>());
if (!j.at("in_reply_to_id").is_null()) {
post.in_reply_to_id = j["in_reply_to_id"].get<std::string>();
}
if (!j.at("in_reply_to_account_id").is_null()) {
post.in_reply_to_account_id = j["in_reply_to_account_id"].get<std::string>();
}
j.at("sensitive").get_to(post.sensitive);
j.at("spoiler_text").get_to(post.spoiler_text);
j.at("replies_count").get_to(post.replies_count);
j.at("reblogs_count").get_to(post.reblogs_count);
j.at("favourites_count").get_to(post.favorites_count);
if (!j.at("edited_at").is_null()) {
post.edited_at = parse_rfc3339(j["edited_at"].get_ref<const std::string&>());
} else {
post.edited_at = -1;
}
post.content = j.at("content").get<std::string>();
if (!j.at("reblog").is_null()) {
post.reblog = std::make_unique<Post>();
from_json(j["reblog"].get<json>(), *post.reblog.get());
}
j.at("account").get_to(post.account);
j.at("emojis").get_to(post.emojis);
}

View File

@ -2,6 +2,7 @@
#include <string>
#include <vector>
#include <memory>
#include <nlohmann/json_fwd.hpp>
#include "blankie/serializer.h"
@ -25,7 +26,8 @@ struct AccountField {
struct Account {
std::string id;
std::string username;
std::string domain_name;
std::string server;
bool same_server;
std::string display_name;
time_t created_at;
blankie::html::HTMLString note_html;
@ -36,8 +38,35 @@ struct Account {
uint64_t statuses_count;
std::vector<Emoji> emojis;
std::vector<AccountField> fields;
constexpr std::string acct(bool always_show_domain = true) const {
std::string res = this->username;
if (always_show_domain || !this->same_server) {
res += '@';
res += this->server;
}
return res;
}
};
struct Post {
std::string id;
time_t created_at;
std::optional<std::string> in_reply_to_id;
std::optional<std::string> in_reply_to_account_id;
bool sensitive;
std::string spoiler_text;
uint64_t replies_count;
uint64_t reblogs_count;
uint64_t favorites_count;
time_t edited_at; // negative is not edited
blankie::html::HTMLString content;
std::unique_ptr<Post> reblog;
Account account;
std::vector<Emoji> emojis;
};
void from_json(const nlohmann::json& j, Emoji& emoji);
void from_json(const nlohmann::json& j, AccountField& field);
void from_json(const nlohmann::json& j, Account& account);
void from_json(const nlohmann::json& j, Post& post);

View File

@ -41,6 +41,7 @@ a:hover {
color: var(--bright-accent-color);
}
/* CUSTOM EMOJI */
.custom_emoji {
height: 1em;
width: 1em;
@ -48,6 +49,30 @@ a:hover {
vertical-align: middle;
}
/* POST */
.post-header, .post-header a {
display: flex;
}
.post-header a {
color: inherit;
text-decoration: none;
}
.post-header a:hover {
text-decoration: underline;
}
.post-avatar {
width: 3em;
height: 3em;
}
.post-header span, .post-header time {
margin-top: auto;
margin-bottom: auto;
margin-left: 0.5em;
}
.post-header a:has(time) {
margin-left: auto;
}
/* ERROR PAGE */
.error {
text-align: center;

View File

@ -6,3 +6,4 @@ extern const uint64_t css_hash;
void css_route(const httplib::Request& req, httplib::Response& res);
void user_route(const httplib::Request& req, httplib::Response& res);
void status_route(const httplib::Request& req, httplib::Response& res);

29
routes/status.cpp Normal file
View File

@ -0,0 +1,29 @@
#include "routes.h"
#include "../servehelper.h"
#include "../client.h"
#include "../models.h"
void status_route(const httplib::Request& req, httplib::Response& res) {
std::string server = req.matches.str(1);
std::string id = req.matches.str(2);
std::optional<Post> post;
try {
post = mastodon_client.get_post(server, id);
} catch (const std::exception& e) {
res.status = 500;
serve_error(req, res, "500: Internal server error", "Failed to fetch post information", e.what());
return;
}
if (!post) {
res.status = 404;
serve_error(req, res, "404: Post not found");
return;
}
Element body("body", {
serialize_post(req, server, *post),
});
serve(req, res, "", std::move(body));
}

View File

@ -2,14 +2,15 @@
#include "../servehelper.h"
#include "../client.h"
#include "../models.h"
#include "../timeutils.h"
static const char* sorting_method_names[3] = {"Posts", "Posts and replies", "Media"};
static const char* sorting_method_suffixes[3] = {"", "/with_replies", "/media"};
static inline PostSortingMethod get_sorting_method(const std::string& method);
static inline Element user_header(const httplib::Request& req, const Account& account, PostSortingMethod sorting_method);
static inline Element user_header(const httplib::Request& req, const std::string& server, const Account& account, PostSortingMethod sorting_method);
static inline Element user_link_field(const httplib::Request& req, const Account& account, const AccountField& field);
static inline Element sorting_method_link(const httplib::Request& req, const Account& account, PostSortingMethod current_method, PostSortingMethod new_method);
static inline Element sorting_method_link(const httplib::Request& req, const std::string& server, const Account& account, PostSortingMethod current_method, PostSortingMethod new_method);
void user_route(const httplib::Request& req, httplib::Response& res) {
@ -33,9 +34,9 @@ void user_route(const httplib::Request& req, httplib::Response& res) {
}
Element body("body", {
user_header(req, *account, sorting_method),
user_header(req, server, *account, sorting_method),
});
serve(req, res, account->display_name + " (" + account->username + '@' + account->domain_name + ')', std::move(body));
serve(req, res, account->display_name + " (" + account->acct() + ')', std::move(body));
}
@ -50,12 +51,7 @@ static inline PostSortingMethod get_sorting_method(const std::string& method) {
}
static inline Element user_header(const httplib::Request& req, const Account& account, PostSortingMethod sorting_method) {
struct tm created_at;
char created_at_str[16];
gmtime_r(&account.created_at, &created_at);
strftime(created_at_str, 16, "%Y-%m-%d", &created_at);
static inline Element user_header(const httplib::Request& req, const std::string& server, const Account& account, PostSortingMethod sorting_method) {
Element user_links("table", {{"class", "user_page-user_links"}}, {});
user_links.nodes.reserve(account.fields.size());
for (const AccountField& i : account.fields) {
@ -71,9 +67,9 @@ static inline Element user_header(const httplib::Request& req, const Account& ac
Element("img", {{"class", "user_page-profile"}, {"alt", "User profile picture"}, {"src", account.avatar}}, {}),
}),
Element("span", {
Element("b", {preprocess_html(req, account.emojis, account.display_name)}), " (", account.username, "@", account.domain_name, ")",
Element("b", {preprocess_html(req, account.emojis, account.display_name)}), " (", account.acct(), ")",
Element("br"),
Element("br"), Element("b", {"Joined: "}), std::string(created_at_str),
Element("br"), Element("b", {"Joined: "}), short_time(account.created_at),
Element("br"),
Element("b", {std::to_string(account.statuses_count)}), " Posts", " / ",
Element("b", {std::to_string(account.following_count)}), " Following", " / ",
@ -82,15 +78,15 @@ static inline Element user_header(const httplib::Request& req, const Account& ac
}),
Element("div", {{"class", "user_page-user_description"}}, {
Element("div", {{"class", "user_page-user_bio"}}, {preprocess_html(req, account.domain_name, account.emojis, account.note_html)}),
Element("div", {{"class", "user_page-user_bio"}}, {preprocess_html(req, account.server, account.emojis, account.note_html)}),
std::move(user_links),
}),
Element("nav", {{"class", "user_page-user_posts_nav"}}, {
sorting_method_link(req, account, sorting_method, PostSortingMethod::Posts),
sorting_method_link(req, account, sorting_method, PostSortingMethod::PostsAndReplies),
sorting_method_link(req, account, sorting_method, PostSortingMethod::Media),
sorting_method_link(req, server, account, sorting_method, PostSortingMethod::Posts),
sorting_method_link(req, server, account, sorting_method, PostSortingMethod::PostsAndReplies),
sorting_method_link(req, server, account, sorting_method, PostSortingMethod::Media),
}),
});
return header;
@ -101,27 +97,22 @@ static inline Element user_link_field(const httplib::Request& req, const Account
Element tr("tr", {
Element("th", {preprocess_html(req, account.emojis, field.name)}),
Element("td", {preprocess_html(req, account.domain_name, account.emojis, field.value_html)}),
Element("td", {preprocess_html(req, account.server, account.emojis, field.value_html)}),
});
if (field.verified_at >= 0) {
struct tm verified_at;
char verified_at_str[32];
gmtime_r(&field.verified_at, &verified_at);
strftime(verified_at_str, 32, "%Y-%m-%d %H:%M:%S %Z", &verified_at);
tr.attributes = {{"class", "verified"}, {"title", "Verified at "s + verified_at_str}};
tr.attributes = {{"class", "verified"}, {"title", "Verified at "s + full_time(field.verified_at)}};
}
return tr;
}
static inline Element sorting_method_link(const httplib::Request& req, const Account& account, PostSortingMethod current_method, PostSortingMethod new_method) {
static inline Element sorting_method_link(const httplib::Request& req, const std::string& server, const Account& account, PostSortingMethod current_method, PostSortingMethod new_method) {
const char* method_name = sorting_method_names[new_method];
if (current_method == new_method) {
return Element("b", {method_name});
} else {
return Element("a", {{"href", get_origin(req) + '/' + account.domain_name + "/@" + account.username + sorting_method_suffixes[new_method]}}, {
return Element("a", {{"href", get_origin(req) + '/' + server + "/@" + account.acct(false) + sorting_method_suffixes[new_method]}}, {
method_name,
});
}

View File

@ -6,6 +6,7 @@
#include "config.h"
#include "models.h"
#include "timeutils.h"
#include "servehelper.h"
#include "lxb_wrapper.h"
#include "routes/routes.h"
@ -163,6 +164,33 @@ bool should_send_304(const httplib::Request& req, uint64_t hash) {
return pos != std::string::npos && (pos == 0 || header[pos - 1] != '/');
}
Element serialize_post(const httplib::Request& req, const std::string& server, const Post& post) {
using namespace std::string_literals;
std::string time_title = post.edited_at < 0
? full_time(post.created_at)
: "Created: "s + full_time(post.created_at) + "\nEdited: " + full_time(post.edited_at);
const char* time_badge = post.edited_at < 0 ? "" : " (edited)";
Element div("div", {
Element("div", {{"class", "post-header"}}, {
Element("a", {{"href", get_origin(req) + '/' + server + "/@" + post.account.acct(false)}}, {
Element("img", {{"class", "post-avatar"}, {"alt", "User profile picture"}, {"src", post.account.avatar}}, {}),
Element("span", {
Element("b", {preprocess_html(req, post.account.emojis, post.account.display_name)}),
Element("br"), "@", post.account.acct(),
}),
}),
Element("a", {{"href", get_origin(req) + '/' + server + "/@" + post.account.acct(false) + '/' + post.id}, {"title", time_title}}, {
Element("time", {{"datetime", to_rfc3339(post.created_at)}}, {relative_time(post.created_at, current_time()), time_badge}),
}),
}),
preprocess_html(req, server, post.emojis, post.content),
});
return div;
}
blankie::html::HTMLString preprocess_html(const httplib::Request& req, const std::string& domain_name, const std::vector<Emoji>& emojis, const blankie::html::HTMLString& str) {
LXB::HTML::Document document(str.str);
preprocess_html(req, domain_name, emojis, document.body_element());
@ -195,6 +223,7 @@ static inline void preprocess_html(const httplib::Request& req, const std::strin
}
}
static std::regex mention_class_re("\\bmention\\b");
static inline void preprocess_link(const httplib::Request& req, const std::string& domain_name, lxb_dom_element_t* element) {
using namespace std::string_literals;
@ -205,8 +234,14 @@ static inline void preprocess_link(const httplib::Request& req, const std::strin
}
std::string href(reinterpret_cast<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) : "";
std::string instance_url_base = "https://"s + domain_name;
if (href.starts_with(instance_url_base + '/') || href == instance_url_base) {
// .mention is used in note and posts
// Instance base is used for link fields
if (std::regex_search(cls, mention_class_re) || href.starts_with(instance_url_base + '/') || href == instance_url_base) {
// Proxy this instance's URLs to Coyote
href = proxy_mastodon_url(req, std::move(href));
@ -290,7 +325,7 @@ static inline std::vector<lxb_dom_node_t*> emojify(lxb_dom_document_t* document,
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_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());

View File

@ -4,6 +4,7 @@
#include <httplib/httplib.h>
#include "blankie/serializer.h"
struct Post; // forward declaration from models.h
struct Emoji; // forward declaration from models.h
using Element = blankie::html::Element;
@ -19,5 +20,7 @@ std::string get_origin(const httplib::Request& req);
std::string proxy_mastodon_url(const httplib::Request& req, const std::string& url_str);
bool should_send_304(const httplib::Request& req, uint64_t hash);
Element serialize_post(const httplib::Request& req, const std::string& server, const Post& post);
blankie::html::HTMLString preprocess_html(const httplib::Request& req, const std::string& domain_name, const std::vector<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);

56
timeutils.cpp Normal file
View File

@ -0,0 +1,56 @@
#include <system_error>
#include "timeutils.h"
time_t current_time() {
struct timespec tp;
if (clock_gettime(CLOCK_REALTIME, &tp)) {
throw std::system_error(errno, std::generic_category(), "clock_gettime()");
}
return tp.tv_sec;
}
std::string short_time(time_t time) {
struct tm tm;
gmtime_r(&time, &tm);
char buf[16];
size_t len = strftime(buf, 16, "%Y-%m-%d", &tm);
return std::string(buf, len);
}
std::string full_time(time_t time) {
struct tm tm;
gmtime_r(&time, &tm);
char buf[32];
size_t len = strftime(buf, 32, "%Y-%m-%d %H:%M:%S GMT", &tm);
return std::string(buf, len);
}
std::string to_rfc3339(time_t time) {
struct tm tm;
gmtime_r(&time, &tm);
char buf[32];
size_t len = strftime(buf, 32, "%Y-%m-%dT%H:%M:%SZ", &tm);
return std::string(buf, len);
}
std::string relative_time(time_t from, time_t to) {
time_t diff = to - from;
if (diff < 60) {
return std::to_string(diff) + 's';
} else if (diff < 60 * 60) {
return std::to_string(diff / 60) + 'm';
} else if (diff < 60 * 60 * 60) {
return std::to_string(diff / (60 * 60)) + 'h';
} else if (diff < 24 * 60 * 60 * 60) {
return std::to_string(diff / (24 * 60 * 60)) + 'd';
} else {
return short_time(from);
}
}

11
timeutils.h Normal file
View File

@ -0,0 +1,11 @@
#pragma once
#include <ctime>
#include <string>
time_t current_time();
std::string short_time(time_t time);
std::string full_time(time_t time);
std::string to_rfc3339(time_t time);
std::string relative_time(time_t from, time_t to);