#include "server.hpp" #include "store.hpp" #include "application.hpp" #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include 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{file}, std::istreambuf_iterator{}}; if (file.bad()) { throw std::runtime_error("Failed reading web asset: " + path.string()); } auto response = std::make_shared(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> { 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 { 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> { 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(); auto application = fpol::application{{options.database, options.allow_demo, {}}}; auto restarted = boost::asio::co_spawn(io, application.tick(true), boost::asio::use_future); io.run(); restarted.get(); io.restart(); auto reads = std::set{"auth/me", "leagues", "league", "catalogs", "candidates", "results", "standings", "admin/sources"}; auto actions = reads; for (auto const &action : {"auth/login", "auth/register", "auth/logout", "auth/password", "auth/reset", "league/create", "league/join", "league/invite", "league/revoke-invites", "league/remove", "league/settings", "league/name", "league/ratings", "team/name", "draft/queue", "draft/pick", "draft/start", "draft/pause", "draft/resume"}) { actions.insert(action); } for (auto const &action : actions) { auto method = reads.contains(action) ? "GET" : "POST"; router.add_response_route(method, "/api/" + action, [&application, &options, action, read = reads.contains(action)](ligo::request_context context, boost::json::value input) -> boost::asio::awaitable> { auto session = std::string{}; auto cookie = context.headers["cookie"]; for (std::size_t start = 0; start < cookie.size();) { auto end = cookie.find(';', start); if (end == std::string::npos) { end = cookie.size(); } auto part = cookie.substr(start, end - start); auto first = part.find_first_not_of(' '); if (first != std::string::npos) { part.erase(0, first); } if (part.starts_with("fpol_session=")) { if (!session.empty()) { session.clear(); break; } session = part.substr(13); } start = end + 1; } auto result = boost::json::value{}; if (!read && (context.headers["origin"] != options.origin || context.headers["x-fpol-request"] != "1")) { result = boost::json::object{{"ok", false}, {"status", 403}, {"message", "Request origin is not allowed"}}; } else { if (read) { input = boost::json::object{}; for (auto const &[key, value] : context.query) { input.as_object()[key] = value; } } result = co_await application.handle(action, std::move(input), session, context.headers["x-csrf-token"], context.peer); } auto response = ligo::service_response{}; response.status = static_cast(result.at("status").as_int64()); response.headers = {{"Cache-Control", "no-store"}, {"X-Content-Type-Options", "nosniff"}}; auto suffix = std::string{"; Path=/; HttpOnly; SameSite=Lax"} + (options.secure_cookies ? "; Secure" : ""); if (auto token = result.as_object().if_contains("session_token")) { response.headers.emplace_back("Set-Cookie", "fpol_session=" + std::string{token->as_string()} + "; Max-Age=2592000" + suffix); result.as_object().erase("session_token"); } if (result.as_object().contains("clear_session")) { response.headers.emplace_back("Set-Cookie", "fpol_session=; Max-Age=0" + suffix); result.as_object().erase("clear_session"); } response.body = boost::json::serialize(result); co_return response; }); } router.add_route("GET", "/api/rules", [&store](ligo::request_context, boost::json::value input) -> boost::asio::awaitable> { co_return co_await store.handle("rules", std::move(input)); }); // The snapshot simulator stays a local development tool, explicitly enabled. if (options.allow_demo) { for (auto const &action : {"catalog", "capacity", "score-preview"}) { auto method = 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> { try { co_return co_await store.handle(action, std::move(input)); } catch (std::exception 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.parse_query = true; http_options.max_body_size = 64 * 1024; 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 timer = boost::asio::steady_timer{io}; auto stopping = false; auto started = server.start(); if (!started) { modulus::log_error(started.error().describe()); return 1; } auto exit_code = 0; auto ticking = [&]() -> boost::asio::awaitable { while (!stopping) { timer.expires_after(std::chrono::seconds{1}); auto error = boost::system::error_code{}; co_await timer.async_wait(boost::asio::redirect_error(boost::asio::use_awaitable, error)); if (error || stopping) { break; } try { co_await application.tick(); } catch (std::exception const &failure) { modulus::log_error(failure.what()); } } }; boost::asio::co_spawn(io, ticking(), [&](std::exception_ptr failure) {if(failure){exit_code=1;io.stop();} }); signals.async_wait([&](boost::system::error_code error, int) { if (!error) { stopping = true; timer.cancel(); 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; }