Initial commit
This commit is contained in:
commit
74215a0b1d
|
@ -0,0 +1 @@
|
|||
build
|
|
@ -0,0 +1,3 @@
|
|||
[submodule "thirdparty/httplib"]
|
||||
path = thirdparty/httplib
|
||||
url = https://github.com/yhirose/cpp-httplib.git
|
|
@ -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})
|
|
@ -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 "&";
|
||||
case '<': return "<";
|
||||
case '>': return ">";
|
||||
case '"': return """;
|
||||
case '\'': return "'";
|
||||
default: __builtin_unreachable();
|
||||
}
|
||||
}
|
|
@ -0,0 +1,11 @@
|
|||
#pragma once
|
||||
|
||||
#include <string>
|
||||
|
||||
namespace blankie {
|
||||
namespace html {
|
||||
|
||||
std::string escape(const std::string& in);
|
||||
|
||||
} // namespace html
|
||||
} // namespace blankie
|
|
@ -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);
|
||||
}
|
|
@ -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
|
|
@ -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;
|
||||
}
|
|
@ -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;
|
|
@ -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"]);
|
||||
}
|
||||
}
|
|
@ -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);
|
|
@ -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"
|
||||
}
|
||||
}
|
|
@ -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()");
|
||||
}
|
||||
}
|
|
@ -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");
|
||||
}
|
||||
}
|
|
@ -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;
|
||||
};
|
|
@ -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;
|
||||
}
|
||||
}
|
|
@ -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;
|
||||
}
|
|
@ -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);
|
|
@ -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);
|
||||
}
|
|
@ -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);
|
|
@ -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");
|
||||
}
|
||||
}
|
|
@ -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);
|
|
@ -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,
|
||||
});
|
||||
}
|
||||
}
|
|
@ -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] != '/');
|
||||
}
|
|
@ -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);
|
|
@ -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);
|
||||
}
|
|
@ -0,0 +1 @@
|
|||
Subproject commit 5ef4cfd263309458afe27246d66f370e3b351faa
|
Loading…
Reference in New Issue