commit 74215a0b1d7f73abbad395ab43ea9758af1b3734 Author: blankie Date: Wed Nov 22 19:39:24 2023 +1100 Initial commit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..378eac2 --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +build diff --git a/.gitmodules b/.gitmodules new file mode 100644 index 0000000..6a59a3d --- /dev/null +++ b/.gitmodules @@ -0,0 +1,3 @@ +[submodule "thirdparty/httplib"] + path = thirdparty/httplib + url = https://github.com/yhirose/cpp-httplib.git diff --git a/CMakeLists.txt b/CMakeLists.txt new file mode 100644 index 0000000..42b78c5 --- /dev/null +++ b/CMakeLists.txt @@ -0,0 +1,40 @@ +cmake_minimum_required(VERSION 3.16) + +project(coyote C CXX) + + +find_package(nlohmann_json REQUIRED) +find_package(CURL REQUIRED) +set(HTTPLIB_REQUIRE_OPENSSL ON) +add_subdirectory(thirdparty/httplib) +#find_package(PkgConfig REQUIRED) +#pkg_check_modules(HIREDIS REQUIRED hiredis) + + +if (CMAKE_BUILD_TYPE MATCHES "Debug") + if (NOT FLAGS) + list(APPEND FLAGS -fsanitize=undefined,thread) + endif() + # https://sourceforge.net/p/valgrind/mailman/valgrind-users/thread/Ygze8PzaQAYWlKDj%40wildebeest.org/ + list(APPEND FLAGS -gdwarf-4) +endif() + +# https://t.me/NightShadowsHangout/670691 +list(APPEND FLAGS -Werror -Wall -Wextra -Wshadow -Wpedantic -Wno-gnu-anonymous-struct -fPIC -fno-rtti -Wconversion -Wno-unused-parameter -Wimplicit-fallthrough) + +# i have no idea why this hack wasn't needed before but it's needed if sanitizers are used +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 + blankie/serializer.cpp blankie/escape.cpp) +set_target_properties(${PROJECT_NAME} + PROPERTIES + CXX_STANDARD 20 + CXX_STANDARD_REQUIRED YES + CXX_EXTENSIONS NO +) +target_include_directories(${PROJECT_NAME} PRIVATE thirdparty ${HIREDIS_INCLUDE_DIRS}) +target_link_libraries(${PROJECT_NAME} PRIVATE nlohmann_json::nlohmann_json httplib::httplib CURL::libcurl ${HIREDIS_LINK_LIBRARIES}) +target_compile_options(${PROJECT_NAME} PRIVATE ${FLAGS}) diff --git a/blankie/escape.cpp b/blankie/escape.cpp new file mode 100644 index 0000000..b30b4c9 --- /dev/null +++ b/blankie/escape.cpp @@ -0,0 +1,42 @@ +#include + +#include "escape.h" + +static inline const char* get_replacement(char c); + +namespace blankie { +namespace html { + +std::string escape(const std::string& in) { + std::string out; + size_t pos = 0; + size_t last_pos = 0; + + out.reserve(in.size()); + while ((pos = in.find_first_of("&<>\"'", pos)) != std::string::npos) { + out.append(in, last_pos, pos - last_pos); + out.append(get_replacement(in[pos])); + pos++; + last_pos = pos; + } + + if (in.size() > last_pos) { + out.append(in, last_pos); + } + + return out; +} + +} // namespace html +} // namespace blankie + +static inline const char* get_replacement(char c) { + switch (c) { + case '&': return "&"; + case '<': return "<"; + case '>': return ">"; + case '"': return """; + case '\'': return "'"; + default: __builtin_unreachable(); + } +} diff --git a/blankie/escape.h b/blankie/escape.h new file mode 100644 index 0000000..4045588 --- /dev/null +++ b/blankie/escape.h @@ -0,0 +1,11 @@ +#pragma once + +#include + +namespace blankie { +namespace html { + +std::string escape(const std::string& in); + +} // namespace html +} // namespace blankie diff --git a/blankie/serializer.cpp b/blankie/serializer.cpp new file mode 100644 index 0000000..5a3d43d --- /dev/null +++ b/blankie/serializer.cpp @@ -0,0 +1,62 @@ +#include +#include + +#include "escape.h" +#include "serializer.h" + +static inline bool is_autoclosing_tag(const char* tag); + +namespace blankie { +namespace html { + +std::string Element::serialize() const { + std::string out; + + out += '<'; + out += this->tag; + for (const auto &[key, value] : this->attributes) { + out += ' '; + out += key; + if (!value.empty()) { + out += "=\""; + out += escape(value); + out += '"'; + } + } + out += '>'; + + if (is_autoclosing_tag(this->tag)) { + return out; + } + + for (const Node& node : this->nodes) { + if (const Element* element = std::get_if(&node)) { + out += element->serialize(); + } else if (const char* const* text = std::get_if(&node)) { + out += escape(*text); + } else if (const std::string* str = std::get_if(&node)) { + out += escape(*str); + } else if (const HTMLString* html_str = std::get_if(&node)) { + out += html_str->str; + } else { + throw std::runtime_error("Encountered unknown node"); + } + } + + out += "tag; + out += '>'; + + return out; +} + +} // namespace html +} // namespace blankie + +static inline bool is_autoclosing_tag(const char* tag) { + return !strncmp(tag, "link", 5) + || !strncmp(tag, "meta", 5) + || !strncmp(tag, "img", 4) + || !strncmp(tag, "br", 3) + || !strncmp(tag, "input", 6); +} diff --git a/blankie/serializer.h b/blankie/serializer.h new file mode 100644 index 0000000..36193ba --- /dev/null +++ b/blankie/serializer.h @@ -0,0 +1,40 @@ +#pragma once + +#include +#include +#include +#include + +namespace blankie { +namespace html { + +struct Element; +struct HTMLString { + HTMLString() = default; + explicit HTMLString(std::string str_) : str(std::move(str_)) {} + HTMLString& operator=(std::string str_) { + this->str = std::move(str_); + return *this; + } + + std::string str; +}; + +typedef std::pair Attribute; +typedef std::variant Node; + +struct Element { + const char* tag; + std::vector attributes; + std::vector nodes; + + Element(const char* tag_) : tag(tag_) {} + Element(const char* tag_, std::vector nodes_) + : tag(tag_), nodes(std::move(nodes_)) {} + Element(const char* tag_, std::vector attributes_, std::vector nodes_) + : tag(tag_), attributes(std::move(attributes_)), nodes(std::move(nodes_)) {} + std::string serialize() const; +}; + +} // namespace html +} // namespace blankie diff --git a/client.cpp b/client.cpp new file mode 100644 index 0000000..1c9c81f --- /dev/null +++ b/client.cpp @@ -0,0 +1,159 @@ +#include +#include +#include + +#include "client.h" +#include "models.h" + +MastodonClient mastodon_client; + +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); + setopt(this->_share, CURLSHOPT_SHARE, CURL_LOCK_DATA_CONNECT); + setopt(this->_share, CURLSHOPT_SHARE, CURL_LOCK_DATA_PSL); + setopt(this->_share, CURLSHOPT_SHARE, CURL_LOCK_DATA_HSTS); + + 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(const std::string& host, const std::string& username) { + using namespace std::string_literals; + + try { + std::string resp = this->_send_request("https://"s + host + "/api/v1/accounts/lookup?acct=" + username); + return nlohmann::json::parse(std::move(resp)); + } catch (const CurlException& e) { + if (e.code != CURLE_HTTP_RETURNED_ERROR || this->_response_status_code() != 404) { + throw; + } + + return std::nullopt; + } +} + +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_SHARE, this->_share); + setopt(curl, CURLOPT_FAILONERROR, 1L); + } 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; +} + +std::string MastodonClient::_send_request(const std::string& url) { + 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); + } + + return res; +} + +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 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; +} diff --git a/client.h b/client.h new file mode 100644 index 0000000..0580df5 --- /dev/null +++ b/client.h @@ -0,0 +1,65 @@ +#pragma once + +#include +#include +#include +#include + +#include +struct Account; // forward declaration from models.h + +class CurlException : public std::exception { +public: + CurlException(CURLcode code_) : code(code_) {} + + const char* what() const noexcept { + return curl_easy_strerror(this->code); + } + + CURLcode code; +}; + +class CurlShareException : public std::exception { +public: + CurlShareException(CURLSHcode code_) : code(code_) {} + + const char* what() const noexcept { + return curl_share_strerror(this->code); + } + + CURLSHcode code; +}; + +class MastodonClient { +public: + MastodonClient(const MastodonClient&&) = delete; + MastodonClient& operator=(const MastodonClient&&) = delete; + + MastodonClient(); + ~MastodonClient(); + + static void init() { + CURLcode code = curl_global_init(CURL_GLOBAL_ALL); + if (code) { + throw CurlException(code); + } + } + + static void cleanup() { + curl_global_cleanup(); + } + + std::optional get_account_by_username(const std::string& host, const std::string& username); + +private: + CURL* _get_easy(); + std::string _send_request(const std::string& url); + long _response_status_code(); + + std::mutex _share_locks[CURL_LOCK_DATA_LAST]; + + CURLSH* _share = nullptr; + pthread_key_t _easy_key; +}; + +extern MastodonClient mastodon_client; diff --git a/config.cpp b/config.cpp new file mode 100644 index 0000000..abd8370 --- /dev/null +++ b/config.cpp @@ -0,0 +1,56 @@ +#include +#include + +#include "file.h" +#include "config.h" + +Config config; + +void load_config(const char* path) { + File config_file(path, "r"); + config = nlohmann::json::parse(config_file.get()); +} + +RedisConfig get_redis_config(const nlohmann::json& j) { + using namespace std::string_literals; + + RedisConfig redis_config; + const nlohmann::json& connection = j.at("connection"); + const std::string& connection_type = connection.at("type").get_ref(); + if (connection_type == "ip") { + redis_config.connection_method = IPConnection{ + connection.at("address").get(), + connection.at("port").get() + }; + } else if (connection_type == "unix") { + redis_config.connection_method = UnixConnection{connection.at("unix").get()}; + } else { + throw std::invalid_argument("Unknown redis connection type: "s + connection_type); + } + + if (j.at("username").is_string()) { + redis_config.username = j["username"].get(); + } + if (j.at("password").is_string()) { + redis_config.password = j["password"].get(); + } + + return redis_config; +} + +void from_json(const nlohmann::json& j, Config& conf) { + using namespace std::string_literals; + + j.at("bind_host").get_to(conf.bind_host); + j.at("bind_port").get_to(conf.bind_port); + if (conf.bind_port < 0) { + throw std::invalid_argument("Invalid port to bind to: "s + std::to_string(conf.bind_port)); + } + + if (j.at("canonical_origin").is_string()) { + conf.canonical_origin = j["canonical_origin"].get(); + } + if (j.at("redis").at("enabled").get()) { + conf.redis_config = get_redis_config(j["redis"]); + } +} diff --git a/config.h b/config.h new file mode 100644 index 0000000..88d55d5 --- /dev/null +++ b/config.h @@ -0,0 +1,32 @@ +#pragma once + +#include +#include +#include +#include + +struct IPConnection { + std::string address; + int port; +}; +struct UnixConnection { + std::string unix; +}; + +struct RedisConfig { + std::variant connection_method; + std::optional username; + std::optional password; +}; + +struct Config { + std::string bind_host = "127.0.0.1"; + int bind_port = 8080; + std::optional canonical_origin; + std::optional redis_config; +}; + +extern Config config; + +void load_config(const char* path); +void from_json(const nlohmann::json& j, Config& conf); diff --git a/example_config.json b/example_config.json new file mode 100644 index 0000000..2da2e00 --- /dev/null +++ b/example_config.json @@ -0,0 +1,16 @@ +{ + "bind_host": "127.0.0.1", + "bind_port": 8080, + "canonical_origin": null, + "redis": { + "enabled": true, + "connection": { + "type": "ip", + "address": "127.0.0.1", + "port": 6379, + "unix": "/path/to/redis.sock" + }, + "username": null, + "password": "one of the passwords of all time" + } +} diff --git a/file.h b/file.h new file mode 100644 index 0000000..daf43e4 --- /dev/null +++ b/file.h @@ -0,0 +1,61 @@ +#pragma once + +#include +#include + +static void try_close(FILE* file); + +class File { +public: + // https://stackoverflow.com/a/2173764 + File(const File&) = delete; + File& operator=(const File&) = delete; + + inline File(File&& other) { + try_close(this->_file); + this->_file = other._file; + other._file = nullptr; + } + inline File& operator=(File&& other) { + if (this == &other) { + return *this; + } + + try_close(this->_file); + this->_file = other._file; + other._file = nullptr; + + return *this; + } + + File(const char* __restrict path, const char* __restrict mode) { + this->_file = fopen(path, mode); + if (!this->_file) { + throw std::system_error(errno, std::generic_category(), "fopen()"); + } + } + ~File() { + try_close(this->_file); + } + + void write(const char* data, size_t length) { + if (fwrite(data, 1, length, this->_file) != length) { + throw std::system_error(errno, std::generic_category(), "fwrite()"); + } + } + inline void write(const std::string& str) { + this->write(str.data(), str.size()); + } + inline constexpr FILE* get() const noexcept { + return this->_file; + } + +protected: + FILE* _file = nullptr; +}; + +static void try_close(FILE* file) { + if (file && fclose(file)) { + perror("fclose()"); + } +} diff --git a/hiredis_wrapper.cpp b/hiredis_wrapper.cpp new file mode 100644 index 0000000..dfa94ec --- /dev/null +++ b/hiredis_wrapper.cpp @@ -0,0 +1,128 @@ +#include "hiredis_wrapper.h" + +Redis::Redis(const std::string& address, int port) { + this->_context = redisConnect(address.c_str(), port); + if (!this->_context) { + throw std::bad_alloc(); + } + if (this->_context->err) { + RedisException e(this->_context->errstr); + redisFree(this->_context); + throw e; + } +} +Redis::Redis(const std::string& unix) { + this->_context = redisConnectUnix(unix.c_str()); + if (!this->_context) { + throw std::bad_alloc(); + } + if (this->_context->err) { + RedisException e(this->_context->errstr); + redisFree(this->_context); + throw e; + } +} +Redis::~Redis() { + redisFree(this->_context); +} + +void Redis::auth(const std::string& username, const std::string& password) { + RedisReply reply = this->command("AUTH %s %s", username.c_str(), password.c_str()); + if (reply->type == REDIS_REPLY_STATUS) { + // good :D + } else { + throw std::runtime_error("AUTH gave an unexpected return type"); + } +} +void Redis::auth(const std::string& password) { + RedisReply reply = this->command("AUTH %s", password.c_str()); + if (reply->type == REDIS_REPLY_STATUS) { + // good :D + } else { + throw std::runtime_error("AUTH gave an unexpected return type"); + } +} + +time_t Redis::ttl(const std::string& key) { + RedisReply reply = this->command("TTL %s", key.c_str()); + if (reply->type == REDIS_REPLY_INTEGER) { + return reply->integer; + } else { + throw std::runtime_error("TTL gave an unexpected return type"); + } +} + +bool Redis::expire(const std::string& key, time_t expiry) { + RedisReply reply = this->command("EXPIRE %s %d", key.c_str(), expiry); + + if (reply->type == REDIS_REPLY_INTEGER) { + return reply->integer == 1; + } else { + throw std::runtime_error("EXPIRE gave an unexpected return type"); + } +} + +bool Redis::expire_nx(const std::string& key, time_t expiry) { + if (this->_fake_expire_nx) { + time_t current_expiry = this->ttl(key); + if (current_expiry < 0) { + return this->expire(key, expiry); + } + return false; + } + + RedisReply reply(nullptr, freeReplyObject); + try{ + reply = this->command("EXPIRE %s %d NX", key.c_str(), expiry); + } catch (const RedisException& e) { + if (e.error == "ERR wrong number of arguments for 'expire' command") { + this->_fake_expire_nx = true; + return this->expire_nx(key, expiry); + } + throw; + } + + if (reply->type == REDIS_REPLY_INTEGER) { + return reply->integer == 1; + } else { + throw std::runtime_error("EXPIRE NX gave an unexpected return type"); + } +} + +std::optional Redis::get(const std::string& key) { + RedisReply reply = this->command("GET %s", key.c_str()); + if (reply->type == REDIS_REPLY_STRING) { + return std::string(reply->str, reply->len); + } else if (reply->type == REDIS_REPLY_NIL) { + return std::nullopt; + } else { + throw std::runtime_error("GET gave an unexpected return type"); + } +} +void Redis::set(const std::string& key, const std::string& value, time_t expiry) { + RedisReply reply = this->command("SET %s %s EX %d", key.c_str(), value.c_str(), expiry); + if (reply->type == REDIS_REPLY_STATUS) { + // good :D + } else { + throw std::runtime_error("SET gave an unexpected return type"); + } +} + +std::optional Redis::hget(const std::string& key, const std::string& field) { + RedisReply reply = this->command("HGET %s %s", key.c_str(), field.c_str()); + if (reply->type == REDIS_REPLY_STRING) { + return std::string(reply->str, reply->len); + } else if (reply->type == REDIS_REPLY_NIL) { + return std::nullopt; + } else { + throw std::runtime_error("HGET gave an unexpected return type"); + } +} +void Redis::hset(const std::string& key, const std::string& field, const std::string& value) { + RedisReply reply = this->command("HSET %s %s %s", key.c_str(), field.c_str(), value.c_str()); + if (reply->type == REDIS_REPLY_INTEGER) { + // good :D + } else { + throw std::runtime_error("SET gave an unexpected return type"); + } +} diff --git a/hiredis_wrapper.h b/hiredis_wrapper.h new file mode 100644 index 0000000..8f3473b --- /dev/null +++ b/hiredis_wrapper.h @@ -0,0 +1,73 @@ +#pragma once + +#include +#include +#include +#include +#include + +#pragma GCC diagnostic push +#pragma GCC diagnostic ignored "-Wpragmas" +#pragma GCC diagnostic ignored "-Wc99-extensions" +#pragma GCC diagnostic ignored "-Wconversion" +#pragma GCC diagnostic ignored "-Wpedantic" +#include +#pragma GCC diagnostic pop + +class RedisException : public std::exception { +public: + RedisException(const char* error_) : error(error_) {} + RedisException(const char* error_, size_t length) : error(error_, length) {} + + const char* what() const noexcept { + return this->error.c_str(); + } + + std::string error; +}; + +using RedisReply = std::unique_ptr; + +class Redis { +public: + Redis(const Redis&) = delete; + Redis operator=(const Redis&) = delete; + + Redis(const std::string& address, int port); + Redis(const std::string& unix); + ~Redis(); + + template + RedisReply command(const char* format, Args... args) { + std::lock_guard guard(this->_mutex); + redisReply* raw_reply = static_cast(redisCommand(this->_context, format, args...)); + if (!raw_reply) { + throw RedisException(this->_context->errstr); + } + + RedisReply reply(raw_reply, freeReplyObject); + if (reply->type == REDIS_REPLY_ERROR) { + throw RedisException(reply->str, reply->len); + } + return reply; + } + + void auth(const std::string& username, const std::string& password); + void auth(const std::string& password); + + time_t ttl(const std::string& key); + bool expire(const std::string& key, time_t expiry); + bool expire_nx(const std::string& key, time_t expiry); + + std::optional get(const std::string& key); + void set(const std::string& key, const std::string& value, time_t expiry); + + std::optional hget(const std::string& key, const std::string& field); + void hset(const std::string& key, const std::string& field, const std::string& value); + +private: + redisContext* _context; + std::mutex _mutex; + + bool _fake_expire_nx = false; +}; diff --git a/main.cpp b/main.cpp new file mode 100644 index 0000000..195949f --- /dev/null +++ b/main.cpp @@ -0,0 +1,92 @@ +#include +#include + +#include "config.h" +#include "client.h" +#include "servehelper.h" +#include "routes/routes.h" + +#define DOMAIN_RE "(?:[a-zA-Z0-9-]+\\.)+[a-zA-Z]{2,}" +// https://docs.joinmastodon.org/methods/accounts/#422-unprocessable-entity +#define USERNAME_RE "[a-zA-Z0-9_]+" +#define ACCT_RE USERNAME_RE "(?:@" DOMAIN_RE ")?" + +int main(int argc, char** argv) { + if (argc != 2) { + fprintf(stderr, "Usage: %s \n", argc > 0 ? argv[0] : "coyote"); + return 1; + } + + if (setenv("TZ", "UTC", true)) { + perror("Failed to set TZ=UTC: setenv()"); + return 1; + } + + try { + load_config(argv[1]); + } catch (const std::exception& e) { + fprintf(stderr, "Failed to load config: %s\n", e.what()); + return 1; + } + + MastodonClient::init(); + atexit(MastodonClient::cleanup); + + httplib::Server server; + +// server.Get("/", [&](const httplib::Request& req, httplib::Response& res) { +// home_route(req, res, config); +// }); + server.Get("/style.css", css_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) { + 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"); + }); + server.Get("/debug/exception/unknown", [](const httplib::Request& req, httplib::Response& res) { + throw "cope"; + }); +#endif + server.Get(".*", [&](const httplib::Request& req, httplib::Response& res) { + res.status = 404; + serve_error(req, res, "404: Page not found"); + }); + server.set_exception_handler([&](const httplib::Request& req, httplib::Response& res, std::exception_ptr ep) { + res.status = 500; + try { + std::rethrow_exception(ep); + } catch (const std::exception& e) { + fprintf(stderr, "Exception thrown on %s: %s\n", req.path.c_str(), e.what()); + serve_error(req, res, "500: Internal server error", std::nullopt, e.what()); + } catch (...) { + fprintf(stderr, "Exception thrown on %s: Unknown exception\n", req.path.c_str()); + serve_error(req, res, "500: Internal server error", std::nullopt, "Unknown exception"); + } + }); + + if (config.bind_port != 0) { + if (!server.bind_to_port(config.bind_host, config.bind_port)) { + fprintf(stderr, "Failed to bind to %s:%d\n", config.bind_host.c_str(), config.bind_port); + return 1; + } + } else { + int port = server.bind_to_any_port(config.bind_host); + if (port == -1) { + fprintf(stderr, "Failed to bind to %s:\n", config.bind_host.c_str()); + return 1; + } + config.bind_port = port; + } + printf("Listening on %s:%d...\n", config.bind_host.c_str(), config.bind_port); + + if (!server.listen_after_bind()) { + fprintf(stderr, "Failed to listen on %s:%d\n", config.bind_host.c_str(), config.bind_port); + return 1; + } +} diff --git a/models.cpp b/models.cpp new file mode 100644 index 0000000..698aa81 --- /dev/null +++ b/models.cpp @@ -0,0 +1,81 @@ +#include +#include +#include +#include +#include + +#include "models.h" +#include "numberhelper.h" + +using json = nlohmann::json; +static time_t parse_rfc3339(const std::string& str); + + +void from_json(const json& j, Emoji& emoji) { + j.at("shortcode").get_to(emoji.shortcode); + j.at("url").get_to(emoji.url); +} + +void from_json(const json& j, AccountField& field) { + j.at("name").get_to(field.name); + field.value_html = j.at("value").get(); + if (j.at("verified_at").is_null()) { + field.verified_at = -1; + } else { + field.verified_at = parse_rfc3339(j["verified_at"].get_ref()); + } +} + +static std::regex host_regex(R"EOF(https?://([a-z0-9-.]+)/.+)EOF", std::regex::ECMAScript | std::regex::icase); +void from_json(const json& j, Account& account) { + using namespace std::string_literals; + + j.at("id").get_to(account.id); + j.at("username").get_to(account.username); + j.at("display_name").get_to(account.display_name); + account.created_at = parse_rfc3339(j.at("created_at").get_ref()); + account.note_html = j.at("note").get(); + j.at("avatar").get_to(account.avatar); + j.at("header").get_to(account.header); + j.at("followers_count").get_to(account.followers_count); + j.at("following_count").get_to(account.following_count); + j.at("statuses_count").get_to(account.statuses_count); + j.at("emojis").get_to(account.emojis); + j.at("fields").get_to(account.fields); + + std::smatch sm; + const std::string& url = j.at("url").get_ref(); + 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); +} + + +static std::regex rfc3339_re(R"EOF((\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2}):(\d{2})(?:\.\d+)?(?:(Z)|([+-]\d{2}):(\d{2})))EOF", std::regex::ECMAScript | std::regex::icase); +time_t parse_rfc3339(const std::string& str) { + using namespace std::string_literals; + + std::smatch sm; + if (!std::regex_match(str, sm, rfc3339_re)) { + throw std::invalid_argument("unknown date format: "s + str); + } + + struct tm tm = { + .tm_sec = to_int(sm.str(6)), + .tm_min = to_int(sm.str(5)), + .tm_hour = to_int(sm.str(4)), + + .tm_mday = to_int(sm.str(3)), + .tm_mon = to_int(sm.str(2)) - 1, + .tm_year = to_int(sm.str(1)) - 1900, + + .tm_isdst = -1, + .tm_gmtoff = !sm.str(7).empty() ? 0 : to_int(sm.str(8)) * 60 * 60 + to_int(sm.str(9)) * 60, + }; + time_t time = mktime(&tm); + if (time == -1) { + throw std::system_error(errno, std::generic_category(), "mktime()"); + } + return time; +} diff --git a/models.h b/models.h new file mode 100644 index 0000000..a71b1eb --- /dev/null +++ b/models.h @@ -0,0 +1,43 @@ +#pragma once + +#include +#include +#include + +#include "blankie/serializer.h" + +enum PostSortingMethod { + Posts = 0, + PostsAndReplies, + Media, +}; + +struct Emoji { + std::string shortcode; + std::string url; +}; + +struct AccountField { + std::string name; + blankie::html::HTMLString value_html; + time_t verified_at; // negative is not verified +}; +struct Account { + std::string id; + std::string username; + std::string domain_name; + std::string display_name; + time_t created_at; + blankie::html::HTMLString note_html; + std::string avatar; + std::string header; + uint64_t followers_count; + uint64_t following_count; + uint64_t statuses_count; + std::vector emojis; + std::vector fields; +}; + +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); diff --git a/numberhelper.cpp b/numberhelper.cpp new file mode 100644 index 0000000..0e10b26 --- /dev/null +++ b/numberhelper.cpp @@ -0,0 +1,31 @@ +#include +#include + +#include "numberhelper.h" + +unsigned long long to_ull(const std::string& str) { + char* endptr; + + errno = 0; + unsigned long long ret = strtoull(str.c_str(), &endptr, 10); + if (ret > ULLONG_MAX && errno == ERANGE) { + throw std::overflow_error(str + " is too big"); + } else if (endptr[0] != '\0') { + throw std::invalid_argument(str + " has trailing text"); + } + return ret; +} + +int to_int(const std::string& str) { + char* endptr; + + long ret = strtol(str.c_str(), &endptr, 10); + if (ret > INT_MAX) { + throw std::overflow_error(str + " is too big"); + } else if (ret < INT_MIN) { + throw std::underflow_error(str + " is too small"); + } else if (endptr[0] != '\0') { + throw std::invalid_argument(str + " has trailing text"); + } + return static_cast(ret); +} diff --git a/numberhelper.h b/numberhelper.h new file mode 100644 index 0000000..9c153a1 --- /dev/null +++ b/numberhelper.h @@ -0,0 +1,7 @@ +#pragma once + +#include +#include + +unsigned long long to_ull(const std::string& str); +int to_int(const std::string& str); diff --git a/routes/css.cpp b/routes/css.cpp new file mode 100644 index 0000000..55f3b23 --- /dev/null +++ b/routes/css.cpp @@ -0,0 +1,142 @@ +#include +#include + +#include "routes.h" +#include "../servehelper.h" + +static const constexpr char css[] = R"EOF( +:root { + --bg-color: black; + --fg-color: white; + --font-size: 13pt; + + --error-background-color: rgb(100, 0, 0); + --error-border-color: red; + --error-text-color: white; + + --accent-color: #962AC3; + --bright-accent-color: #DE6DE6; +} + +* { + margin: 0; + padding: 0; +} +html { + background-color: var(--bg-color); + color: var(--fg-color); + font-size: var(--font-size); + font-family: sans-serif; + padding: 10px; +} +p { + margin-top: 1em; + margin-bottom: 1em; +} + +a { + color: var(--accent-color); +} +a:hover { + color: var(--bright-accent-color); +} + +/* ERROR PAGE */ +.error { + text-align: center; + background-color: var(--error-background-color); + color: var(--error-text-color); + border-style: solid; + border-color: var(--error-border-color); +} +.error * { + margin: revert; + padding: revert; +} + +/* USER PAGE */ +.user_page-header { + width: 100%; + max-height: 15em; + object-fit: cover; +} + +.user_page-user_pfp { + display: flex; +} +.user_page-user_pfp img { + width: 7.5em; + height: 7.5em; +} +.user_page-user_pfp span { + margin-top: auto; + margin-bottom: auto; + margin-left: 0.5em; +} + +/* https://stackoverflow.com/a/7354648 */ +@media (min-width: 801px) { + .user_page-user_description { + display: grid; + grid-template-columns: 1fr 1fr; + } +} + +.user_page-user_bio { + line-height: 1.25; +} + +.user_page-user_links { + height: fit-content; + width: fit-content; + text-align: left; + margin-top: 1em; + margin-bottom: 1em; + /* https://stackoverflow.com/a/5680823 */ + border-collapse: collapse; +} +.user_page-user_links td { + padding-left: 1em; +} +.user_page-user_links .verified { + background-color: rgba(0, 150, 0, 0.3); +} +.user_page-user_links .verified td::after { + content: " ✓"; + color: rgb(100, 255, 100); +} + +.user_page-user_posts_nav { + display: grid; + grid-template-columns: 1fr 1fr 1fr; + text-align: center; +} +.user_page-user_posts_nav a { + text-decoration: none; +} +)EOF"; +// one for \0, one for trailing newline +#define CSS_LEN sizeof(css) / sizeof(css[0]) - 2 + +#if __cpp_constinit + #define CONSTINIT constinit +#else + #define CONSTINIT +#endif + +CONSTINIT const uint64_t css_hash = FastHash(css, CSS_LEN, 0); + +void css_route(const httplib::Request& req, httplib::Response& res) { + using namespace std::string_literals; + + res.set_header("ETag", "\""s + std::to_string(css_hash) + '"'); + res.set_header("Cache-Control", "max-age=31536000, immutable"); + + if (should_send_304(req, css_hash)) { + res.status = 304; + res.set_header("Content-Length", std::to_string(CSS_LEN)); + res.set_header("Content-Type", "text/css"); + } else { + res.set_content(css, CSS_LEN, "text/css"); + } +} diff --git a/routes/routes.h b/routes/routes.h new file mode 100644 index 0000000..d3efe3c --- /dev/null +++ b/routes/routes.h @@ -0,0 +1,8 @@ +#pragma once + +#include + +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); diff --git a/routes/user.cpp b/routes/user.cpp new file mode 100644 index 0000000..157b95c --- /dev/null +++ b/routes/user.cpp @@ -0,0 +1,129 @@ +#include "routes.h" +#include "../servehelper.h" +#include "../client.h" +#include "../models.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 Account& account, PostSortingMethod sorting_method); +static inline Element user_link_field(const AccountField& field); +static inline Element sorting_method_link(const Account& account, PostSortingMethod current_method, PostSortingMethod new_method); + + +void user_route(const httplib::Request& req, httplib::Response& res) { + std::string server = req.matches.str(1); + std::string username = req.matches.str(2); + PostSortingMethod sorting_method = get_sorting_method(req.matches.str(3)); + + std::optional account; + try { + account = mastodon_client.get_account_by_username(server, username); + } catch (const std::exception& e) { + res.status = 500; + serve_error(req, res, "500: Internal server error", "Failed to fetch user information", e.what()); + return; + } + + if (!account) { + res.status = 404; + serve_error(req, res, "404: User not found"); + return; + } + + Element body("body", { + user_header(*account, sorting_method), + }); + serve(req, res, account->display_name + " (" + account->username + '@' + account->domain_name + ')', std::move(body)); +} + + +static inline PostSortingMethod get_sorting_method(const std::string& method) { + for (size_t i = 0; i < sizeof(sorting_method_suffixes) / sizeof(sorting_method_suffixes[0]); i++) { + if (method == sorting_method_suffixes[i]) { + return static_cast(i); + } + } + + __builtin_unreachable(); +} + + +static inline Element user_header(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); + + Element user_links("table", {{"class", "user_page-user_links"}}, {}); + user_links.nodes.reserve(account.fields.size()); + for (const AccountField& i : account.fields) { + user_links.nodes.push_back(user_link_field(i)); + } + + Element header("header", { + Element("a", {{"href", account.header}}, { + Element("img", {{"class", "user_page-header"}, {"alt", "User header"}, {"src", account.header}}, {}), + }), + Element("div", {{"class", "user_page-user_pfp"}}, { + Element("a", {{"href", account.avatar}}, { + Element("img", {{"alt", "User profile picture"}, {"src", account.avatar}}, {}), + }), + Element("span", { + Element("b", {account.display_name}), " (", account.username, "@", account.domain_name, ")", + Element("br"), + Element("br"), Element("b", {"Joined: "}), std::string(created_at_str), + Element("br"), + Element("b", {std::to_string(account.statuses_count)}), " Posts", " / ", + Element("b", {std::to_string(account.following_count)}), " Following", " / ", + Element("b", {std::to_string(account.followers_count)}), " Followers", + }), + }), + + Element("div", {{"class", "user_page-user_description"}}, { + Element("div", {{"class", "user_page-user_bio"}}, {account.note_html}), + + std::move(user_links), + }), + + Element("nav", {{"class", "user_page-user_posts_nav"}}, { + sorting_method_link(account, sorting_method, PostSortingMethod::Posts), + sorting_method_link(account, sorting_method, PostSortingMethod::PostsAndReplies), + sorting_method_link(account, sorting_method, PostSortingMethod::Media), + }), + }); + return header; +} + +static inline Element user_link_field(const AccountField& field) { + using namespace std::string_literals; + + Element tr("tr", { + Element("th", {field.name}), + Element("td", {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}}; + } + + return tr; +} + +static inline Element sorting_method_link(const Account& account, PostSortingMethod current_method, PostSortingMethod new_method) { + using namespace std::string_literals; + const char* method_name = sorting_method_names[new_method]; + + if (current_method == new_method) { + return Element("b", {method_name}); + } else { + return Element("a", {{"href", "/"s + account.domain_name + "/@" + account.username + sorting_method_suffixes[new_method]}}, { + method_name, + }); + } +} diff --git a/servehelper.cpp b/servehelper.cpp new file mode 100644 index 0000000..0d9d02a --- /dev/null +++ b/servehelper.cpp @@ -0,0 +1,155 @@ +#include +#include +#include +#include +#include + +#include "config.h" +#include "servehelper.h" +#include "routes/routes.h" + +class CurlUrlException : public std::exception { +public: + CurlUrlException(CURLUcode code_) : code(code_) {} + + const char* what() const noexcept { + return curl_url_strerror(this->code); + } + + CURLUcode code; +}; + + +void serve(const httplib::Request& req, httplib::Response& res, std::string title, Element element, Nodes extra_head) { + using namespace std::string_literals; + + std::string css_url = get_origin(req) + "/style.css"; + res.set_header("Content-Security-Policy", "default-src 'none'; img-src https:; media-src: https:; style-src "s + css_url); + + Element head("head", { + Element("meta", {{"charset", "utf-8"}}, {}), + Element("title", {std::move(title)}), + Element("link", {{"rel", "stylesheet"}, {"href", std::move(css_url) + "?v=" + std::to_string(css_hash)}}, {}), + Element("meta", {{"name", "viewport"}, {"content", "width=device-width,initial-scale=1"}}, {}) + }); + head.nodes.reserve(head.nodes.size() + extra_head.size()); + head.nodes.insert(head.nodes.end(), extra_head.begin(), extra_head.end()); + std::string html = ""s + Element("html", { + std::move(head), + std::move(element) + }).serialize(); + + uint64_t hash = FastHash(html.data(), html.size(), 0); + res.set_header("ETag", std::string(1, '"') + std::to_string(hash) + '"'); + + if (should_send_304(req, hash)) { + res.status = 304; + res.set_header("Content-Length", std::to_string(html.size())); + res.set_header("Content-Type", "text/html"); + } else { + res.set_content(std::move(html), "text/html"); + } +} + +void serve_error(const httplib::Request& req, httplib::Response& res, + std::string title, std::optional subtitle, std::optional info) { + + Element error_div("div", {{"class", "error"}}, { + Element("h2", {title}) + }); + if (subtitle) { + error_div.nodes.push_back(Element("p", { + std::move(*subtitle) + })); + } + if (info) { + error_div.nodes.push_back(Element("pre", { + Element("code", {std::move(*info)}) + })); + } + + Element body("body", {std::move(error_div)}); + serve(req, res, std::move(title), std::move(body)); +} + +void serve_redirect(const httplib::Request& req, httplib::Response& res, std::string url, bool permanent) { + using namespace std::string_literals; + + Element body("body", { + "Redirecting to ", + Element("a", {{"href", url}}, {url}), + "…" + }); + res.set_redirect(url, permanent ? 301 : 302); + serve(req, res, "Redirecting to "s + std::move(url) + "…", std::move(body)); +} + + + +std::string get_origin(const httplib::Request& req) { + if (req.has_header("X-Canonical-Origin")) { + return req.get_header_value("X-Canonical-Origin"); + } + if (config.canonical_origin) { + return *config.canonical_origin; + } + + std::string origin = "http://"; + if (req.has_header("Host")) { + origin += req.get_header_value("Host"); + } else { + origin += config.bind_host; + if (config.bind_port != 80) { + origin += ':' + std::to_string(config.bind_port); + } + } + return origin; +} + +std::string proxy_mastodon_url(const httplib::Request& req, const std::string& url_str) { + using CurlStr = std::unique_ptr; + + std::unique_ptr url(curl_url(), curl_url_cleanup); + if (!url) { + throw std::bad_alloc(); + } + + CURLUcode code = curl_url_set(url.get(), CURLUPART_URL, url_str.c_str(), 0); + if (code) { + throw CurlUrlException(code); + } + + auto get_part = [&](CURLUPart part) { + char* content; + CURLUcode code = curl_url_get(url.get(), part, &content, 0); + if (code) { + 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); + CurlStr fragment = get_part(CURLUPART_FRAGMENT); + + std::string new_url = get_origin(req) + '/' + host.get() + path.get(); + if (query) { + new_url += '?'; + new_url += query.get(); + } + if (fragment) { + new_url += '#'; + new_url += fragment.get(); + } + return new_url; +} + +bool should_send_304(const httplib::Request& req, uint64_t hash) { + std::string header = req.get_header_value("If-None-Match"); + if (header == "*") { + return true; + } + + size_t pos = header.find(std::string(1, '"') + std::to_string(hash) + '"'); + return pos != std::string::npos && (pos == 0 || header[pos - 1] != '/'); +} diff --git a/servehelper.h b/servehelper.h new file mode 100644 index 0000000..9b57d6c --- /dev/null +++ b/servehelper.h @@ -0,0 +1,19 @@ +#pragma once + +#include + +#include +#include "blankie/serializer.h" + +using Element = blankie::html::Element; +using Node = blankie::html::Node; +using Nodes = std::vector; + +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, + std::string title, std::optional subtitle = std::nullopt, std::optional info = std::nullopt); +void serve_redirect(const httplib::Request& req, httplib::Response& res, std::string url, bool permanent = false); + +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); diff --git a/thirdparty/FastHash.h b/thirdparty/FastHash.h new file mode 100644 index 0000000..8902f21 --- /dev/null +++ b/thirdparty/FastHash.h @@ -0,0 +1,79 @@ +// https://github.com/KoneLinx/small_ECS/blob/master/FastHash.h +// but slightly modified to abandon ranges + +// Copyright(C) 2021 Kobe Vrijsen +// +// A simple ECS example +// +// This file is free software and distributed under the terms of the European Union +// Public Lincense as published by the European Commision; either version 1.2 of the +// License, or , at your option, any later version. + +/* The MIT License + + Copyright (C) 2012 Zilong Tan (eric.zltan@gmail.com) + + Permission is hereby granted, free of charge, to any person + obtaining a copy of this software and associated documentation + files (the "Software"), to deal in the Software without + restriction, including without limitation the rights to use, copy, + modify, merge, publish, distribute, sublicense, and/or sell copies + of the Software, and to permit persons to whom the Software is + furnished to do so, subject to the following conditions: + + The above copyright notice and this permission notice shall be + included in all copies or substantial portions of the Software. + + THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, + EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF + MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND + NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS + BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN + ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN + CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + SOFTWARE. +*/ + +// Source: fast-hash https://github.com/ztanml/fast-hash (26 Nov 2021) +// Changes: General uplift and providing a general constant hash for ranges + +#include +#include + +#pragma once + +// Compression function for Merkle-Damgard construction. +// This function is generated using the framework provided. +//#define mix(h) ({ +// (h) ^= (h) >> 23; +// (h) *= 0x2127599bf4325c37ULL; +// (h) ^= (h) >> 47; }) + +constexpr uint64_t FastHash(const char* str, std::size_t size, uint64_t seed/*, uint64_t back = {}*/) +{ + auto const mix = [](uint64_t h) + { + h ^= h >> 23; + h *= 0x2127599bf4325c37ULL; + h ^= h >> 47; + return h; + }; + + const uint64_t m = 0x880355f21e6d1965ULL; + + uint64_t h = seed ^ (size * m); + + for (std::size_t i = 0; i < size; i++) + { + h ^= mix(static_cast(str[i])); + h *= m; + } + + //if (back != uint64_t()) + //{ + // h ^= mix(back); + // h *= m; + //} + + return mix(h); +} diff --git a/thirdparty/httplib b/thirdparty/httplib new file mode 160000 index 0000000..5ef4cfd --- /dev/null +++ b/thirdparty/httplib @@ -0,0 +1 @@ +Subproject commit 5ef4cfd263309458afe27246d66f370e3b351faa