diff --git a/cmake_modules/embed_file.cmake b/cmake_modules/embed_file.cmake new file mode 100644 index 000000000..f79832d3e --- /dev/null +++ b/cmake_modules/embed_file.cmake @@ -0,0 +1,52 @@ +# CMake script to embed multiple files as C++ string constants +# Usage: cmake -DINPUT_FILES="file1.txt|file2.yaml" -DOUTPUT_FILE=output.h -P embed_file.cmake + +if(NOT DEFINED INPUT_FILES) + message(FATAL_ERROR "INPUT_FILES must be defined") +endif() + +if(NOT DEFINED OUTPUT_FILE) + message(FATAL_ERROR "OUTPUT_FILE must be defined") +endif() + +# Convert pipe-separated string to list +string(REPLACE "|" ";" INPUT_FILES_LIST "${INPUT_FILES}") + +# Start building the header content +set(HEADER_CONTENT "// Auto-generated file - DO NOT EDIT\n") +set(HEADER_CONTENT "${HEADER_CONTENT}// Generated from embedded files\n\n") +set(HEADER_CONTENT "${HEADER_CONTENT}#pragma once\n\n") +set(HEADER_CONTENT "${HEADER_CONTENT}namespace EmbeddedFiles\n{\n") + +# Process each input file +foreach(INPUT_FILE ${INPUT_FILES_LIST}) + if(NOT EXISTS "${INPUT_FILE}") + message(FATAL_ERROR "Input file does not exist: ${INPUT_FILE}") + endif() + + # Read the input file + file(READ "${INPUT_FILE}" FILE_CONTENT) + + # Escape special characters for C++ string literal + string(REPLACE "\\" "\\\\" FILE_CONTENT "${FILE_CONTENT}") + string(REPLACE "\"" "\\\"" FILE_CONTENT "${FILE_CONTENT}") + string(REPLACE "\n" "\\n\"\n\t\"" FILE_CONTENT "${FILE_CONTENT}") + + # Get the base name for the variable + get_filename_component(VAR_NAME "${INPUT_FILE}" NAME_WE) + string(TOUPPER "${VAR_NAME}" VAR_NAME) + string(REPLACE "-" "_" VAR_NAME "${VAR_NAME}") + + # Add to header content + set(HEADER_CONTENT "${HEADER_CONTENT}\tconst char* ${VAR_NAME}_CONTENT = \"${FILE_CONTENT}\";\n\n") + + message(STATUS "Embedded ${INPUT_FILE} as ${VAR_NAME}_CONTENT") +endforeach() + +# Close the namespace +set(HEADER_CONTENT "${HEADER_CONTENT}}\n") + +# Write the output file +file(WRITE "${OUTPUT_FILE}" "${HEADER_CONTENT}") + +message(STATUS "Generated ${OUTPUT_FILE}") diff --git a/lib/network/NetworkHandler.cpp b/lib/network/NetworkHandler.cpp index 29c13a86b..d3e6fb7ab 100644 --- a/lib/network/NetworkHandler.cpp +++ b/lib/network/NetworkHandler.cpp @@ -30,6 +30,11 @@ std::unique_ptr NetworkHandler::createServerTCP(INetworkServerLi return std::make_unique(listener, *context); } +NetworkContext & NetworkHandler::getContext() +{ + return *context; +} + std::shared_ptr NetworkHandler::createAsyncConnection(INetworkConnectionListener & listener) { auto loopbackConnection = std::make_shared(listener, *context); diff --git a/lib/network/NetworkHandler.h b/lib/network/NetworkHandler.h index aef52d3e4..bc9f19ff4 100644 --- a/lib/network/NetworkHandler.h +++ b/lib/network/NetworkHandler.h @@ -30,6 +30,8 @@ public: void run() override; void stop() override; + + NetworkContext & getContext() override; }; VCMI_LIB_NAMESPACE_END diff --git a/lib/network/NetworkInterface.h b/lib/network/NetworkInterface.h index 9c92aed2a..cf20d0992 100644 --- a/lib/network/NetworkInterface.h +++ b/lib/network/NetworkInterface.h @@ -184,6 +184,8 @@ public: /// Starts network processing on this thread. Does not returns until networking processing has been terminated virtual void run() = 0; virtual void stop() = 0; + + virtual NetworkContext & getContext() = 0; }; VCMI_LIB_NAMESPACE_END diff --git a/lobby/CMakeLists.txt b/lobby/CMakeLists.txt index b4169ab09..7711dd677 100644 --- a/lobby/CMakeLists.txt +++ b/lobby/CMakeLists.txt @@ -2,7 +2,9 @@ set(lobby_SRCS StdInc.cpp EntryPoint.cpp + HttpServer.cpp LobbyDatabase.cpp + LobbyHttpApi.cpp LobbyServer.cpp SQLiteConnection.cpp ) @@ -10,12 +12,36 @@ set(lobby_SRCS set(lobby_HEADERS StdInc.h + HttpServer.h LobbyDatabase.h LobbyDefines.h + LobbyHttpApi.h LobbyServer.h SQLiteConnection.h ) +# Generate header file from web assets +set(WEB_ASSETS + "${CMAKE_CURRENT_SOURCE_DIR}/web/openapi.yaml" + "${CMAKE_CURRENT_SOURCE_DIR}/web/swagger.html" +) +set(WEB_HEADER_FILE "${CMAKE_CURRENT_BINARY_DIR}/EmbeddedWebAssets.h") + +# Convert list to semicolon-separated string for passing to script +string(REPLACE ";" "|" WEB_ASSETS_STRING "${WEB_ASSETS}") + +add_custom_command( + OUTPUT "${WEB_HEADER_FILE}" + COMMAND ${CMAKE_COMMAND} + -DINPUT_FILES=${WEB_ASSETS_STRING} + -DOUTPUT_FILE=${WEB_HEADER_FILE} + -P ${CMAKE_CURRENT_SOURCE_DIR}/../cmake_modules/embed_file.cmake + DEPENDS ${WEB_ASSETS} + COMMENT "Embedding web assets into C++ header" + VERBATIM +) +list(APPEND lobby_HEADERS "${WEB_HEADER_FILE}") + assign_source_group(${lobby_SRCS} ${lobby_HEADERS}) add_executable(vcmilobby ${lobby_SRCS} ${lobby_HEADERS}) @@ -29,6 +55,7 @@ target_link_libraries(vcmilobby PRIVATE ${lobby_LIBS} ${SQLite3_LIBRARIES}) target_include_directories(vcmilobby PRIVATE ${SQLite3_INCLUDE_DIRS}) target_include_directories(vcmilobby PUBLIC ${CMAKE_CURRENT_SOURCE_DIR}) +target_include_directories(vcmilobby PRIVATE ${CMAKE_CURRENT_BINARY_DIR}) if(WIN32) set_target_properties(vcmilobby diff --git a/lobby/EntryPoint.cpp b/lobby/EntryPoint.cpp index 250eee25a..592981f12 100644 --- a/lobby/EntryPoint.cpp +++ b/lobby/EntryPoint.cpp @@ -10,6 +10,8 @@ #include "StdInc.h" #include "LobbyServer.h" +#include "HttpServer.h" +#include "LobbyHttpApi.h" #include "../lib/CConsoleHandler.h" #include "../lib/logging/CBasicLogConfigurator.h" @@ -18,6 +20,8 @@ #include "../lib/VCMIDirs.h" static const int LISTENING_PORT = 3031; +static const int HTTP_API_PORT = 3032; +static const bool HTTP_API_LOCALHOST_ONLY = true; int main(int argc, const char * argv[]) { @@ -45,7 +49,23 @@ int main(int argc, const char * argv[]) logGlobal->error("Failed to start server! Another server already uses the same port? Reason: '%s'", e.what()); return 1; } + + // Start HTTP API Server + LobbyHttpApi lobbyApi(*server.getDatabase()); + HttpServer httpServer(server.getNetworkContext(), lobbyApi, HTTP_API_PORT, HTTP_API_LOCALHOST_ONLY); + try + { + httpServer.start(); + } + catch (const std::exception & e) + { + logGlobal->error("Failed to start HTTP API server! Reason: '%s'", e.what()); + return 1; + } + server.run(); + httpServer.stop(); + return 0; } diff --git a/lobby/HttpServer.cpp b/lobby/HttpServer.cpp new file mode 100644 index 000000000..66b2afea4 --- /dev/null +++ b/lobby/HttpServer.cpp @@ -0,0 +1,226 @@ +/* + * HttpServer.cpp, part of VCMI engine + * + * Authors: listed in file AUTHORS in main folder + * + * License: GNU General Public License v2.0 or later + * Full text of license available in license.txt file, in main folder + * + */ +#include "StdInc.h" +#include "HttpServer.h" +#include "EmbeddedWebAssets.h" + +#include "../lib/logging/CLogger.h" + +namespace beast = boost::beast; +namespace http = beast::http; +namespace net = boost::asio; +using tcp = net::ip::tcp; + +/// Extracts the value of a URL query parameter by key (e.g. "hours" from "/api?hours=24") +static std::string extractQueryParameter(boost::beast::string_view target, const std::string & key) +{ + std::string t(target); + auto qpos = t.find('?'); + if (qpos == std::string::npos) + return {}; + std::string query = t.substr(qpos + 1); + const std::string prefix = key + '='; + for (std::string::size_type pos = 0; pos < query.size(); ) + { + auto amp = query.find('&', pos); + std::string part = query.substr(pos, amp == std::string::npos ? std::string::npos : amp - pos); + if (part.substr(0, prefix.size()) == prefix) + return part.substr(prefix.size()); + if (amp == std::string::npos) break; + pos = amp + 1; + } + return {}; +} + +HttpServer::HttpServer(boost::asio::io_context & ioc, ILobbyHttpHandler & handler, unsigned short port, bool localhostOnly) + : handler(handler) + , port(port) + , localhostOnly(localhostOnly) + , ioc(ioc) +{ +} + +HttpServer::~HttpServer() +{ + stop(); +} + +void HttpServer::start() +{ + acceptor = std::make_unique(ioc); + if (localhostOnly) + { + tcp::endpoint ep{boost::asio::ip::address_v4::loopback(), port}; + acceptor->open(ep.protocol()); + acceptor->set_option(tcp::acceptor::reuse_address(true)); + acceptor->bind(ep); + } + else + { + tcp::endpoint ep{tcp::v6(), port}; + acceptor->open(ep.protocol()); + acceptor->set_option(tcp::acceptor::reuse_address(true)); + acceptor->set_option(boost::asio::ip::v6_only(false)); + acceptor->bind(ep); + } + acceptor->listen(); + + doAccept(); + logGlobal->info("HTTP API Server started on port %d", port); +} + +void HttpServer::stop() +{ + if (acceptor && acceptor->is_open()) + { + acceptor->close(); + logGlobal->info("HTTP API Server stopped"); + } +} + +void HttpServer::doAccept() +{ + acceptor->async_accept([this](boost::system::error_code ec, tcp::socket socket) + { + if (!ec) + { + auto stream = std::make_shared(std::move(socket)); + auto buffer = std::make_shared(); + auto req = std::make_shared>(); + + http::async_read(*stream, *buffer, *req, + [this, stream, buffer, req](boost::system::error_code readEc, std::size_t) mutable + { + if (readEc) + { + logGlobal->error("HTTP read error: %s", readEc.message()); + return; + } + try + { + auto res = std::make_shared>(handleRequest(std::move(*req), *stream)); + http::async_write(*stream, *res, + [stream, res](boost::system::error_code, std::size_t) + { + beast::error_code shutdownEc; + stream->socket().shutdown(tcp::socket::shutdown_send, shutdownEc); + }); + } + catch (const std::exception & e) + { + logGlobal->error("HTTP session error: %s", e.what()); + } + }); + } + if (acceptor && acceptor->is_open()) + doAccept(); + }); +} + +HttpServer::Response HttpServer::makeResponse(const Request & req, http::status status, std::string body, const std::string & contentType) +{ + Response res{status, req.version()}; + res.set(http::field::server, "VCMI-Lobby-API"); + res.set(http::field::content_type, contentType); + res.keep_alive(req.keep_alive()); + res.body() = std::move(body); + res.prepare_payload(); + return res; +} + +HttpServer::Response HttpServer::handleStatsV1(const Request & req) +{ + return makeResponse(req, http::status::ok, handler.getApiStats()); +} + +HttpServer::Response HttpServer::handleChatsV1(const Request & req) +{ + std::string channelName = "english"; + if (auto val = extractQueryParameter(req.target(), "channelName"); !val.empty()) + channelName = val; + + return makeResponse(req, http::status::ok, handler.getApiChats(channelName)); +} + +HttpServer::Response HttpServer::handleRoomsV1(const Request & req) +{ + int hours = -1; + int limit = 50; + if (auto val = extractQueryParameter(req.target(), "hours"); !val.empty()) + { + try { hours = std::stoi(val); } + catch (const std::invalid_argument &) { return makeResponse(req, http::status::bad_request, R"({"error":"Parameter 'hours' must be an integer"})"); } + catch (const std::out_of_range &) { return makeResponse(req, http::status::bad_request, R"({"error":"Parameter 'hours' is out of range"})"); } + } + if (auto val = extractQueryParameter(req.target(), "limit"); !val.empty()) + { + try + { + limit = std::stoi(val); + if (limit < 1 || limit > 250) + return makeResponse(req, http::status::bad_request, R"({"error":"Parameter 'limit' must be between 1 and 250"})"); + } + catch (const std::invalid_argument &) { return makeResponse(req, http::status::bad_request, R"({"error":"Parameter 'limit' must be an integer"})"); } + catch (const std::out_of_range &) { return makeResponse(req, http::status::bad_request, R"({"error":"Parameter 'limit' must be between 1 and 250"})"); } + } + + return makeResponse(req, http::status::ok, handler.getApiRooms(hours, limit)); +} + +HttpServer::Response HttpServer::handleDocs(const Request & req) +{ + return makeResponse(req, http::status::ok, EmbeddedFiles::SWAGGER_CONTENT, "text/html"); +} + +HttpServer::Response HttpServer::handleOpenApiSpec(const Request & req) +{ + return makeResponse(req, http::status::ok, EmbeddedFiles::OPENAPI_CONTENT, "text/yaml"); +} + +HttpServer::Response HttpServer::handleRequest(Request && req, beast::tcp_stream & stream) +{ + std::string clientIP = "unknown"; + try + { + clientIP = stream.socket().remote_endpoint().address().to_string(); + } + catch(const boost::system::system_error & e) + { + logGlobal->warn("HTTP API: could not get client IP: %s", e.what()); + } + + std::string userAgent = std::string(req[http::field::user_agent]); + if (userAgent.empty()) + userAgent = "unknown"; + + logGlobal->info("HTTP API Request: %s %s from %s (User-Agent: %s)", + req.method_string().data(), + req.target().data(), + clientIP.c_str(), + userAgent.c_str()); + + try + { + if (req.target() == "/api/v1/stats") return handleStatsV1(req); + if (req.target().starts_with("/api/v1/chats")) return handleChatsV1(req); + if (req.target().starts_with("/api/v1/rooms")) return handleRoomsV1(req); + if (req.target() == "/api/docs" || + req.target() == "/") return handleDocs(req); + if (req.target() == "/api/openapi.yaml") return handleOpenApiSpec(req); + + return makeResponse(req, http::status::not_found, + R"({ "error": "Not Found", "message": "The requested endpoint does not exist" })"); + } + catch (const std::exception & e) + { + logGlobal->error("Error handling HTTP request: %s", e.what()); + return makeResponse(req, http::status::internal_server_error, R"({"error":"Internal Server Error"})"); + } +} diff --git a/lobby/HttpServer.h b/lobby/HttpServer.h new file mode 100644 index 000000000..b9b52377f --- /dev/null +++ b/lobby/HttpServer.h @@ -0,0 +1,58 @@ +/* + * HttpServer.h, part of VCMI engine + * + * Authors: listed in file AUTHORS in main folder + * + * License: GNU General Public License v2.0 or later + * Full text of license available in license.txt file, in main folder + * + */ +#pragma once + +#include +#include + +/// Interface that must be implemented to handle the concrete API endpoints +class ILobbyHttpHandler +{ +public: + virtual ~ILobbyHttpHandler() = default; + + virtual std::string getApiStats() = 0; + virtual std::string getApiChats(const std::string & channelName) = 0; + virtual std::string getApiRooms(int hours, int limit) = 0; +}; + +/// Generic HTTP/REST server built on Boost.Beast. +/// Handles the TCP accept loop, request parsing, routing, and response writing. +/// Delegates concrete API data to ILobbyHttpHandler. +class HttpServer +{ +public: + HttpServer(boost::asio::io_context & ioc, ILobbyHttpHandler & handler, unsigned short port, bool localhostOnly); + ~HttpServer(); + + void start(); + void stop(); + +private: + using Request = boost::beast::http::request; + using Response = boost::beast::http::response; + + void doAccept(); + Response handleRequest(Request && req, boost::beast::tcp_stream & stream); + + static Response makeResponse(const Request & req, boost::beast::http::status status, std::string body, const std::string & contentType = "application/json"); + + Response handleStatsV1(const Request & req); + Response handleChatsV1(const Request & req); + Response handleRoomsV1(const Request & req); + Response handleDocs(const Request & req); + Response handleOpenApiSpec(const Request & req); + + ILobbyHttpHandler & handler; + unsigned short port; + bool localhostOnly; + boost::asio::io_context & ioc; + std::unique_ptr acceptor; +}; diff --git a/lobby/LobbyDatabase.cpp b/lobby/LobbyDatabase.cpp index 3b32c8e02..d593bd893 100644 --- a/lobby/LobbyDatabase.cpp +++ b/lobby/LobbyDatabase.cpp @@ -272,6 +272,15 @@ void LobbyDatabase::prepareStatements() ORDER BY secondsElapsed ASC )"); + getRoomsStatement = database->prepare(R"( + SELECT roomID, hostAccountID, displayName, description, status, playerLimit, version, mods, strftime('%s',CURRENT_TIMESTAMP)- strftime('%s',gr.creationTime) AS secondsElapsed + FROM gameRooms gr + LEFT JOIN accounts a ON gr.hostAccountID = a.accountID + WHERE (? = -1 OR strftime('%s',CURRENT_TIMESTAMP) - strftime('%s',gr.creationTime) < ? * 3600) + ORDER BY gr.creationTime DESC + LIMIT ? + )"); + getGameRoomInvitesStatement = database->prepare(R"( SELECT a.accountID, a.displayName FROM gameRoomInvites gri @@ -304,6 +313,36 @@ void LobbyDatabase::prepareStatements() WHERE accountID = ? )"); + getActiveAccountsCountsBatchStatement = database->prepare(R"( + SELECT + SUM(lastLoginTime >= datetime('now', '-1 hours')), + SUM(lastLoginTime >= datetime('now', '-24 hours')), + SUM(lastLoginTime >= datetime('now', '-168 hours')), + SUM(lastLoginTime >= datetime('now', '-720 hours')), + SUM(lastLoginTime >= datetime('now', '-8760 hours')) + FROM accounts + )"); + + getRegisteredAccountsCountsBatchStatement = database->prepare(R"( + SELECT + COUNT(*), + SUM(creationTime >= datetime('now', '-24 hours')), + SUM(creationTime >= datetime('now', '-168 hours')), + SUM(creationTime >= datetime('now', '-720 hours')), + SUM(creationTime >= datetime('now', '-8760 hours')) + FROM accounts + )"); + + getClosedGameRoomsCountsBatchStatement = database->prepare(R"( + SELECT + COUNT(*), + SUM(creationTime >= datetime('now', '-24 hours')), + SUM(creationTime >= datetime('now', '-168 hours')), + SUM(creationTime >= datetime('now', '-720 hours')), + SUM(creationTime >= datetime('now', '-8760 hours')) + FROM gameRooms WHERE status = 5 + )"); + isAccountCookieValidStatement = database->prepare(R"( SELECT COUNT(accountID) FROM accountCookies @@ -481,6 +520,48 @@ std::string LobbyDatabase::getAccountDisplayName(const std::string & accountID) return result; } +LobbyDatabase::ActiveAccountsCounts LobbyDatabase::getActiveAccountsCounts() +{ + ActiveAccountsCounts result{}; + + getActiveAccountsCountsBatchStatement->reset(); + + if(getActiveAccountsCountsBatchStatement->execute()) + getActiveAccountsCountsBatchStatement->getColumns(result.h1, result.h24, result.h168, result.h720, result.h8760); + + getActiveAccountsCountsBatchStatement->reset(); + + return result; +} + +LobbyDatabase::RegisteredAccountsCounts LobbyDatabase::getRegisteredAccountsCounts() +{ + RegisteredAccountsCounts result{}; + + getRegisteredAccountsCountsBatchStatement->reset(); + + if(getRegisteredAccountsCountsBatchStatement->execute()) + getRegisteredAccountsCountsBatchStatement->getColumns(result.total, result.h24, result.h168, result.h720, result.h8760); + + getRegisteredAccountsCountsBatchStatement->reset(); + + return result; +} + +LobbyDatabase::ClosedGameRoomsCounts LobbyDatabase::getClosedGameRoomsCounts() +{ + ClosedGameRoomsCounts result{}; + + getClosedGameRoomsCountsBatchStatement->reset(); + + if(getClosedGameRoomsCountsBatchStatement->execute()) + getClosedGameRoomsCountsBatchStatement->getColumns(result.total, result.h24, result.h168, result.h720, result.h8760); + + getClosedGameRoomsCountsBatchStatement->reset(); + + return result; +} + LobbyCookieStatus LobbyDatabase::getAccountCookieStatus(const std::string & accountID, const std::string & accessCookieUUID) { bool result = false; @@ -645,6 +726,45 @@ std::vector LobbyDatabase::getActiveAccounts() return result; } +std::vector LobbyDatabase::getRooms(int hours, int limit) +{ + std::vector result; + + getRoomsStatement->reset(); + getRoomsStatement->setBinds(hours, hours, limit); + + while(getRoomsStatement->execute()) + { + LobbyGameRoom entry; + std::string hostAccountDisplayName; + int64_t secondsElapsed; + getRoomsStatement->getColumns(entry.roomID, entry.hostAccountID, hostAccountDisplayName, entry.description, entry.roomState, entry.playerLimit, entry.version, entry.modsJson, secondsElapsed); + entry.age = std::chrono::seconds(secondsElapsed); + + LobbyAccount hostAccount; + hostAccount.accountID = entry.hostAccountID; + hostAccount.displayName = hostAccountDisplayName; + entry.participants.push_back(hostAccount); + + result.push_back(entry); + } + getRoomsStatement->reset(); + + for (auto & room : result) + { + getGameRoomPlayersStatement->setBinds(room.roomID); + while(getGameRoomPlayersStatement->execute()) + { + LobbyAccount account; + getGameRoomPlayersStatement->getColumns(account.accountID, account.displayName); + room.participants.push_back(account); + } + getGameRoomPlayersStatement->reset(); + } + + return result; +} + std::string LobbyDatabase::getIdleGameRoom(const std::string & hostAccountID) { std::string result; diff --git a/lobby/LobbyDatabase.h b/lobby/LobbyDatabase.h index ffc6d5c8c..1a946d733 100644 --- a/lobby/LobbyDatabase.h +++ b/lobby/LobbyDatabase.h @@ -47,6 +47,10 @@ class LobbyDatabase SQLiteStatementPtr getAccountInviteStatusStatement; SQLiteStatementPtr getAccountGameRoomStatement; SQLiteStatementPtr getAccountDisplayNameStatement; + SQLiteStatementPtr getActiveAccountsCountsBatchStatement; + SQLiteStatementPtr getRegisteredAccountsCountsBatchStatement; + SQLiteStatementPtr getClosedGameRoomsCountsBatchStatement; + SQLiteStatementPtr getRoomsStatement; SQLiteStatementPtr getGameRoomPlayersStatement; SQLiteStatementPtr getGameRoomInvitesStatement; SQLiteStatementPtr countRoomUsedSlotsStatement; @@ -89,12 +93,24 @@ public: std::vector getAccountGameHistory(const std::string & accountID); std::vector getActiveGameRooms(); std::vector getActiveAccounts(); + std::vector getRooms(int hours, int limit); std::vector getRecentMessageHistory(const std::string & channelType, const std::string & channelName); std::vector getFullMessageHistory(const std::string & channelType, const std::string & channelName); std::string getIdleGameRoom(const std::string & hostAccountID); std::string getAccountGameRoom(const std::string & accountID); std::string getAccountDisplayName(const std::string & accountID); + /// Batch account activity counts: total registered, plus active in last 1h/24h/1w/1m/1y + struct ActiveAccountsCounts { int h1, h24, h168, h720, h8760; }; + ActiveAccountsCounts getActiveAccountsCounts(); + + /// Batch registration counts: total, plus registered in last 24h/1w/1m/1y + struct RegisteredAccountsCounts { int total, h24, h168, h720, h8760; }; + RegisteredAccountsCounts getRegisteredAccountsCounts(); + + /// Batch closed game room counts: total, plus closed in last 24h/1w/1m/1y + struct ClosedGameRoomsCounts { int total, h24, h168, h720, h8760; }; + ClosedGameRoomsCounts getClosedGameRoomsCounts(); LobbyCookieStatus getAccountCookieStatus(const std::string & accountID, const std::string & accessCookieUUID); LobbyInviteStatus getAccountInviteStatus(const std::string & accountID, const std::string & roomID); diff --git a/lobby/LobbyHttpApi.cpp b/lobby/LobbyHttpApi.cpp new file mode 100644 index 000000000..b3fe0112b --- /dev/null +++ b/lobby/LobbyHttpApi.cpp @@ -0,0 +1,181 @@ +/* + * LobbyHttpApi.cpp, part of VCMI engine + * + * Authors: listed in file AUTHORS in main folder + * + * License: GNU General Public License v2.0 or later + * Full text of license available in license.txt file, in main folder + * + */ +#include "StdInc.h" +#include "LobbyHttpApi.h" +#include "LobbyDatabase.h" + +#include "../lib/json/JsonNode.h" +#include "../lib/logging/CLogger.h" + +LobbyHttpApi::LobbyHttpApi(LobbyDatabase & database) + : database(database) + , startTime(std::chrono::system_clock::now()) +{ +} + +bool LobbyHttpApi::isCacheValid(const CacheEntry & entry) const +{ + return std::chrono::system_clock::now() - entry.timestamp < std::chrono::seconds(CACHE_TTL_SECONDS); +} + +std::string LobbyHttpApi::formatTimestamp(std::chrono::system_clock::time_point timePoint) +{ + auto tt = std::chrono::system_clock::to_time_t(timePoint); + std::tm tm{}; + localtime_r(&tt, &tm); + std::ostringstream oss; + oss << std::put_time(&tm, "%Y-%m-%dT%H:%M:%S%z"); + return oss.str(); +} + +std::string LobbyHttpApi::getApiStats() +{ + if (statsCache && isCacheValid(*statsCache)) + return statsCache->json; + JsonNode stats; + stats["onlinePlayers"].Vector() = JsonVector(); + for (const auto & player : database.getActiveAccounts()) + stats["onlinePlayers"].Vector().push_back(JsonNode(player.displayName)); + auto activeCounts = database.getActiveAccountsCounts(); + stats["onlinePlayersCount"].Struct() = JsonMap{ + {"current", JsonNode(static_cast(stats["onlinePlayers"].Vector().size()))}, + {"lastHour", JsonNode(static_cast(activeCounts.h1))}, + {"lastDay", JsonNode(static_cast(activeCounts.h24))}, + {"lastWeek", JsonNode(static_cast(activeCounts.h168))}, + {"lastMonth", JsonNode(static_cast(activeCounts.h720))}, + {"lastYear", JsonNode(static_cast(activeCounts.h8760))} + }; + auto registeredCounts = database.getRegisteredAccountsCounts(); + stats["registeredPlayersCount"].Struct() = JsonMap{ + {"total", JsonNode(static_cast(registeredCounts.total))}, + {"lastDay", JsonNode(static_cast(registeredCounts.h24))}, + {"lastWeek", JsonNode(static_cast(registeredCounts.h168))}, + {"lastMonth", JsonNode(static_cast(registeredCounts.h720))}, + {"lastYear", JsonNode(static_cast(registeredCounts.h8760))} + }; + std::map lobbysCount; + for (const auto & room : database.getActiveGameRooms()) + lobbysCount[room.roomState]++; + auto closedCounts = database.getClosedGameRoomsCounts(); + stats["gameCount"].Struct() = JsonMap{ + {"current", JsonNode(lobbysCount[LobbyRoomState::BUSY])}, + {"total", JsonNode(static_cast(closedCounts.total))}, + {"lastDay", JsonNode(static_cast(closedCounts.h24))}, + {"lastWeek", JsonNode(static_cast(closedCounts.h168))}, + {"lastMonth", JsonNode(static_cast(closedCounts.h720))}, + {"lastYear", JsonNode(static_cast(closedCounts.h8760))} + }; + stats["lobbyCount"].Struct() = JsonMap{ + {"current", JsonNode(static_cast(lobbysCount[LobbyRoomState::PUBLIC] + lobbysCount[LobbyRoomState::PRIVATE]))}, + {"public", JsonNode(static_cast(lobbysCount[LobbyRoomState::PUBLIC]))}, + {"private", JsonNode(static_cast(lobbysCount[LobbyRoomState::PRIVATE]))} + }; + stats["lobbyStartTime"].String() = formatTimestamp(startTime); + + stats["server"].String() = "VCMI Lobby"; + stats["apiVersion"].String() = "1.0"; + + std::string json = stats.toCompactString(); + statsCache = CacheEntry{json, std::chrono::system_clock::now()}; + return json; +} + +std::string LobbyHttpApi::getApiChats(const std::string & channelName) +{ + auto it = chatsCache.find(channelName); + if (it != chatsCache.end() && isCacheValid(it->second)) + return it->second.json; + + JsonNode chats; + chats["messages"].Vector() = JsonVector(); + chats["channelName"].String() = channelName; + + auto messages = database.getRecentMessageHistory("global", channelName); + + for (const auto & msg : messages) + { + JsonNode message; + message["displayName"].String() = msg.displayName; + message["messageText"].String() = msg.messageText; + message["ageSeconds"].Integer() = msg.age.count(); + + auto messageTime = std::chrono::system_clock::now() - msg.age; + message["timestamp"].String() = formatTimestamp(messageTime); + + chats["messages"].Vector().push_back(message); + } + + chats["count"].Integer() = chats["messages"].Vector().size(); + + std::string json = chats.toCompactString(); + chatsCache[channelName] = CacheEntry{json, std::chrono::system_clock::now()}; + return json; +} + +std::string LobbyHttpApi::serializeRooms(const std::vector & rooms, int hours, int limit) +{ + JsonNode result; + result["rooms"].Vector() = JsonVector(); + result["hours"].Integer() = hours; + result["limit"].Integer() = limit; + + int count = 0; + for (const auto & room : rooms) + { + if (hours != -1 && room.age > std::chrono::hours(hours)) + continue; + if (count >= limit) + break; + + JsonNode roomNode; + roomNode["description"].String() = room.description; + roomNode["status"].Integer() = static_cast(room.roomState); + roomNode["playerLimit"].Integer() = room.playerLimit; + roomNode["version"].String() = room.version; + roomNode["secondsElapsed"].Integer() = room.age.count(); + + auto creationTime = std::chrono::system_clock::now() - room.age; + roomNode["createdAt"].String() = formatTimestamp(creationTime); + + // Parse mods JSON string + try { + JsonNode modsNode(reinterpret_cast(room.modsJson.data()), room.modsJson.size(), ""); + roomNode["mods"] = modsNode; + } catch(const std::exception & e) { + logGlobal->warn("HTTP API: failed to parse mods JSON: %s", e.what()); + roomNode["mods"].Struct() = JsonMap{}; + } + + result["rooms"].Vector().push_back(roomNode); + ++count; + } + + result["count"].Integer() = result["rooms"].Vector().size(); + return result.toCompactString(); +} + +std::string LobbyHttpApi::getApiRooms(int hours, int limit) +{ + if (roomsCache) + { + const auto & c = *roomsCache; + const bool ttlOk = std::chrono::system_clock::now() - c.timestamp < std::chrono::seconds(CACHE_TTL_SECONDS); + const bool hoursOk = c.fetchedHours == -1 || (hours != -1 && c.fetchedHours >= hours); + const bool limitOk = c.fetchedLimit >= limit; + if (ttlOk && hoursOk && limitOk) + return serializeRooms(c.rooms, hours, limit); + } + + auto fetchedRooms = database.getRooms(hours, limit); + + roomsCache = RoomsCacheEntry{fetchedRooms, hours, limit, std::chrono::system_clock::now()}; + + return serializeRooms(fetchedRooms, hours, limit); +} diff --git a/lobby/LobbyHttpApi.h b/lobby/LobbyHttpApi.h new file mode 100644 index 000000000..ad83df59c --- /dev/null +++ b/lobby/LobbyHttpApi.h @@ -0,0 +1,55 @@ +/* + * LobbyHttpApi.h, part of VCMI engine + * + * Authors: listed in file AUTHORS in main folder + * + * License: GNU General Public License v2.0 or later + * Full text of license available in license.txt file, in main folder + * + */ +#pragma once + +#include "HttpServer.h" +#include "LobbyDefines.h" + +class LobbyDatabase; + +/// Concrete implementation of the VCMI lobby REST API. +/// Fetches data from LobbyDatabase, applies caching, and serializes to JSON. +class LobbyHttpApi : public ILobbyHttpHandler +{ +public: + static constexpr int CACHE_TTL_SECONDS = 30; + + explicit LobbyHttpApi(LobbyDatabase & database); + + std::string getApiStats() override; + std::string getApiChats(const std::string & channelName) override; + std::string getApiRooms(int hours, int limit) override; + +private: + struct CacheEntry + { + std::string json; + std::chrono::system_clock::time_point timestamp; + }; + + bool isCacheValid(const CacheEntry & entry) const; + std::string formatTimestamp(std::chrono::system_clock::time_point timePoint); + std::string serializeRooms(const std::vector & rooms, int hours, int limit); + + LobbyDatabase & database; + std::chrono::system_clock::time_point startTime; + + std::optional statsCache; + std::map chatsCache; + + struct RoomsCacheEntry + { + std::vector rooms; + int fetchedHours; // -1 means all time + int fetchedLimit; + std::chrono::system_clock::time_point timestamp; + }; + std::optional roomsCache; +}; diff --git a/lobby/LobbyServer.cpp b/lobby/LobbyServer.cpp index 8effc59af..d3e5c6688 100644 --- a/lobby/LobbyServer.cpp +++ b/lobby/LobbyServer.cpp @@ -841,6 +841,16 @@ LobbyServer::LobbyServer(const boost::filesystem::path & databasePath) { } +LobbyDatabase * LobbyServer::getDatabase() const +{ + return database.get(); +} + +NetworkContext & LobbyServer::getNetworkContext() +{ + return networkHandler->getContext(); +} + void LobbyServer::start(uint16_t port) { networkServer->start(port); diff --git a/lobby/LobbyServer.h b/lobby/LobbyServer.h index 2ccff59cf..acb781bd9 100644 --- a/lobby/LobbyServer.h +++ b/lobby/LobbyServer.h @@ -9,7 +9,7 @@ */ #pragma once -#include "../lib/network/NetworkInterface.h" +#include "../lib/network/NetworkDefines.h" #include "LobbyDefines.h" VCMI_LIB_NAMESPACE_BEGIN @@ -101,4 +101,7 @@ public: void start(uint16_t port); void run(); + + LobbyDatabase * getDatabase() const; + NetworkContext & getNetworkContext(); }; diff --git a/lobby/web/openapi.yaml b/lobby/web/openapi.yaml new file mode 100644 index 000000000..35952a3de --- /dev/null +++ b/lobby/web/openapi.yaml @@ -0,0 +1,215 @@ +openapi: 3.0.0 +info: + title: VCMI Lobby API + description: REST API for VCMI Lobby Server statistics and information + version: 1.0.0 +servers: + - url: /api/v1 + description: API v1 +paths: + /stats: + get: + summary: Get lobby statistics + description: Returns comprehensive statistics about the VCMI lobby including online players, registered players, games, and lobbies + responses: + '200': + description: Successful response + content: + application/json: + schema: + type: object + properties: + onlinePlayers: + type: array + items: + type: string + description: List of currently online player display names + onlinePlayersCount: + type: object + properties: + current: + type: integer + description: Currently online players + lastHour: + type: integer + description: Active in last hour + lastDay: + type: integer + description: Active in last 24 hours + lastWeek: + type: integer + description: Active in last week + lastMonth: + type: integer + description: Active in last month + lastYear: + type: integer + description: Active in last year + registeredPlayersCount: + type: object + properties: + total: + type: integer + description: Total registered players + lastDay: + type: integer + description: Registered in last 24 hours + lastWeek: + type: integer + description: Registered in last week + lastMonth: + type: integer + description: Registered in last month + lastYear: + type: integer + description: Registered in last year + gameCount: + type: object + properties: + current: + type: integer + description: Currently running games + total: + type: integer + description: Total completed games + lastDay: + type: integer + description: Games in last 24 hours + lastWeek: + type: integer + description: Games in last week + lastMonth: + type: integer + description: Games in last month + lastYear: + type: integer + description: Games in last year + lobbyCount: + type: object + properties: + current: + type: integer + description: Currently open lobbies + public: + type: integer + description: Public lobbies + private: + type: integer + description: Private lobbies + lobbyStartTime: + type: string + format: date-time + description: Server start timestamp + server: + type: string + description: Server name + apiVersion: + type: string + description: API version + /chats: + get: + summary: Get public chat messages + description: Returns recent messages from a public chat channel + parameters: + - in: query + name: channelName + schema: + type: string + default: english + required: false + description: Name of the chat channel (default is 'english') + responses: + '200': + description: Successful response + content: + application/json: + schema: + type: object + properties: + channelName: + type: string + description: Name of the requested chat channel + messages: + type: array + items: + type: object + properties: + displayName: + type: string + description: Display name of the message sender + messageText: + type: string + description: The message content + ageSeconds: + type: integer + description: Message age in seconds + timestamp: + type: string + format: date-time + description: Message creation timestamp + /rooms: + get: + summary: Get game rooms + description: Returns recent game rooms with optional time filter + parameters: + - in: query + name: hours + schema: + type: integer + default: -1 + required: false + description: Filter rooms created in last N hours (-1 for all rooms, default is -1) + - in: query + name: limit + schema: + type: integer + default: 50 + minimum: 1 + maximum: 250 + required: false + description: Maximum number of rooms to return (default 50, max 250) + responses: + '200': + description: Successful response + content: + application/json: + schema: + type: object + properties: + hours: + type: integer + description: Hours filter applied + limit: + type: integer + description: Limit applied + count: + type: integer + description: Number of rooms returned + rooms: + type: array + items: + type: object + properties: + description: + type: string + description: Room description + status: + type: integer + description: Room status (0=preparing, 1=public, 2=private, 3=busy, 4=closed, 5=abandoned) + playerLimit: + type: integer + description: Maximum number of players + version: + type: string + description: Game version + secondsElapsed: + type: integer + description: Seconds since room creation + createdAt: + type: string + format: date-time + description: Room creation timestamp + mods: + type: object + description: JSON object containing mod configuration + diff --git a/lobby/web/swagger.html b/lobby/web/swagger.html new file mode 100644 index 000000000..811a6d379 --- /dev/null +++ b/lobby/web/swagger.html @@ -0,0 +1,26 @@ + + + + + VCMI Lobby API - Swagger UI + + + +
+ + + + +