Initial commit

This commit is contained in:
blankie 2023-11-22 19:39:24 +11:00
commit 74215a0b1d
Signed by: blankie
GPG Key ID: CC15FC822C7F61F5
27 changed files with 1576 additions and 0 deletions

1
.gitignore vendored Normal file
View File

@ -0,0 +1 @@
build

3
.gitmodules vendored Normal file
View File

@ -0,0 +1,3 @@
[submodule "thirdparty/httplib"]
path = thirdparty/httplib
url = https://github.com/yhirose/cpp-httplib.git

40
CMakeLists.txt Normal file
View File

@ -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})

42
blankie/escape.cpp Normal file
View File

@ -0,0 +1,42 @@
#include <stdexcept>
#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 "&amp;";
case '<': return "&lt;";
case '>': return "&gt;";
case '"': return "&quot;";
case '\'': return "&#x27;";
default: __builtin_unreachable();
}
}

11
blankie/escape.h Normal file
View File

@ -0,0 +1,11 @@
#pragma once
#include <string>
namespace blankie {
namespace html {
std::string escape(const std::string& in);
} // namespace html
} // namespace blankie

62
blankie/serializer.cpp Normal file
View File

@ -0,0 +1,62 @@
#include <cstring>
#include <stdexcept>
#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<Element>(&node)) {
out += element->serialize();
} else if (const char* const* text = std::get_if<const char*>(&node)) {
out += escape(*text);
} else if (const std::string* str = std::get_if<std::string>(&node)) {
out += escape(*str);
} else if (const HTMLString* html_str = std::get_if<HTMLString>(&node)) {
out += html_str->str;
} else {
throw std::runtime_error("Encountered unknown node");
}
}
out += "</";
out += this->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);
}

40
blankie/serializer.h Normal file
View File

@ -0,0 +1,40 @@
#pragma once
#include <string>
#include <vector>
#include <variant>
#include <utility>
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<const char*, std::string> Attribute;
typedef std::variant<Element, const char*, std::string, HTMLString> Node;
struct Element {
const char* tag;
std::vector<Attribute> attributes;
std::vector<Node> nodes;
Element(const char* tag_) : tag(tag_) {}
Element(const char* tag_, std::vector<Node> nodes_)
: tag(tag_), nodes(std::move(nodes_)) {}
Element(const char* tag_, std::vector<Attribute> attributes_, std::vector<Node> nodes_)
: tag(tag_), attributes(std::move(attributes_)), nodes(std::move(nodes_)) {}
std::string serialize() const;
};
} // namespace html
} // namespace blankie

159
client.cpp Normal file
View File

@ -0,0 +1,159 @@
#include <cassert>
#include <stdexcept>
#include <nlohmann/json.hpp>
#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<typename A>
void setopt(CURLSH* share, CURLSHoption option, A parameter) {
CURLSHcode code = curl_share_setopt(share, option, parameter);
if (code) {
throw CurlShareException(code);
}
}
template<typename A>
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<Account> 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<std::mutex*>(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<std::mutex*>(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<std::string*>(userdata);
// XXX should we bother with exceptions
str->append(ptr, realsize);
return realsize;
}

65
client.h Normal file
View File

@ -0,0 +1,65 @@
#pragma once
#include <mutex>
#include <optional>
#include <exception>
#include <pthread.h>
#include <curl/curl.h>
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<Account> 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;

56
config.cpp Normal file
View File

@ -0,0 +1,56 @@
#include <stdexcept>
#include <nlohmann/json.hpp>
#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<const std::string&>();
if (connection_type == "ip") {
redis_config.connection_method = IPConnection{
connection.at("address").get<std::string>(),
connection.at("port").get<int>()
};
} else if (connection_type == "unix") {
redis_config.connection_method = UnixConnection{connection.at("unix").get<std::string>()};
} else {
throw std::invalid_argument("Unknown redis connection type: "s + connection_type);
}
if (j.at("username").is_string()) {
redis_config.username = j["username"].get<std::string>();
}
if (j.at("password").is_string()) {
redis_config.password = j["password"].get<std::string>();
}
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<std::string>();
}
if (j.at("redis").at("enabled").get<bool>()) {
conf.redis_config = get_redis_config(j["redis"]);
}
}

32
config.h Normal file
View File

