164 lines
5.9 KiB
C++
164 lines
5.9 KiB
C++
#include "server.hpp"
|
|
#include "store.hpp"
|
|
|
|
#include <chrono>
|
|
#include <csignal>
|
|
#include <exception>
|
|
#include <expected>
|
|
#include <fstream>
|
|
#include <iterator>
|
|
#include <memory>
|
|
#include <stdexcept>
|
|
#include <string>
|
|
#include <utility>
|
|
|
|
#include <boost/asio/co_spawn.hpp>
|
|
#include <boost/asio/io_context.hpp>
|
|
#include <boost/asio/signal_set.hpp>
|
|
#include <boost/asio/use_future.hpp>
|
|
#include <boost/json/object.hpp>
|
|
|
|
#include <ligo.hpp>
|
|
#include <ligo/detail/errors.hpp>
|
|
#include <modulus.hpp>
|
|
|
|
|
|
namespace {
|
|
|
|
auto content_type(std::filesystem::path const &path) -> std::string {
|
|
auto extension = path.extension().string();
|
|
if (extension == ".html") {
|
|
return "text/html; charset=utf-8";
|
|
}
|
|
if (extension == ".css") {
|
|
return "text/css; charset=utf-8";
|
|
}
|
|
if (extension == ".js") {
|
|
return "text/javascript; charset=utf-8";
|
|
}
|
|
if (extension == ".json") {
|
|
return "application/json";
|
|
}
|
|
if (extension == ".svg") {
|
|
return "image/svg+xml";
|
|
}
|
|
if (extension == ".png") {
|
|
return "image/png";
|
|
}
|
|
if (extension == ".ico") {
|
|
return "image/x-icon";
|
|
}
|
|
if (extension == ".woff2") {
|
|
return "font/woff2";
|
|
}
|
|
return "application/octet-stream";
|
|
}
|
|
|
|
auto add_asset(ligo::service_router &router, std::string route, std::filesystem::path const &path) -> void {
|
|
auto file = std::ifstream{path, std::ios::binary};
|
|
if (!file) {
|
|
throw std::runtime_error("Cannot read web asset: " + path.string());
|
|
}
|
|
auto body = std::string{std::istreambuf_iterator<char>{file}, std::istreambuf_iterator<char>{}};
|
|
if (file.bad()) {
|
|
throw std::runtime_error("Failed reading web asset: " + path.string());
|
|
}
|
|
auto response = std::make_shared<ligo::service_response const>(ligo::text_response(content_type(path), std::move(body)));
|
|
router.add_response_route("GET", std::move(route), [response](ligo::request_context, boost::json::value) -> boost::asio::awaitable<std::expected<ligo::service_response, modulus::error>> {
|
|
co_return *response;
|
|
});
|
|
}
|
|
|
|
auto add_web_routes(ligo::service_router &router, std::filesystem::path const &root) -> void {
|
|
if (std::filesystem::is_symlink(root) || !std::filesystem::is_directory(root)) {
|
|
throw std::invalid_argument("Web root must be a directory, not a symlink: " + root.string());
|
|
}
|
|
auto index = root / "index.html";
|
|
if (std::filesystem::is_symlink(index) || !std::filesystem::is_regular_file(index)) {
|
|
throw std::invalid_argument("Web root must contain index.html; run npm run build in frontend first");
|
|
}
|
|
// Register only existing build artifacts, never resolve request paths on disk.
|
|
// Assets are read once and remain unchanged until the server restarts.
|
|
add_asset(router, "/", index);
|
|
for (auto const &entry : std::filesystem::recursive_directory_iterator{root}) {
|
|
if (entry.is_symlink() || !entry.is_regular_file()) {
|
|
continue;
|
|
}
|
|
auto relative = entry.path().lexically_relative(root);
|
|
auto hidden = false;
|
|
for (auto const &part : relative) {
|
|
if (part.string().starts_with('.')) {
|
|
hidden = true;
|
|
}
|
|
}
|
|
auto route = "/" + relative.generic_string();
|
|
if (hidden || route == "/api" || route.starts_with("/api/")) {
|
|
continue;
|
|
}
|
|
add_asset(router, std::move(route), entry.path());
|
|
}
|
|
}
|
|
|
|
auto stop_server(ligo::http::server &server) -> boost::asio::awaitable<void> {
|
|
auto result = co_await server.async_stop(std::chrono::milliseconds{3000});
|
|
if (!result) {
|
|
modulus::log_warn(result.error().describe());
|
|
}
|
|
}
|
|
|
|
}
|
|
|
|
auto fpol::run(fpol::server_options const &options) -> int {
|
|
auto io = boost::asio::io_context{};
|
|
auto router = ligo::service_router{};
|
|
router.add_route("GET", "/api/health", [](ligo::request_context, boost::json::value) -> boost::asio::awaitable<std::expected<boost::json::value, modulus::error>> {
|
|
co_return boost::json::object{{"status", "ok"}};
|
|
});
|
|
if (!options.web_root.empty()) {
|
|
add_web_routes(router, options.web_root);
|
|
}
|
|
auto store = fpol::store{io.get_executor(), options.database};
|
|
auto initialized = boost::asio::co_spawn(io, store.initialize(), boost::asio::use_future);
|
|
io.run();
|
|
initialized.get();
|
|
io.restart();
|
|
for (auto const &action : {"rules", "catalog", "capacity", "score-preview"}) {
|
|
auto method = std::string_view{action} == "rules" || std::string_view{action} == "catalog" ? "GET" : "POST";
|
|
router.add_route(method, std::string{"/api/"} + action, [&store, action = std::string{action}](ligo::request_context, boost::json::value input) -> boost::asio::awaitable<std::expected<boost::json::value, modulus::error>> {
|
|
try {
|
|
co_return co_await store.handle(action, std::move(input));
|
|
} catch (std::invalid_argument const &error) {
|
|
co_return std::unexpected(ligo::detail::make_error(ligo::detail::error_invalid_argument, error.what()));
|
|
} catch (std::out_of_range const &error) {
|
|
co_return std::unexpected(ligo::detail::make_error(ligo::detail::error_invalid_argument, error.what()));
|
|
}
|
|
});
|
|
}
|
|
auto http_options = ligo::http::server_options{};
|
|
http_options.on_error = [](modulus::error error) { modulus::log_warn(error.describe()); };
|
|
auto server = ligo::http::server{io, {.host = options.host, .port = options.port}, std::move(router), std::move(http_options)};
|
|
auto signals = boost::asio::signal_set{io, SIGINT, SIGTERM};
|
|
auto started = server.start();
|
|
if (!started) {
|
|
modulus::log_error(started.error().describe());
|
|
return 1;
|
|
}
|
|
auto exit_code = 0;
|
|
signals.async_wait([&](boost::system::error_code error, int) {
|
|
if (!error) {
|
|
modulus::log_info("Stopping server");
|
|
boost::asio::co_spawn(io, stop_server(server), [&](std::exception_ptr failure) {
|
|
if (failure) {
|
|
modulus::log_error("Server shutdown failed");
|
|
exit_code = 1;
|
|
}
|
|
io.stop();
|
|
});
|
|
}
|
|
});
|
|
auto host = options.host.find(':') == std::string::npos ? options.host : "[" + options.host + "]";
|
|
modulus::log_info("Listening on http://" + host + ":" + std::to_string(server.selected_port()));
|
|
io.run();
|
|
return exit_code;
|
|
}
|