#include "config.h"
#include <array>
#include <cstdlib>
#include <cstdint>
#include <filesystem>
#include <optional>
#include <set>
#include <string>
#include <string_view>
#include <system_error>
#include <utility>
#include <variant>
#include <toml++/toml.hpp>
#include "email_address.h"
namespace
{
const std::set<std::string> CONFIG_KEYS = {
"administrator_email",
"avif_quality",
"base_url",
"card_storage_root",
"database_path",
"email",
"listen_address",
"listen_port",
"maximum_accumulated_pulls",
"static_root",
"thumbnail_long_side",
};
const std::set<std::string> EMAIL_CONFIG_KEYS = {
"daily_attempt_limit",
"from_address",
"from_name",
"link_file",
"mailjet_api_key_environment",
"mailjet_secret_key_environment",
"transport",
};
mw::E<std::string> requiredString(
const toml::table& table,
std::string_view name)
{
auto value = table[name].value<std::string>();
if(!value)
{
return std::unexpected(mw::runtimeError(
"Configuration key '" + std::string(name) +
"' must be a string"));
}
return std::move(*value);
}
mw::E<std::int64_t> requiredInteger(
const toml::table& table,
std::string_view name)
{
auto value = table[name].value<std::int64_t>();
if(!value)
{
return std::unexpected(mw::runtimeError(
"Configuration key '" + std::string(name) +
"' must be an integer"));
}
return *value;
}
std::filesystem::path normalizedPath(
const std::filesystem::path& config_directory,
const std::string& value)
{
std::filesystem::path path(value);
if(path.is_relative())
{
path = config_directory / path;
}
return std::filesystem::absolute(path).lexically_normal();
}
bool pathContains(
const std::filesystem::path& parent,
const std::filesystem::path& child)
{
auto parent_position = parent.begin();
auto child_position = child.begin();
while(parent_position != parent.end() &&
child_position != child.end() &&
*parent_position == *child_position)
{
++parent_position;
++child_position;
}
return parent_position == parent.end();
}
mw::E<void> createDirectory(
const std::filesystem::path& path,
std::string_view description)
{
std::error_code filesystem_error;
std::filesystem::create_directories(path, filesystem_error);
if(filesystem_error)
{
return std::unexpected(mw::runtimeError(
"Failed to create " + std::string(description) + ": " +
filesystem_error.message()));
}
return {};
}
} // namespace
mw::E<Config> loadConfig(const std::filesystem::path& config_path)
{
toml::table table;
try
{
table = toml::parse_file(config_path.string());
}
catch(const toml::parse_error& error)
{
return std::unexpected(mw::runtimeError(
"Failed to parse configuration: " +
std::string(error.description())));
}
for(const auto& [key, value] : table)
{
[[maybe_unused]] const toml::node& node = value;
if(!CONFIG_KEYS.contains(std::string(key.str())))
{
return std::unexpected(mw::runtimeError(
"Unknown configuration key '" +
std::string(key.str()) + "'"));
}
}
const toml::table* email_table = table["email"].as_table();
if(email_table == nullptr)
{
return std::unexpected(mw::runtimeError(
"Configuration key 'email' must be a table"));
}
for(const auto& [key, value] : *email_table)
{
[[maybe_unused]] const toml::node& node = value;
if(!EMAIL_CONFIG_KEYS.contains(std::string(key.str())))
{
return std::unexpected(mw::runtimeError(
"Unknown email configuration key '" +
std::string(key.str()) + "'"));
}
}
auto base_url_text = requiredString(table, "base_url");
auto listen_address_text = requiredString(table, "listen_address");
auto static_root_text = requiredString(table, "static_root");
auto database_path_text = requiredString(table, "database_path");
auto card_storage_text = requiredString(table, "card_storage_root");
auto avif_quality = requiredInteger(table, "avif_quality");
auto thumbnail_long_side = requiredInteger(
table, "thumbnail_long_side");
auto administrator_email_text = requiredString(
table, "administrator_email");
auto maximum_accumulated_pulls = requiredInteger(
table, "maximum_accumulated_pulls");
if(!base_url_text || !listen_address_text || !static_root_text ||
!database_path_text || !card_storage_text || !avif_quality ||
!thumbnail_long_side || !administrator_email_text ||
!maximum_accumulated_pulls)
{
if(!base_url_text)
{
return std::unexpected(std::move(base_url_text.error()));
}
if(!listen_address_text)
{
return std::unexpected(std::move(listen_address_text.error()));
}
if(!static_root_text)
{
return std::unexpected(std::move(static_root_text.error()));
}
if(!database_path_text)
{
return std::unexpected(std::move(database_path_text.error()));
}
if(!card_storage_text)
{
return std::unexpected(std::move(card_storage_text.error()));
}
if(!avif_quality)
{
return std::unexpected(std::move(avif_quality.error()));
}
if(!thumbnail_long_side)
{
return std::unexpected(std::move(
thumbnail_long_side.error()));
}
if(!administrator_email_text)
{
return std::unexpected(std::move(
administrator_email_text.error()));
}
return std::unexpected(std::move(
maximum_accumulated_pulls.error()));
}
auto base_url = mw::URL::fromStr(*base_url_text);
if(!base_url ||
(base_url->scheme() != "http" && base_url->scheme() != "https") ||
base_url->host().empty())
{
return std::unexpected(mw::runtimeError(
"base_url must be an absolute HTTP or HTTPS URL"));
}
std::string base_path = base_url->path();
if(base_path.empty())
{
base_path = "/";
}
while(base_path.size() > 1 && base_path.ends_with('/'))
{
base_path.pop_back();
}
if(!base_path.ends_with('/'))
{
base_path.push_back('/');
}
base_url->path(base_path.c_str());
std::optional<mw::HTTPServer::ListenAddress> listen_address;
if(listen_address_text->starts_with("unix:"))
{
const std::string socket_path = listen_address_text->substr(5);
if(socket_path.empty())
{
return std::unexpected(mw::runtimeError(
"A Unix listen address requires a socket path"));
}
listen_address.emplace(mw::SocketFileInfo(socket_path));
}
else
{
auto listen_port = requiredInteger(table, "listen_port");
if(!listen_port || *listen_port < 1 || *listen_port > 65535)
{
return std::unexpected(mw::runtimeError(
"listen_port must be an integer from 1 through 65535"));
}
listen_address.emplace(mw::IPSocketInfo{
*listen_address_text, static_cast<int>(*listen_port)});
}
if(*avif_quality < 0 || *avif_quality > 100)
{
return std::unexpected(mw::runtimeError(
"avif_quality must be from 0 through 100"));
}
if(*thumbnail_long_side < 1 ||
*thumbnail_long_side > UINT32_MAX)
{
return std::unexpected(mw::runtimeError(
"thumbnail_long_side must be a positive 32-bit integer"));
}
if(*maximum_accumulated_pulls < 1 ||
*maximum_accumulated_pulls > UINT32_MAX)
{
return std::unexpected(mw::runtimeError(
"maximum_accumulated_pulls must be a positive 32-bit integer"));
}
auto administrator_email = normalizeEmail(*administrator_email_text);
if(!administrator_email)
{
return std::unexpected(mw::runtimeError(
"administrator_email is invalid"));
}
const std::filesystem::path config_directory =
std::filesystem::absolute(config_path).parent_path();
const std::filesystem::path static_root = normalizedPath(
config_directory, *static_root_text);
const std::filesystem::path database_path = normalizedPath(
config_directory, *database_path_text);
const std::filesystem::path card_storage_root = normalizedPath(
config_directory, *card_storage_text);
auto transport_text = requiredString(*email_table, "transport");
if(!transport_text)
{
return std::unexpected(std::move(transport_text.error()));
}
EmailConfig email;
if(*transport_text == "file")
{
auto link_file = requiredString(*email_table, "link_file");
if(!link_file)
{
return std::unexpected(std::move(link_file.error()));
}
email.transport = EmailTransport::FILE;
email.link_file = std::filesystem::path(*link_file);
if(!email.link_file.is_absolute())
{
return std::unexpected(mw::runtimeError(
"email.link_file must be absolute with an existing parent"));
}
email.link_file = std::filesystem::absolute(
email.link_file).lexically_normal();
if(!std::filesystem::is_directory(email.link_file.parent_path()))
{
return std::unexpected(mw::runtimeError(
"email.link_file must be absolute with an existing parent"));
}
if(pathContains(static_root, email.link_file) ||
pathContains(card_storage_root, email.link_file))
{
return std::unexpected(mw::runtimeError(
"email.link_file must be outside public asset roots"));
}
if(email_table->contains("mailjet_api_key_environment") ||
email_table->contains("mailjet_secret_key_environment") ||
email_table->contains("daily_attempt_limit") ||
email_table->contains("from_address") ||
email_table->contains("from_name"))
{
return std::unexpected(mw::runtimeError(
"File email transport rejects Mailjet settings"));
}
if(base_url->scheme() == "http" &&
std::holds_alternative<mw::IPSocketInfo>(*listen_address))
{
const std::string& address =
std::get<mw::IPSocketInfo>(*listen_address).address;
if(address != "127.0.0.1" && address != "::1" &&
address != "localhost")
{
return std::unexpected(mw::runtimeError(
"HTTP file transport requires a loopback listener"));
}
}
}
else if(*transport_text == "mailjet")
{
auto from_address = requiredString(*email_table, "from_address");
auto from_name = requiredString(*email_table, "from_name");
auto api_environment = requiredString(
*email_table, "mailjet_api_key_environment");
auto secret_environment = requiredString(
*email_table, "mailjet_secret_key_environment");
auto daily_limit = requiredInteger(
*email_table, "daily_attempt_limit");
if(!from_address || !from_name || !api_environment ||
!secret_environment || !daily_limit || from_name->empty() ||
api_environment->empty() || secret_environment->empty() ||
*daily_limit < 1 ||
*daily_limit > UINT32_MAX || base_url->scheme() != "https")
{
return std::unexpected(mw::runtimeError(
"Mailjet email configuration is invalid"));
}
if(!normalizeEmail(*from_address))
{
return std::unexpected(mw::runtimeError(
"email.from_address is invalid"));
}
const char* api_key = std::getenv(api_environment->c_str());
const char* secret_key = std::getenv(secret_environment->c_str());
if(api_key == nullptr || *api_key == '\0' ||
secret_key == nullptr || *secret_key == '\0')
{
return std::unexpected(mw::runtimeError(
"Mailjet credential environment variables are missing"));
}
email.transport = EmailTransport::MAILJET;
email.from_address = std::move(*from_address);
email.from_name = std::move(*from_name);
email.mailjet_api_key_environment = std::move(*api_environment);
email.mailjet_secret_key_environment = std::move(*secret_environment);
email.daily_attempt_limit = static_cast<std::uint32_t>(*daily_limit);
}
else
{
return std::unexpected(mw::runtimeError(
"email.transport must be 'mailjet' or 'file'"));
}
std::error_code filesystem_error;
if(!std::filesystem::is_directory(static_root, filesystem_error) ||
filesystem_error)
{
return std::unexpected(mw::runtimeError(
"static_root must name an existing directory"));
}
if(pathContains(static_root, card_storage_root) ||
pathContains(card_storage_root, static_root))
{
return std::unexpected(mw::runtimeError(
"static_root and card_storage_root must not overlap"));
}
if(pathContains(static_root, database_path) ||
pathContains(card_storage_root, database_path))
{
return std::unexpected(mw::runtimeError(
"database_path must not be inside a mounted asset root"));
}
if(email.transport == EmailTransport::FILE &&
(pathContains(static_root, email.link_file) ||
pathContains(card_storage_root, email.link_file)))
{
return std::unexpected(mw::runtimeError(
"email.link_file must not be inside an asset root"));
}
auto database_directory = createDirectory(
database_path.parent_path(), "the database directory");
if(!database_directory)
{
return std::unexpected(std::move(database_directory.error()));
}
auto card_directory = createDirectory(
card_storage_root, "the card storage directory");
if(!card_directory)
{
return std::unexpected(std::move(card_directory.error()));
}
auto published_directory = createDirectory(
card_storage_root / "published", "the published card directory");
if(!published_directory)
{
return std::unexpected(std::move(published_directory.error()));
}
return Config{
std::move(*base_url),
std::move(*listen_address),
static_root,
database_path,
card_storage_root,
static_cast<int>(*avif_quality),
static_cast<std::uint32_t>(*thumbnail_long_side),
std::move(administrator_email->email),
std::move(administrator_email->key),
static_cast<std::uint32_t>(*maximum_accumulated_pulls),
std::move(email),
};
}