@ -0,0 +1,32 @@
#pragma once
#include <string>
#include <variant>
#include <optional>
#include <nlohmann/json_fwd.hpp>
struct IPConnection {
std::string address;
int port;
};
struct UnixConnection {
std::string unix;
};
struct RedisConfig {
std::variant<IPConnection, UnixConnection> connection_method;
std::optional<std::string> username;
std::optional<std::string> password;
};
struct Config {
std::string bind_host = "127.0.0.1";
int bind_port = 8080;
std::optional<std::string> canonical_origin;
std::optional<RedisConfig> redis_config;
};
extern Config config;
void load_config(const char* path);
void from_json(const nlohmann::json& j, Config& conf);

16
example_config.json Normal file
View File

@ -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"
}
}

61
file.h Normal file
View File

@ -0,0 +1,61 @@
#pragma once
#include <cstdio>
#include <system_error>
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()");
}
}

128
hiredis_wrapper.cpp Normal file
View File

@ -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<std::string> 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<std::string> 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");
}
}

73
hiredis_wrapper.h Normal file
View File

@ -0,0 +1,73 @@
#pragma once
#include <mutex>
#include <string>
#include <memory>
#include <optional>
#include <exception>
#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 <hiredis.h>
#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<redisReply, decltype(&freeReplyObject)>;
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<typename... Args>
RedisReply command(const char* format, Args... args) {
std::lock_guard<std::mutex> guard(this->_mutex);
redisReply* raw_reply = static_cast<redisReply*>(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<std::string> get(const std::string& key);
void set(const std::string& key, const std::string& value, time_t expiry);
std::optional<std::string> 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;
};

92
main.cpp Normal file
View File

@ -0,0 +1,92 @@
#include <cstdio>
#include <httplib/httplib.h>
#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 <path/to/config/file.json>\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:<any>\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;
}
}

81
models.cpp Normal file
View File

@ -0,0 +1,81 @@
#include <ctime>
#include <regex>
#include <stdexcept>
#include <system_error>
#include <nlohmann/json.hpp>
#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<std::string>();
if (j.at("verified_at").is_null()) {
field.verified_at = -1;
} else {
field.verified_at = parse_rfc3339(j["verified_at"].get_ref<const std::string&>());
}
}
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<const std::string&>());
account.note_html = j.at("note").get<std::string>();
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<const std::string&>();
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;
}

43
models.h Normal file
View File

@ -0,0 +1,43 @@
#pragma once
#include <string>
#include <vector>
#include <nlohmann/json_fwd.hpp>
#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<Emoji> emojis;
std::vector<AccountField> 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);

31
numberhelper.cpp Normal file
View File

@ -0,0 +1,31 @@
#include <climits>
#include <stdexcept>
#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<int>(ret);
}

7
numberhelper.h Normal file
View File

@ -0,0 +1,7 @@
#pragma once
#include <string>
#include <cstdlib>
unsigned long long to_ull(const std::string& str);
int to_int(const std::string& str);

142
routes/css.cpp Normal file
View File

@ -0,0 +1,142 @@
#include <string>
#include <FastHash.h>
#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");
}
}

8
routes/routes.h Normal file
View File

@ -0,0 +1,8 @@
#pragma once
#include <httplib/httplib.h>
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);

129
routes/user.cpp Normal file
View File

@ -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> 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<PostSortingMethod>(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,
});
}
}

155
servehelper.cpp Normal file
View File

@ -0,0 +1,155 @@
#include <memory>
#include <exception>
#include <stdexcept>
#include <FastHash.h>
#include <curl/curl.h>
#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 = "<!DOCTYPE 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<std::string> subtitle, std::optional<std::string> 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<char, decltype(&curl_free)>;
std::unique_ptr<CURLU, decltype(&curl_url_cleanup)> 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] != '/');
}

19
servehelper.h Normal file
View File

@ -0,0 +1,19 @@
#pragma once
#include <optional>
#include <httplib/httplib.h>
#include "blankie/serializer.h"
using Element = blankie::html::Element;
using Node = blankie::html::Node;
using Nodes = std::vector<Node>;
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<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);
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);

79
thirdparty/FastHash.h vendored Normal file
View File

@ -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 <kobevrijsen@posteo.be>
//
// 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 <cstdint>
#include <cstddef>
#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<uint64_t>(str[i]));
h *= m;
}
//if (back != uint64_t())
//{
// h ^= mix(back);
// h *= m;
//}
return mix(h);
}

1
thirdparty/httplib vendored Submodule

@ -0,0 +1 @@
Subproject commit 5ef4cfd263309458afe27246d66f370e3b351faa