From 08bd6f8371b8ace6b0454b6bbf8d284bd370dc5c Mon Sep 17 00:00:00 2001 From: Laserlicht <13953785+Laserlicht@users.noreply.github.com> Date: Sat, 17 Jan 2026 23:21:05 +0100 Subject: [PATCH 01/22] basic api implementation --- lobby/CMakeLists.txt | 2 + lobby/EntryPoint.cpp | 17 ++++++ lobby/HttpApiServer.cpp | 132 ++++++++++++++++++++++++++++++++++++++++ lobby/HttpApiServer.h | 45 ++++++++++++++ lobby/LobbyServer.cpp | 5 ++ lobby/LobbyServer.h | 2 + 6 files changed, 203 insertions(+) create mode 100644 lobby/HttpApiServer.cpp create mode 100644 lobby/HttpApiServer.h diff --git a/lobby/CMakeLists.txt b/lobby/CMakeLists.txt index b4169ab09..ffc7b3000 100644 --- a/lobby/CMakeLists.txt +++ b/lobby/CMakeLists.txt @@ -2,6 +2,7 @@ set(lobby_SRCS StdInc.cpp EntryPoint.cpp + HttpApiServer.cpp LobbyDatabase.cpp LobbyServer.cpp SQLiteConnection.cpp @@ -10,6 +11,7 @@ set(lobby_SRCS set(lobby_HEADERS StdInc.h + HttpApiServer.h LobbyDatabase.h LobbyDefines.h LobbyServer.h diff --git a/lobby/EntryPoint.cpp b/lobby/EntryPoint.cpp index 250eee25a..6d5fdb66c 100644 --- a/lobby/EntryPoint.cpp +++ b/lobby/EntryPoint.cpp @@ -10,6 +10,7 @@ #include "StdInc.h" #include "LobbyServer.h" +#include "HttpApiServer.h" #include "../lib/CConsoleHandler.h" #include "../lib/logging/CBasicLogConfigurator.h" @@ -18,6 +19,7 @@ #include "../lib/VCMIDirs.h" static const int LISTENING_PORT = 3031; +static const int HTTP_API_PORT = 3032; int main(int argc, const char * argv[]) { @@ -45,7 +47,22 @@ 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 + HttpApiServer httpServer(server, HTTP_API_PORT); + 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/HttpApiServer.cpp b/lobby/HttpApiServer.cpp new file mode 100644 index 000000000..48da24dd2 --- /dev/null +++ b/lobby/HttpApiServer.cpp @@ -0,0 +1,132 @@ +/* + * HttpApiServer.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 "HttpApiServer.h" +#include "LobbyServer.h" +#include "LobbyDatabase.h" + +#include "../lib/json/JsonNode.h" +#include "../lib/logging/CLogger.h" +#include "../lib/GameConstants.h" + +namespace beast = boost::beast; +namespace http = beast::http; +namespace net = boost::asio; +using tcp = net::ip::tcp; + +HttpApiServer::HttpApiServer(LobbyServer & lobbyServer, unsigned short port) + : lobbyServer(lobbyServer) + , port(port) + , running(false) +{ +} + +HttpApiServer::~HttpApiServer() +{ + stop(); +} + +void HttpApiServer::start() +{ + running = true; + thread = std::make_unique([this]() { run(); }); + logGlobal->info("HTTP API Server started on port %d", port); + startTime = std::chrono::system_clock::now(); +} + +void HttpApiServer::stop() +{ + if (running) + { + running = false; + ioc.stop(); + if (thread && thread->joinable()) + thread->join(); + logGlobal->info("HTTP API Server stopped"); + } +} + +void HttpApiServer::run() +{ + try + { + tcp::acceptor acceptor{ioc, {tcp::v4(), port}}; + + while (running) + { + tcp::socket socket{ioc}; + acceptor.accept(socket); + + beast::tcp_stream stream(std::move(socket)); + beast::flat_buffer buffer; + + http::request req; + http::read(stream, buffer, req); + + handleRequest(std::move(req), stream); + } + } + catch (const std::exception & e) + { + logGlobal->error("HTTP API Server error: %s", e.what()); + } +} + +void HttpApiServer::handleRequest(http::request && req, beast::tcp_stream & stream) +{ + auto const createResponse = [&req](http::status status, const std::string & body) + { + http::response res{status, req.version()}; + res.set(http::field::server, "VCMI-Lobby-API"); + res.set(http::field::content_type, "application/json"); + res.keep_alive(req.keep_alive()); + res.body() = body; + res.prepare_payload(); + return res; + }; + + try + { + if (req.target() == "/api/v1/stats") + { + JsonNode stats = getStats(); + std::string json = stats.toCompactString(); + auto res = createResponse(http::status::ok, json); + http::write(stream, res); + } + else + { + // 404 Not Found + std::string json = R"({ "error": "Not Found", "message": "The requested endpoint does not exist" })"; + auto res = createResponse(http::status::not_found, json); + http::write(stream, res); + } + + // Graceful shutdown + beast::error_code ec; + stream.socket().shutdown(tcp::socket::shutdown_send, ec); + } + catch (const std::exception & e) + { + logGlobal->error("Error handling HTTP request: %s", e.what()); + } +} + +JsonNode HttpApiServer::getStats() +{ + JsonNode stats; + stats["totalGames"].Integer() = 0; + stats["activePlayers"].Integer() = lobbyServer.getDatabase()->getActiveAccounts().size(); + stats["startTime"].String() = std::format("{:%Y-%m-%dT%H:%M:%S}", startTime); + stats["server"].String() = "VCMI Lobby"; + stats["lobbyVersion"].String() = GameConstants::VCMI_VERSION; + stats["apiVersion"].String() = "1.0"; + return stats; +} diff --git a/lobby/HttpApiServer.h b/lobby/HttpApiServer.h new file mode 100644 index 000000000..0be401565 --- /dev/null +++ b/lobby/HttpApiServer.h @@ -0,0 +1,45 @@ +/* + * HttpApiServer.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 +#include +#include + +VCMI_LIB_NAMESPACE_BEGIN +class JsonNode; +VCMI_LIB_NAMESPACE_END + +class LobbyServer; + +class HttpApiServer +{ +public: + HttpApiServer(LobbyServer & lobbyServer, unsigned short port); + ~HttpApiServer(); + + void start(); + void stop(); + +private: + void run(); + void handleRequest(boost::beast::http::request && req, + boost::beast::tcp_stream & stream); + + JsonNode getStats(); + + LobbyServer & lobbyServer; + unsigned short port; + boost::asio::io_context ioc; + std::unique_ptr thread; + bool running; + std::chrono::system_clock::time_point startTime; +}; diff --git a/lobby/LobbyServer.cpp b/lobby/LobbyServer.cpp index 8effc59af..8eab15868 100644 --- a/lobby/LobbyServer.cpp +++ b/lobby/LobbyServer.cpp @@ -850,3 +850,8 @@ void LobbyServer::run() { networkHandler->run(); } + +LobbyDatabase * LobbyServer::getDatabase() const +{ + return database.get(); +} diff --git a/lobby/LobbyServer.h b/lobby/LobbyServer.h index 2ccff59cf..2a39ac31c 100644 --- a/lobby/LobbyServer.h +++ b/lobby/LobbyServer.h @@ -101,4 +101,6 @@ public: void start(uint16_t port); void run(); + + LobbyDatabase * getDatabase() const; }; From dfdc446f90f5c8537c8213ddfb526091a866143a Mon Sep 17 00:00:00 2001 From: Laserlicht <13953785+Laserlicht@users.noreply.github.com> Date: Sun, 18 Jan 2026 00:35:41 +0100 Subject: [PATCH 02/22] add api content --- lobby/HttpApiServer.cpp | 39 +++++++++++++++-- lobby/LobbyDatabase.cpp | 95 +++++++++++++++++++++++++++++++++++++++++ lobby/LobbyDatabase.h | 9 ++++ 3 files changed, 140 insertions(+), 3 deletions(-) diff --git a/lobby/HttpApiServer.cpp b/lobby/HttpApiServer.cpp index 48da24dd2..0b0ee2030 100644 --- a/lobby/HttpApiServer.cpp +++ b/lobby/HttpApiServer.cpp @@ -122,9 +122,42 @@ void HttpApiServer::handleRequest(http::request && req, beast JsonNode HttpApiServer::getStats() { JsonNode stats; - stats["totalGames"].Integer() = 0; - stats["activePlayers"].Integer() = lobbyServer.getDatabase()->getActiveAccounts().size(); - stats["startTime"].String() = std::format("{:%Y-%m-%dT%H:%M:%S}", startTime); + stats["onlinePlayers"].Vector() = JsonVector(); + for (const auto & player : lobbyServer.getDatabase()->getActiveAccounts()) + stats["onlinePlayers"].Vector().push_back(JsonNode(player.displayName)); + stats["onlinePlayersCount"].Struct() = JsonMap{ + {"current", JsonNode(static_cast(stats["onlinePlayers"].Vector().size()))}, + {"lastHour", JsonNode(lobbyServer.getDatabase()->getActiveAccountsCount(1))}, + {"lastDay", JsonNode(lobbyServer.getDatabase()->getActiveAccountsCount(24))}, + {"lastWeek", JsonNode(lobbyServer.getDatabase()->getActiveAccountsCount(168))}, + {"lastMonth", JsonNode(lobbyServer.getDatabase()->getActiveAccountsCount(720))}, + {"lastYear", JsonNode(lobbyServer.getDatabase()->getActiveAccountsCount(8760))} + }; + stats["registeredPlayersCount"].Struct() = JsonMap{ + {"total", JsonNode(lobbyServer.getDatabase()->getAccountCount())}, + {"lastDay", JsonNode(lobbyServer.getDatabase()->getRegisteredAccountsCount(24))}, + {"lastWeek", JsonNode(lobbyServer.getDatabase()->getRegisteredAccountsCount(168))}, + {"lastMonth", JsonNode(lobbyServer.getDatabase()->getRegisteredAccountsCount(720))}, + {"lastYear", JsonNode(lobbyServer.getDatabase()->getRegisteredAccountsCount(8760))} + }; + std::map lobbysCount; + for (const auto & room : lobbyServer.getDatabase()->getActiveGameRooms()) + lobbysCount[room.roomState]++; + stats["gameCount"].Struct() = JsonMap{ + {"current", JsonNode(lobbysCount[LobbyRoomState::BUSY])}, + {"total", JsonNode(lobbyServer.getDatabase()->getClosedGameRoomsCount())}, + {"lastDay", JsonNode(lobbyServer.getDatabase()->getClosedGameRoomsCount(24))}, + {"lastWeek", JsonNode(lobbyServer.getDatabase()->getClosedGameRoomsCount(168))}, + {"lastMonth", JsonNode(lobbyServer.getDatabase()->getClosedGameRoomsCount(720))}, + {"lastYear", JsonNode(lobbyServer.getDatabase()->getClosedGameRoomsCount(8760))} + }; + 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["registeredPlayersCount"].Integer() = lobbyServer.getDatabase()->getAccountCount(); + stats["lobbyStartTime"].String() = std::format("{:%Y-%m-%dT%H:%M:%S}", startTime); stats["server"].String() = "VCMI Lobby"; stats["lobbyVersion"].String() = GameConstants::VCMI_VERSION; stats["apiVersion"].String() = "1.0"; diff --git a/lobby/LobbyDatabase.cpp b/lobby/LobbyDatabase.cpp index 3b32c8e02..4f6ac3476 100644 --- a/lobby/LobbyDatabase.cpp +++ b/lobby/LobbyDatabase.cpp @@ -304,6 +304,35 @@ void LobbyDatabase::prepareStatements() WHERE accountID = ? )"); + getAccountCountStatement = database->prepare(R"( + SELECT COUNT(*) + FROM accounts + )"); + + getActiveAccountsCountStatement = database->prepare(R"( + SELECT COUNT(*) + FROM accounts + WHERE lastLoginTime >= datetime('now', '-' || ? || ' hours') + )"); + + getRegisteredAccountsCountStatement = database->prepare(R"( + SELECT COUNT(*) + FROM accounts + WHERE creationTime >= datetime('now', '-' || ? || ' hours') + )"); + + getClosedGameRoomsCountStatement = database->prepare(R"( + SELECT COUNT(*) + FROM gameRooms + WHERE status = 5 AND creationTime >= datetime('now', '-' || ? || ' hours') + )"); + + getClosedGameRoomsCountAllStatement = database->prepare(R"( + SELECT COUNT(*) + FROM gameRooms + WHERE status = 5 + )");; + isAccountCookieValidStatement = database->prepare(R"( SELECT COUNT(accountID) FROM accountCookies @@ -481,6 +510,72 @@ std::string LobbyDatabase::getAccountDisplayName(const std::string & accountID) return result; } +int LobbyDatabase::getAccountCount() +{ + int result; + + if(getAccountCountStatement->execute()) + getAccountCountStatement->getColumns(result); + getAccountCountStatement->reset(); + + return result; +} + +int LobbyDatabase::getActiveAccountsCount(int hours) +{ + int result = 0; + + getActiveAccountsCountStatement->reset(); + getActiveAccountsCountStatement->setBinds(hours); + + if(getActiveAccountsCountStatement->execute()) + getActiveAccountsCountStatement->getColumns(result); + + getActiveAccountsCountStatement->reset(); + + return result; +} + +int LobbyDatabase::getRegisteredAccountsCount(int hours) +{ + int result = 0; + + getRegisteredAccountsCountStatement->reset(); + getRegisteredAccountsCountStatement->setBinds(hours); + + if(getRegisteredAccountsCountStatement->execute()) + getRegisteredAccountsCountStatement->getColumns(result); + + getRegisteredAccountsCountStatement->reset(); + + return result; +} + +int LobbyDatabase::getClosedGameRoomsCount(int hours) +{ + int result = 0; + + if(hours == -1) + { + getClosedGameRoomsCountAllStatement->reset(); + if(getClosedGameRoomsCountAllStatement->execute()) + getClosedGameRoomsCountAllStatement->getColumns(result); + getClosedGameRoomsCountAllStatement->reset(); + } + else + { + getClosedGameRoomsCountStatement->reset(); + getClosedGameRoomsCountStatement->setBinds(hours); + + if(getClosedGameRoomsCountStatement->execute()) + getClosedGameRoomsCountStatement->getColumns(result); + + getClosedGameRoomsCountStatement->reset(); + } + + return result; +} + LobbyCookieStatus LobbyDatabase::getAccountCookieStatus(const std::string & accountID, const std::string & accessCookieUUID) { bool result = false; diff --git a/lobby/LobbyDatabase.h b/lobby/LobbyDatabase.h index ffc6d5c8c..849011705 100644 --- a/lobby/LobbyDatabase.h +++ b/lobby/LobbyDatabase.h @@ -47,6 +47,11 @@ class LobbyDatabase SQLiteStatementPtr getAccountInviteStatusStatement; SQLiteStatementPtr getAccountGameRoomStatement; SQLiteStatementPtr getAccountDisplayNameStatement; + SQLiteStatementPtr getAccountCountStatement; + SQLiteStatementPtr getActiveAccountsCountStatement; + SQLiteStatementPtr getRegisteredAccountsCountStatement; + SQLiteStatementPtr getClosedGameRoomsCountStatement; + SQLiteStatementPtr getClosedGameRoomsCountAllStatement; SQLiteStatementPtr getGameRoomPlayersStatement; SQLiteStatementPtr getGameRoomInvitesStatement; SQLiteStatementPtr countRoomUsedSlotsStatement; @@ -95,6 +100,10 @@ public: std::string getIdleGameRoom(const std::string & hostAccountID); std::string getAccountGameRoom(const std::string & accountID); std::string getAccountDisplayName(const std::string & accountID); + int getAccountCount(); + int getActiveAccountsCount(int hours); + int getRegisteredAccountsCount(int hours); + int getClosedGameRoomsCount(int hours = -1); LobbyCookieStatus getAccountCookieStatus(const std::string & accountID, const std::string & accessCookieUUID); LobbyInviteStatus getAccountInviteStatus(const std::string & accountID, const std::string & roomID); From d4c2530cb868671726298858c1256842a8b34a49 Mon Sep 17 00:00:00 2001 From: Laserlicht <13953785+Laserlicht@users.noreply.github.com> Date: Sun, 18 Jan 2026 00:56:17 +0100 Subject: [PATCH 03/22] swagger --- cmake_modules/embed_file.cmake | 52 +++++++++++++++ lobby/CMakeLists.txt | 23 +++++++ lobby/HttpApiServer.cpp | 29 ++++++++- lobby/HttpApiServer.h | 2 + lobby/web/openapi.yaml | 111 +++++++++++++++++++++++++++++++++ lobby/web/swagger.html | 26 ++++++++ 6 files changed, 240 insertions(+), 3 deletions(-) create mode 100644 cmake_modules/embed_file.cmake create mode 100644 lobby/web/openapi.yaml create mode 100644 lobby/web/swagger.html 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/lobby/CMakeLists.txt b/lobby/CMakeLists.txt index ffc7b3000..c834a095b 100644 --- a/lobby/CMakeLists.txt +++ b/lobby/CMakeLists.txt @@ -18,6 +18,28 @@ set(lobby_HEADERS 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}) @@ -31,6 +53,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/HttpApiServer.cpp b/lobby/HttpApiServer.cpp index 0b0ee2030..d274165ac 100644 --- a/lobby/HttpApiServer.cpp +++ b/lobby/HttpApiServer.cpp @@ -11,6 +11,7 @@ #include "HttpApiServer.h" #include "LobbyServer.h" #include "LobbyDatabase.h" +#include "EmbeddedWebAssets.h" #include "../lib/json/JsonNode.h" #include "../lib/logging/CLogger.h" @@ -81,11 +82,11 @@ void HttpApiServer::run() void HttpApiServer::handleRequest(http::request && req, beast::tcp_stream & stream) { - auto const createResponse = [&req](http::status status, const std::string & body) + auto const createResponse = [&req](http::status status, const std::string & body, const std::string & contentType = "application/json") { http::response res{status, req.version()}; res.set(http::field::server, "VCMI-Lobby-API"); - res.set(http::field::content_type, "application/json"); + res.set(http::field::content_type, contentType); res.keep_alive(req.keep_alive()); res.body() = body; res.prepare_payload(); @@ -101,6 +102,18 @@ void HttpApiServer::handleRequest(http::request && req, beast auto res = createResponse(http::status::ok, json); http::write(stream, res); } + else if (req.target() == "/api/docs" || req.target() == "/") + { + std::string html = getSwaggerUI(); + auto res = createResponse(http::status::ok, html, "text/html"); + http::write(stream, res); + } + else if (req.target() == "/api/openapi.yaml") + { + std::string spec = getSwaggerSpec(); + auto res = createResponse(http::status::ok, spec, "text/yaml"); + http::write(stream, res); + } else { // 404 Not Found @@ -157,9 +170,19 @@ JsonNode HttpApiServer::getStats() {"private", JsonNode(static_cast(lobbysCount[LobbyRoomState::PRIVATE]))} }; stats["registeredPlayersCount"].Integer() = lobbyServer.getDatabase()->getAccountCount(); - stats["lobbyStartTime"].String() = std::format("{:%Y-%m-%dT%H:%M:%S}", startTime); + stats["lobbyStartTime"].String() = std::format("{:%Y-%m-%dT%H:%M:%S%z}", startTime); stats["server"].String() = "VCMI Lobby"; stats["lobbyVersion"].String() = GameConstants::VCMI_VERSION; stats["apiVersion"].String() = "1.0"; return stats; } + +std::string HttpApiServer::getSwaggerUI() +{ + return EmbeddedFiles::SWAGGER_CONTENT; +} + +std::string HttpApiServer::getSwaggerSpec() +{ + return EmbeddedFiles::OPENAPI_CONTENT; +} diff --git a/lobby/HttpApiServer.h b/lobby/HttpApiServer.h index 0be401565..c2b48816f 100644 --- a/lobby/HttpApiServer.h +++ b/lobby/HttpApiServer.h @@ -35,6 +35,8 @@ private: boost::beast::tcp_stream & stream); JsonNode getStats(); + std::string getSwaggerUI(); + std::string getSwaggerSpec(); LobbyServer & lobbyServer; unsigned short port; diff --git a/lobby/web/openapi.yaml b/lobby/web/openapi.yaml new file mode 100644 index 000000000..fd9486226 --- /dev/null +++ b/lobby/web/openapi.yaml @@ -0,0 +1,111 @@ +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 + lobbyVersion: + type: string + description: VCMI version + apiVersion: + type: string + description: API version 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 + + + +
+ + + + + From 6f073d6a74d79dc199179a613b25f96534970d39 Mon Sep 17 00:00:00 2001 From: Laserlicht <13953785+Laserlicht@users.noreply.github.com> Date: Sun, 18 Jan 2026 01:13:52 +0100 Subject: [PATCH 04/22] chat messages --- lobby/HttpApiServer.cpp | 54 ++++++++++++++++++++++++++++++++++------- lobby/HttpApiServer.h | 3 +-- lobby/web/openapi.yaml | 37 ++++++++++++++++++++++++++++ 3 files changed, 83 insertions(+), 11 deletions(-) diff --git a/lobby/HttpApiServer.cpp b/lobby/HttpApiServer.cpp index d274165ac..7d200a5e8 100644 --- a/lobby/HttpApiServer.cpp +++ b/lobby/HttpApiServer.cpp @@ -102,15 +102,38 @@ void HttpApiServer::handleRequest(http::request && req, beast auto res = createResponse(http::status::ok, json); http::write(stream, res); } + else if (req.target().starts_with("/api/v1/chats")) + { + // Parse query parameters + std::string channelName = "english"; + auto target = std::string(req.target()); + auto queryPos = target.find('?'); + if (queryPos != std::string::npos) + { + auto query = target.substr(queryPos + 1); + auto channelPos = query.find("channelName="); + if (channelPos != std::string::npos) + { + auto valueStart = channelPos + 12; // length of "channelName=" + auto valueEnd = query.find('&', valueStart); + channelName = query.substr(valueStart, valueEnd == std::string::npos ? std::string::npos : valueEnd - valueStart); + } + } + + JsonNode chats = getChats(channelName); + std::string json = chats.toCompactString(); + auto res = createResponse(http::status::ok, json); + http::write(stream, res); + } else if (req.target() == "/api/docs" || req.target() == "/") { - std::string html = getSwaggerUI(); + std::string html = EmbeddedFiles::SWAGGER_CONTENT; auto res = createResponse(http::status::ok, html, "text/html"); http::write(stream, res); } else if (req.target() == "/api/openapi.yaml") { - std::string spec = getSwaggerSpec(); + std::string spec = EmbeddedFiles::OPENAPI_CONTENT; auto res = createResponse(http::status::ok, spec, "text/yaml"); http::write(stream, res); } @@ -177,12 +200,25 @@ JsonNode HttpApiServer::getStats() return stats; } -std::string HttpApiServer::getSwaggerUI() +JsonNode HttpApiServer::getChats(const std::string & channelName) { - return EmbeddedFiles::SWAGGER_CONTENT; -} - -std::string HttpApiServer::getSwaggerSpec() -{ - return EmbeddedFiles::OPENAPI_CONTENT; + JsonNode chats; + chats["messages"].Vector() = JsonVector(); + chats["channelName"].String() = channelName; + + auto messages = lobbyServer.getDatabase()->getRecentMessageHistory("global", channelName); + + for (const auto & msg : messages) + { + JsonNode message; + message["accountID"].String() = msg.accountID; + message["displayName"].String() = msg.displayName; + message["messageText"].String() = msg.messageText; + message["ageSeconds"].Integer() = msg.age.count(); + chats["messages"].Vector().push_back(message); + } + + chats["count"].Integer() = chats["messages"].Vector().size(); + + return chats; } diff --git a/lobby/HttpApiServer.h b/lobby/HttpApiServer.h index c2b48816f..62efd9a55 100644 --- a/lobby/HttpApiServer.h +++ b/lobby/HttpApiServer.h @@ -35,8 +35,7 @@ private: boost::beast::tcp_stream & stream); JsonNode getStats(); - std::string getSwaggerUI(); - std::string getSwaggerSpec(); + JsonNode getChats(const std::string & channelName); LobbyServer & lobbyServer; unsigned short port; diff --git a/lobby/web/openapi.yaml b/lobby/web/openapi.yaml index fd9486226..e5f1ff395 100644 --- a/lobby/web/openapi.yaml +++ b/lobby/web/openapi.yaml @@ -109,3 +109,40 @@ paths: 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 From 1bb26af6f2fd72bfe0fb3f2187261173e8e11ce1 Mon Sep 17 00:00:00 2001 From: Laserlicht <13953785+Laserlicht@users.noreply.github.com> Date: Sun, 18 Jan 2026 01:38:54 +0100 Subject: [PATCH 05/22] get rooms api --- lobby/HttpApiServer.cpp | 77 +++++++++++++++++++++++++++++++++++++++++ lobby/HttpApiServer.h | 1 + lobby/LobbyDatabase.cpp | 48 +++++++++++++++++++++++++ lobby/LobbyDatabase.h | 2 ++ lobby/web/openapi.yaml | 62 +++++++++++++++++++++++++++++++++ 5 files changed, 190 insertions(+) diff --git a/lobby/HttpApiServer.cpp b/lobby/HttpApiServer.cpp index 7d200a5e8..ea7f9fada 100644 --- a/lobby/HttpApiServer.cpp +++ b/lobby/HttpApiServer.cpp @@ -125,6 +125,49 @@ void HttpApiServer::handleRequest(http::request && req, beast auto res = createResponse(http::status::ok, json); http::write(stream, res); } + else if (req.target().starts_with("/api/v1/rooms")) + { + // Parse query parameters + int hours = -1; + int limit = 50; + auto target = std::string(req.target()); + auto queryPos = target.find('?'); + if (queryPos != std::string::npos) + { + auto query = target.substr(queryPos + 1); + auto hoursPos = query.find("hours="); + if (hoursPos != std::string::npos) + { + auto valueStart = hoursPos + 6; // length of "hours=" + auto valueEnd = query.find('&', valueStart); + std::string hoursStr = query.substr(valueStart, valueEnd == std::string::npos ? std::string::npos : valueEnd - valueStart); + try { + hours = std::stoi(hoursStr); + } catch(...) { + hours = -1; + } + } + auto limitPos = query.find("limit="); + if (limitPos != std::string::npos) + { + auto valueStart = limitPos + 6; // length of "limit=" + auto valueEnd = query.find('&', valueStart); + std::string limitStr = query.substr(valueStart, valueEnd == std::string::npos ? std::string::npos : valueEnd - valueStart); + try { + limit = std::stoi(limitStr); + if (limit > 250) limit = 250; + if (limit < 1) limit = 50; + } catch(...) { + limit = 50; + } + } + } + + JsonNode rooms = getRooms(hours, limit); + std::string json = rooms.toCompactString(); + auto res = createResponse(http::status::ok, json); + http::write(stream, res); + } else if (req.target() == "/api/docs" || req.target() == "/") { std::string html = EmbeddedFiles::SWAGGER_CONTENT; @@ -222,3 +265,37 @@ JsonNode HttpApiServer::getChats(const std::string & channelName) return chats; } + +JsonNode HttpApiServer::getRooms(int hours, int limit) +{ + JsonNode result; + result["rooms"].Vector() = JsonVector(); + result["hours"].Integer() = hours; + result["limit"].Integer() = limit; + + auto rooms = lobbyServer.getDatabase()->getRooms(hours, limit); + + for (const auto & room : rooms) + { + 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(); + + // Parse mods JSON string + try { + JsonNode modsNode(reinterpret_cast(room.modsJson.data()), room.modsJson.size(), ""); + roomNode["mods"] = modsNode; + } catch(...) { + roomNode["mods"].Struct() = JsonMap{}; + } + + result["rooms"].Vector().push_back(roomNode); + } + + result["count"].Integer() = result["rooms"].Vector().size(); + + return result; +} diff --git a/lobby/HttpApiServer.h b/lobby/HttpApiServer.h index 62efd9a55..8ae029e4b 100644 --- a/lobby/HttpApiServer.h +++ b/lobby/HttpApiServer.h @@ -36,6 +36,7 @@ private: JsonNode getStats(); JsonNode getChats(const std::string & channelName); + JsonNode getRooms(int hours, int limit); LobbyServer & lobbyServer; unsigned short port; diff --git a/lobby/LobbyDatabase.cpp b/lobby/LobbyDatabase.cpp index 4f6ac3476..ca47ebae6 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 @@ -740,6 +749,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 849011705..1b3f8f04f 100644 --- a/lobby/LobbyDatabase.h +++ b/lobby/LobbyDatabase.h @@ -52,6 +52,7 @@ class LobbyDatabase SQLiteStatementPtr getRegisteredAccountsCountStatement; SQLiteStatementPtr getClosedGameRoomsCountStatement; SQLiteStatementPtr getClosedGameRoomsCountAllStatement; + SQLiteStatementPtr getRoomsStatement; SQLiteStatementPtr getGameRoomPlayersStatement; SQLiteStatementPtr getGameRoomInvitesStatement; SQLiteStatementPtr countRoomUsedSlotsStatement; @@ -94,6 +95,7 @@ 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); diff --git a/lobby/web/openapi.yaml b/lobby/web/openapi.yaml index e5f1ff395..e1883e87e 100644 --- a/lobby/web/openapi.yaml +++ b/lobby/web/openapi.yaml @@ -146,3 +146,65 @@ paths: ageSeconds: type: integer description: Message age in seconds + /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 + mods: + type: object + description: JSON object containing mod configuration + From a1a9d182d6848966b5ad3d7aa657bf799025ac6b Mon Sep 17 00:00:00 2001 From: Laserlicht <13953785+Laserlicht@users.noreply.github.com> Date: Sun, 18 Jan 2026 01:57:28 +0100 Subject: [PATCH 06/22] logging & ipv6 for http --- lobby/HttpApiServer.cpp | 94 +++++++++++++++++++++++++---------------- lobby/HttpApiServer.h | 5 +-- 2 files changed, 60 insertions(+), 39 deletions(-) diff --git a/lobby/HttpApiServer.cpp b/lobby/HttpApiServer.cpp index ea7f9fada..1defc58e2 100644 --- a/lobby/HttpApiServer.cpp +++ b/lobby/HttpApiServer.cpp @@ -39,7 +39,7 @@ void HttpApiServer::start() running = true; thread = std::make_unique([this]() { run(); }); logGlobal->info("HTTP API Server started on port %d", port); - startTime = std::chrono::system_clock::now(); + startTime = std::chrono::system_clock::now(); } void HttpApiServer::stop() @@ -58,7 +58,13 @@ void HttpApiServer::run() { try { - tcp::acceptor acceptor{ioc, {tcp::v4(), port}}; + tcp::acceptor acceptor{ioc}; + 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(); while (running) { @@ -82,6 +88,22 @@ void HttpApiServer::run() void HttpApiServer::handleRequest(http::request && req, beast::tcp_stream & stream) { + // Log the request + std::string clientIP = "unknown"; + try { + auto endpoint = stream.socket().remote_endpoint(); + clientIP = endpoint.address().to_string(); + } catch(...) {} + + 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()); + auto const createResponse = [&req](http::status status, const std::string & body, const std::string & contentType = "application/json") { http::response res{status, req.version()}; @@ -202,40 +224,40 @@ JsonNode HttpApiServer::getStats() { JsonNode stats; stats["onlinePlayers"].Vector() = JsonVector(); - for (const auto & player : lobbyServer.getDatabase()->getActiveAccounts()) - stats["onlinePlayers"].Vector().push_back(JsonNode(player.displayName)); - stats["onlinePlayersCount"].Struct() = JsonMap{ - {"current", JsonNode(static_cast(stats["onlinePlayers"].Vector().size()))}, - {"lastHour", JsonNode(lobbyServer.getDatabase()->getActiveAccountsCount(1))}, - {"lastDay", JsonNode(lobbyServer.getDatabase()->getActiveAccountsCount(24))}, - {"lastWeek", JsonNode(lobbyServer.getDatabase()->getActiveAccountsCount(168))}, - {"lastMonth", JsonNode(lobbyServer.getDatabase()->getActiveAccountsCount(720))}, - {"lastYear", JsonNode(lobbyServer.getDatabase()->getActiveAccountsCount(8760))} - }; - stats["registeredPlayersCount"].Struct() = JsonMap{ - {"total", JsonNode(lobbyServer.getDatabase()->getAccountCount())}, - {"lastDay", JsonNode(lobbyServer.getDatabase()->getRegisteredAccountsCount(24))}, - {"lastWeek", JsonNode(lobbyServer.getDatabase()->getRegisteredAccountsCount(168))}, - {"lastMonth", JsonNode(lobbyServer.getDatabase()->getRegisteredAccountsCount(720))}, - {"lastYear", JsonNode(lobbyServer.getDatabase()->getRegisteredAccountsCount(8760))} - }; - std::map lobbysCount; - for (const auto & room : lobbyServer.getDatabase()->getActiveGameRooms()) - lobbysCount[room.roomState]++; - stats["gameCount"].Struct() = JsonMap{ - {"current", JsonNode(lobbysCount[LobbyRoomState::BUSY])}, - {"total", JsonNode(lobbyServer.getDatabase()->getClosedGameRoomsCount())}, - {"lastDay", JsonNode(lobbyServer.getDatabase()->getClosedGameRoomsCount(24))}, - {"lastWeek", JsonNode(lobbyServer.getDatabase()->getClosedGameRoomsCount(168))}, - {"lastMonth", JsonNode(lobbyServer.getDatabase()->getClosedGameRoomsCount(720))}, - {"lastYear", JsonNode(lobbyServer.getDatabase()->getClosedGameRoomsCount(8760))} - }; - 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["registeredPlayersCount"].Integer() = lobbyServer.getDatabase()->getAccountCount(); + for (const auto & player : lobbyServer.getDatabase()->getActiveAccounts()) + stats["onlinePlayers"].Vector().push_back(JsonNode(player.displayName)); + stats["onlinePlayersCount"].Struct() = JsonMap{ + {"current", JsonNode(static_cast(stats["onlinePlayers"].Vector().size()))}, + {"lastHour", JsonNode(lobbyServer.getDatabase()->getActiveAccountsCount(1))}, + {"lastDay", JsonNode(lobbyServer.getDatabase()->getActiveAccountsCount(24))}, + {"lastWeek", JsonNode(lobbyServer.getDatabase()->getActiveAccountsCount(168))}, + {"lastMonth", JsonNode(lobbyServer.getDatabase()->getActiveAccountsCount(720))}, + {"lastYear", JsonNode(lobbyServer.getDatabase()->getActiveAccountsCount(8760))} + }; + stats["registeredPlayersCount"].Struct() = JsonMap{ + {"total", JsonNode(lobbyServer.getDatabase()->getAccountCount())}, + {"lastDay", JsonNode(lobbyServer.getDatabase()->getRegisteredAccountsCount(24))}, + {"lastWeek", JsonNode(lobbyServer.getDatabase()->getRegisteredAccountsCount(168))}, + {"lastMonth", JsonNode(lobbyServer.getDatabase()->getRegisteredAccountsCount(720))}, + {"lastYear", JsonNode(lobbyServer.getDatabase()->getRegisteredAccountsCount(8760))} + }; + std::map lobbysCount; + for (const auto & room : lobbyServer.getDatabase()->getActiveGameRooms()) + lobbysCount[room.roomState]++; + stats["gameCount"].Struct() = JsonMap{ + {"current", JsonNode(lobbysCount[LobbyRoomState::BUSY])}, + {"total", JsonNode(lobbyServer.getDatabase()->getClosedGameRoomsCount())}, + {"lastDay", JsonNode(lobbyServer.getDatabase()->getClosedGameRoomsCount(24))}, + {"lastWeek", JsonNode(lobbyServer.getDatabase()->getClosedGameRoomsCount(168))}, + {"lastMonth", JsonNode(lobbyServer.getDatabase()->getClosedGameRoomsCount(720))}, + {"lastYear", JsonNode(lobbyServer.getDatabase()->getClosedGameRoomsCount(8760))} + }; + 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["registeredPlayersCount"].Integer() = lobbyServer.getDatabase()->getAccountCount(); stats["lobbyStartTime"].String() = std::format("{:%Y-%m-%dT%H:%M:%S%z}", startTime); stats["server"].String() = "VCMI Lobby"; stats["lobbyVersion"].String() = GameConstants::VCMI_VERSION; diff --git a/lobby/HttpApiServer.h b/lobby/HttpApiServer.h index 8ae029e4b..75b9d55c0 100644 --- a/lobby/HttpApiServer.h +++ b/lobby/HttpApiServer.h @@ -31,8 +31,7 @@ public: private: void run(); - void handleRequest(boost::beast::http::request && req, - boost::beast::tcp_stream & stream); + void handleRequest(boost::beast::http::request && req, boost::beast::tcp_stream & stream); JsonNode getStats(); JsonNode getChats(const std::string & channelName); @@ -43,5 +42,5 @@ private: boost::asio::io_context ioc; std::unique_ptr thread; bool running; - std::chrono::system_clock::time_point startTime; + std::chrono::system_clock::time_point startTime; }; From 5c658374e99584480d794643c8c95beb9093457b Mon Sep 17 00:00:00 2001 From: Laserlicht <13953785+Laserlicht@users.noreply.github.com> Date: Sun, 18 Jan 2026 02:05:20 +0100 Subject: [PATCH 07/22] async handle --- lobby/HttpApiServer.cpp | 25 +++++++++++++++++-------- lobby/HttpApiServer.h | 1 + 2 files changed, 18 insertions(+), 8 deletions(-) diff --git a/lobby/HttpApiServer.cpp b/lobby/HttpApiServer.cpp index 1defc58e2..ad4de098e 100644 --- a/lobby/HttpApiServer.cpp +++ b/lobby/HttpApiServer.cpp @@ -71,13 +71,15 @@ void HttpApiServer::run() tcp::socket socket{ioc}; acceptor.accept(socket); - beast::tcp_stream stream(std::move(socket)); - beast::flat_buffer buffer; - - http::request req; - http::read(stream, buffer, req); - - handleRequest(std::move(req), stream); + // Handle each connection asynchronously + std::thread([this, sock = std::move(socket)]() mutable { + try { + beast::tcp_stream stream(std::move(sock)); + handleSession(std::move(stream)); + } catch (const std::exception & e) { + logGlobal->error("HTTP session error: %s", e.what()); + } + }).detach(); } } catch (const std::exception & e) @@ -86,6 +88,14 @@ void HttpApiServer::run() } } +void HttpApiServer::handleSession(beast::tcp_stream stream) +{ + beast::flat_buffer buffer; + http::request req; + http::read(stream, buffer, req); + handleRequest(std::move(req), stream); +} + void HttpApiServer::handleRequest(http::request && req, beast::tcp_stream & stream) { // Log the request @@ -276,7 +286,6 @@ JsonNode HttpApiServer::getChats(const std::string & channelName) for (const auto & msg : messages) { JsonNode message; - message["accountID"].String() = msg.accountID; message["displayName"].String() = msg.displayName; message["messageText"].String() = msg.messageText; message["ageSeconds"].Integer() = msg.age.count(); diff --git a/lobby/HttpApiServer.h b/lobby/HttpApiServer.h index 75b9d55c0..f8a02b51a 100644 --- a/lobby/HttpApiServer.h +++ b/lobby/HttpApiServer.h @@ -31,6 +31,7 @@ public: private: void run(); + void handleSession(boost::beast::tcp_stream stream); void handleRequest(boost::beast::http::request && req, boost::beast::tcp_stream & stream); JsonNode getStats(); From a5a300bffc72d00106c3785dfcf6814591d4805b Mon Sep 17 00:00:00 2001 From: Laserlicht <13953785+Laserlicht@users.noreply.github.com> Date: Sun, 18 Jan 2026 02:21:44 +0100 Subject: [PATCH 08/22] fix --- lobby/LobbyDatabase.cpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lobby/LobbyDatabase.cpp b/lobby/LobbyDatabase.cpp index ca47ebae6..047e811fb 100644 --- a/lobby/LobbyDatabase.cpp +++ b/lobby/LobbyDatabase.cpp @@ -521,7 +521,7 @@ std::string LobbyDatabase::getAccountDisplayName(const std::string & accountID) int LobbyDatabase::getAccountCount() { - int result; + int result = 0; if(getAccountCountStatement->execute()) getAccountCountStatement->getColumns(result); From c53b7589f3db516ef5ae1aee86ada3195883c5e6 Mon Sep 17 00:00:00 2001 From: Laserlicht <13953785+Laserlicht@users.noreply.github.com> Date: Sun, 18 Jan 2026 02:35:38 +0100 Subject: [PATCH 09/22] fix for old cpp --- lobby/HttpApiServer.cpp | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/lobby/HttpApiServer.cpp b/lobby/HttpApiServer.cpp index ad4de098e..e67154b8e 100644 --- a/lobby/HttpApiServer.cpp +++ b/lobby/HttpApiServer.cpp @@ -268,7 +268,14 @@ JsonNode HttpApiServer::getStats() {"private", JsonNode(static_cast(lobbysCount[LobbyRoomState::PRIVATE]))} }; stats["registeredPlayersCount"].Integer() = lobbyServer.getDatabase()->getAccountCount(); - stats["lobbyStartTime"].String() = std::format("{:%Y-%m-%dT%H:%M:%S%z}", startTime); + + auto tt = std::chrono::system_clock::to_time_t(startTime); + std::tm tm{}; + localtime_r(&tt, &tm); + std::ostringstream oss; + oss << std::put_time(&tm, "%Y-%m-%dT%H:%M:%S%z"); + stats["lobbyStartTime"].String() = oss.str(); + stats["server"].String() = "VCMI Lobby"; stats["lobbyVersion"].String() = GameConstants::VCMI_VERSION; stats["apiVersion"].String() = "1.0"; From 9ea8556265f61f78c36cd8ceb73ed6592b3b3bf9 Mon Sep 17 00:00:00 2001 From: Laserlicht <13953785+Laserlicht@users.noreply.github.com> Date: Sun, 18 Jan 2026 02:51:03 +0100 Subject: [PATCH 10/22] add timestamp --- lobby/HttpApiServer.cpp | 24 ++++++++++++++++++------ lobby/HttpApiServer.h | 1 + lobby/web/openapi.yaml | 8 ++++++++ 3 files changed, 27 insertions(+), 6 deletions(-) diff --git a/lobby/HttpApiServer.cpp b/lobby/HttpApiServer.cpp index e67154b8e..9e2bc98bd 100644 --- a/lobby/HttpApiServer.cpp +++ b/lobby/HttpApiServer.cpp @@ -230,6 +230,16 @@ void HttpApiServer::handleRequest(http::request && req, beast } } +std::string HttpApiServer::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(); +} + JsonNode HttpApiServer::getStats() { JsonNode stats; @@ -269,12 +279,7 @@ JsonNode HttpApiServer::getStats() }; stats["registeredPlayersCount"].Integer() = lobbyServer.getDatabase()->getAccountCount(); - auto tt = std::chrono::system_clock::to_time_t(startTime); - std::tm tm{}; - localtime_r(&tt, &tm); - std::ostringstream oss; - oss << std::put_time(&tm, "%Y-%m-%dT%H:%M:%S%z"); - stats["lobbyStartTime"].String() = oss.str(); + stats["lobbyStartTime"].String() = formatTimestamp(startTime); stats["server"].String() = "VCMI Lobby"; stats["lobbyVersion"].String() = GameConstants::VCMI_VERSION; @@ -296,6 +301,10 @@ JsonNode HttpApiServer::getChats(const std::string & channelName) 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); } @@ -322,6 +331,9 @@ JsonNode HttpApiServer::getRooms(int hours, int limit) 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(), ""); diff --git a/lobby/HttpApiServer.h b/lobby/HttpApiServer.h index f8a02b51a..b07c79f68 100644 --- a/lobby/HttpApiServer.h +++ b/lobby/HttpApiServer.h @@ -33,6 +33,7 @@ private: void run(); void handleSession(boost::beast::tcp_stream stream); void handleRequest(boost::beast::http::request && req, boost::beast::tcp_stream & stream); + std::string formatTimestamp(std::chrono::system_clock::time_point timePoint); JsonNode getStats(); JsonNode getChats(const std::string & channelName); diff --git a/lobby/web/openapi.yaml b/lobby/web/openapi.yaml index e1883e87e..c3dda2ef0 100644 --- a/lobby/web/openapi.yaml +++ b/lobby/web/openapi.yaml @@ -146,6 +146,10 @@ paths: ageSeconds: type: integer description: Message age in seconds + timestamp: + type: string + format: date-time + description: Message creation timestamp /rooms: get: summary: Get game rooms @@ -204,6 +208,10 @@ paths: 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 From c456fe8111e6cc955d224828c755a87f58c6c75d Mon Sep 17 00:00:00 2001 From: Laserlicht <13953785+Laserlicht@users.noreply.github.com> Date: Sun, 18 Jan 2026 03:18:06 +0100 Subject: [PATCH 11/22] use readonly database instance for api --- lobby/EntryPoint.cpp | 2 +- lobby/HttpApiServer.cpp | 44 ++++++++++++++++++++--------------------- lobby/HttpApiServer.h | 7 ++++--- lobby/LobbyDatabase.cpp | 13 +++++++----- lobby/LobbyDatabase.h | 2 +- lobby/LobbyServer.cpp | 7 +------ lobby/LobbyServer.h | 2 -- 7 files changed, 37 insertions(+), 40 deletions(-) diff --git a/lobby/EntryPoint.cpp b/lobby/EntryPoint.cpp index 6d5fdb66c..9843c1210 100644 --- a/lobby/EntryPoint.cpp +++ b/lobby/EntryPoint.cpp @@ -49,7 +49,7 @@ int main(int argc, const char * argv[]) } // Start HTTP API Server - HttpApiServer httpServer(server, HTTP_API_PORT); + HttpApiServer httpServer(databasePath, HTTP_API_PORT); try { httpServer.start(); diff --git a/lobby/HttpApiServer.cpp b/lobby/HttpApiServer.cpp index 9e2bc98bd..21d6e0a0a 100644 --- a/lobby/HttpApiServer.cpp +++ b/lobby/HttpApiServer.cpp @@ -22,8 +22,8 @@ namespace http = beast::http; namespace net = boost::asio; using tcp = net::ip::tcp; -HttpApiServer::HttpApiServer(LobbyServer & lobbyServer, unsigned short port) - : lobbyServer(lobbyServer) +HttpApiServer::HttpApiServer(const boost::filesystem::path & databasePath, unsigned short port) + : database(std::make_unique(databasePath, false)) , port(port) , running(false) { @@ -244,40 +244,40 @@ JsonNode HttpApiServer::getStats() { JsonNode stats; stats["onlinePlayers"].Vector() = JsonVector(); - for (const auto & player : lobbyServer.getDatabase()->getActiveAccounts()) + for (const auto & player : database->getActiveAccounts()) stats["onlinePlayers"].Vector().push_back(JsonNode(player.displayName)); stats["onlinePlayersCount"].Struct() = JsonMap{ {"current", JsonNode(static_cast(stats["onlinePlayers"].Vector().size()))}, - {"lastHour", JsonNode(lobbyServer.getDatabase()->getActiveAccountsCount(1))}, - {"lastDay", JsonNode(lobbyServer.getDatabase()->getActiveAccountsCount(24))}, - {"lastWeek", JsonNode(lobbyServer.getDatabase()->getActiveAccountsCount(168))}, - {"lastMonth", JsonNode(lobbyServer.getDatabase()->getActiveAccountsCount(720))}, - {"lastYear", JsonNode(lobbyServer.getDatabase()->getActiveAccountsCount(8760))} + {"lastHour", JsonNode(database->getActiveAccountsCount(1))}, + {"lastDay", JsonNode(database->getActiveAccountsCount(24))}, + {"lastWeek", JsonNode(database->getActiveAccountsCount(168))}, + {"lastMonth", JsonNode(database->getActiveAccountsCount(720))}, + {"lastYear", JsonNode(database->getActiveAccountsCount(8760))} }; stats["registeredPlayersCount"].Struct() = JsonMap{ - {"total", JsonNode(lobbyServer.getDatabase()->getAccountCount())}, - {"lastDay", JsonNode(lobbyServer.getDatabase()->getRegisteredAccountsCount(24))}, - {"lastWeek", JsonNode(lobbyServer.getDatabase()->getRegisteredAccountsCount(168))}, - {"lastMonth", JsonNode(lobbyServer.getDatabase()->getRegisteredAccountsCount(720))}, - {"lastYear", JsonNode(lobbyServer.getDatabase()->getRegisteredAccountsCount(8760))} + {"total", JsonNode(database->getAccountCount())}, + {"lastDay", JsonNode(database->getRegisteredAccountsCount(24))}, + {"lastWeek", JsonNode(database->getRegisteredAccountsCount(168))}, + {"lastMonth", JsonNode(database->getRegisteredAccountsCount(720))}, + {"lastYear", JsonNode(database->getRegisteredAccountsCount(8760))} }; std::map lobbysCount; - for (const auto & room : lobbyServer.getDatabase()->getActiveGameRooms()) + for (const auto & room : database->getActiveGameRooms()) lobbysCount[room.roomState]++; stats["gameCount"].Struct() = JsonMap{ {"current", JsonNode(lobbysCount[LobbyRoomState::BUSY])}, - {"total", JsonNode(lobbyServer.getDatabase()->getClosedGameRoomsCount())}, - {"lastDay", JsonNode(lobbyServer.getDatabase()->getClosedGameRoomsCount(24))}, - {"lastWeek", JsonNode(lobbyServer.getDatabase()->getClosedGameRoomsCount(168))}, - {"lastMonth", JsonNode(lobbyServer.getDatabase()->getClosedGameRoomsCount(720))}, - {"lastYear", JsonNode(lobbyServer.getDatabase()->getClosedGameRoomsCount(8760))} + {"total", JsonNode(database->getClosedGameRoomsCount())}, + {"lastDay", JsonNode(database->getClosedGameRoomsCount(24))}, + {"lastWeek", JsonNode(database->getClosedGameRoomsCount(168))}, + {"lastMonth", JsonNode(database->getClosedGameRoomsCount(720))}, + {"lastYear", JsonNode(database->getClosedGameRoomsCount(8760))} }; 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["registeredPlayersCount"].Integer() = lobbyServer.getDatabase()->getAccountCount(); + stats["registeredPlayersCount"].Integer() = database->getAccountCount(); stats["lobbyStartTime"].String() = formatTimestamp(startTime); @@ -293,7 +293,7 @@ JsonNode HttpApiServer::getChats(const std::string & channelName) chats["messages"].Vector() = JsonVector(); chats["channelName"].String() = channelName; - auto messages = lobbyServer.getDatabase()->getRecentMessageHistory("global", channelName); + auto messages = database->getRecentMessageHistory("global", channelName); for (const auto & msg : messages) { @@ -320,7 +320,7 @@ JsonNode HttpApiServer::getRooms(int hours, int limit) result["hours"].Integer() = hours; result["limit"].Integer() = limit; - auto rooms = lobbyServer.getDatabase()->getRooms(hours, limit); + auto rooms = database->getRooms(hours, limit); for (const auto & room : rooms) { diff --git a/lobby/HttpApiServer.h b/lobby/HttpApiServer.h index b07c79f68..cff7f1982 100644 --- a/lobby/HttpApiServer.h +++ b/lobby/HttpApiServer.h @@ -18,12 +18,12 @@ VCMI_LIB_NAMESPACE_BEGIN class JsonNode; VCMI_LIB_NAMESPACE_END -class LobbyServer; +class LobbyDatabase; class HttpApiServer { public: - HttpApiServer(LobbyServer & lobbyServer, unsigned short port); + HttpApiServer(const boost::filesystem::path & databasePath, unsigned short port); ~HttpApiServer(); void start(); @@ -39,7 +39,8 @@ private: JsonNode getChats(const std::string & channelName); JsonNode getRooms(int hours, int limit); - LobbyServer & lobbyServer; + std::unique_ptr database; + unsigned short port; boost::asio::io_context ioc; std::unique_ptr thread; diff --git a/lobby/LobbyDatabase.cpp b/lobby/LobbyDatabase.cpp index 047e811fb..fc3ed1629 100644 --- a/lobby/LobbyDatabase.cpp +++ b/lobby/LobbyDatabase.cpp @@ -377,12 +377,15 @@ void LobbyDatabase::prepareStatements() LobbyDatabase::~LobbyDatabase() = default; -LobbyDatabase::LobbyDatabase(const boost::filesystem::path & databasePath) +LobbyDatabase::LobbyDatabase(const boost::filesystem::path & databasePath, bool write) { - database = SQLiteInstance::open(databasePath, true); - createTables(); - upgradeDatabase(); - clearOldData(); + database = SQLiteInstance::open(databasePath, write); + if(write) + { + createTables(); + upgradeDatabase(); + clearOldData(); + } prepareStatements(); } diff --git a/lobby/LobbyDatabase.h b/lobby/LobbyDatabase.h index 1b3f8f04f..8de35593f 100644 --- a/lobby/LobbyDatabase.h +++ b/lobby/LobbyDatabase.h @@ -71,7 +71,7 @@ class LobbyDatabase void clearOldData(); public: - explicit LobbyDatabase(const boost::filesystem::path & databasePath); + explicit LobbyDatabase(const boost::filesystem::path & databasePath, bool write); ~LobbyDatabase(); void setAccountOnline(const std::string & accountID, bool isOnline); diff --git a/lobby/LobbyServer.cpp b/lobby/LobbyServer.cpp index 8eab15868..8c9d8c3f4 100644 --- a/lobby/LobbyServer.cpp +++ b/lobby/LobbyServer.cpp @@ -835,7 +835,7 @@ void LobbyServer::receiveSendInvite(const NetworkConnectionPtr & connection, con LobbyServer::~LobbyServer() = default; LobbyServer::LobbyServer(const boost::filesystem::path & databasePath) - : database(std::make_unique(databasePath)) + : database(std::make_unique(databasePath, true)) , networkHandler(INetworkHandler::createHandler()) , networkServer(networkHandler->createServerTCP(*this)) { @@ -850,8 +850,3 @@ void LobbyServer::run() { networkHandler->run(); } - -LobbyDatabase * LobbyServer::getDatabase() const -{ - return database.get(); -} diff --git a/lobby/LobbyServer.h b/lobby/LobbyServer.h index 2a39ac31c..2ccff59cf 100644 --- a/lobby/LobbyServer.h +++ b/lobby/LobbyServer.h @@ -101,6 +101,4 @@ public: void start(uint16_t port); void run(); - - LobbyDatabase * getDatabase() const; }; From d0e8894e1a9ab705ca28622a6698e602a6463991 Mon Sep 17 00:00:00 2001 From: Laserlicht <13953785+Laserlicht@users.noreply.github.com> Date: Mon, 23 Mar 2026 21:21:51 +0100 Subject: [PATCH 12/22] shared network context & database; get param --- lib/network/NetworkHandler.cpp | 12 +- lib/network/NetworkHandler.h | 8 +- lobby/EntryPoint.cpp | 2 +- lobby/HttpApiServer.cpp | 245 ++++++++++++++++----------------- lobby/HttpApiServer.h | 15 +- lobby/LobbyDatabase.cpp | 13 +- lobby/LobbyDatabase.h | 2 +- lobby/LobbyServer.cpp | 15 +- lobby/LobbyServer.h | 6 + 9 files changed, 165 insertions(+), 153 deletions(-) diff --git a/lib/network/NetworkHandler.cpp b/lib/network/NetworkHandler.cpp index f9d6f5913..36b060dcb 100644 --- a/lib/network/NetworkHandler.cpp +++ b/lib/network/NetworkHandler.cpp @@ -21,7 +21,12 @@ std::unique_ptr INetworkHandler::createHandler() } NetworkHandler::NetworkHandler() - : context(std::make_unique()) + : ownedContext(std::make_unique()) + , context(ownedContext.get()) +{} + +NetworkHandler::NetworkHandler(NetworkContext & externalContext) + : context(&externalContext) {} std::unique_ptr NetworkHandler::createServerTCP(INetworkServerListener & listener) @@ -29,6 +34,11 @@ std::unique_ptr NetworkHandler::createServerTCP(INetworkServerLi return std::make_unique(listener, *context); } +std::unique_ptr NetworkHandler::createHandlerWithContext(NetworkContext & context) +{ + return std::make_unique(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 db30235a9..27c2df832 100644 --- a/lib/network/NetworkHandler.h +++ b/lib/network/NetworkHandler.h @@ -13,12 +13,14 @@ VCMI_LIB_NAMESPACE_BEGIN -class NetworkHandler final : public INetworkHandler +class DLL_LINKAGE NetworkHandler final : public INetworkHandler { - std::unique_ptr context; + std::unique_ptr ownedContext; + NetworkContext * context; public: NetworkHandler(); + explicit NetworkHandler(NetworkContext & externalContext); std::unique_ptr createServerTCP(INetworkServerListener & listener) override; void connectToRemote(INetworkClientListener & listener, const std::string & host, uint16_t port) override; @@ -28,6 +30,8 @@ public: void run() override; void stop() override; + + static std::unique_ptr createHandlerWithContext(NetworkContext & context); }; VCMI_LIB_NAMESPACE_END diff --git a/lobby/EntryPoint.cpp b/lobby/EntryPoint.cpp index 9843c1210..7184df92a 100644 --- a/lobby/EntryPoint.cpp +++ b/lobby/EntryPoint.cpp @@ -49,7 +49,7 @@ int main(int argc, const char * argv[]) } // Start HTTP API Server - HttpApiServer httpServer(databasePath, HTTP_API_PORT); + HttpApiServer httpServer(server.getNetworkContext(), *server.getDatabase(), HTTP_API_PORT); try { httpServer.start(); diff --git a/lobby/HttpApiServer.cpp b/lobby/HttpApiServer.cpp index 21d6e0a0a..906b16d32 100644 --- a/lobby/HttpApiServer.cpp +++ b/lobby/HttpApiServer.cpp @@ -9,7 +9,6 @@ */ #include "StdInc.h" #include "HttpApiServer.h" -#include "LobbyServer.h" #include "LobbyDatabase.h" #include "EmbeddedWebAssets.h" @@ -22,10 +21,30 @@ namespace http = beast::http; namespace net = boost::asio; using tcp = net::ip::tcp; -HttpApiServer::HttpApiServer(const boost::filesystem::path & databasePath, unsigned short port) - : database(std::make_unique(databasePath, false)) +static std::string queryParam(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 {}; +} + +HttpApiServer::HttpApiServer(boost::asio::io_context & ioc, LobbyDatabase & database, unsigned short port) + : database(database) , port(port) - , running(false) + , ioc(ioc) { } @@ -36,74 +55,80 @@ HttpApiServer::~HttpApiServer() void HttpApiServer::start() { - running = true; - thread = std::make_unique([this]() { run(); }); - logGlobal->info("HTTP API Server started on port %d", port); startTime = std::chrono::system_clock::now(); + + acceptor = std::make_unique(ioc); + 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 HttpApiServer::stop() { - if (running) + if (acceptor && acceptor->is_open()) { - running = false; - ioc.stop(); - if (thread && thread->joinable()) - thread->join(); + acceptor->close(); logGlobal->info("HTTP API Server stopped"); } } -void HttpApiServer::run() +void HttpApiServer::doAccept() { - try + acceptor->async_accept([this](boost::system::error_code ec, tcp::socket socket) { - tcp::acceptor acceptor{ioc}; - 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(); - - while (running) + if (!ec) { - tcp::socket socket{ioc}; - acceptor.accept(socket); + auto stream = std::make_shared(std::move(socket)); + auto buffer = std::make_shared(); + auto req = std::make_shared>(); - // Handle each connection asynchronously - std::thread([this, sock = std::move(socket)]() mutable { - try { - beast::tcp_stream stream(std::move(sock)); - handleSession(std::move(stream)); - } catch (const std::exception & e) { - logGlobal->error("HTTP session error: %s", e.what()); - } - }).detach(); + 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()); + } + }); } - } - catch (const std::exception & e) - { - logGlobal->error("HTTP API Server error: %s", e.what()); - } + if (acceptor && acceptor->is_open()) + doAccept(); + }); } -void HttpApiServer::handleSession(beast::tcp_stream stream) -{ - beast::flat_buffer buffer; - http::request req; - http::read(stream, buffer, req); - handleRequest(std::move(req), stream); -} - -void HttpApiServer::handleRequest(http::request && req, beast::tcp_stream & stream) +http::response HttpApiServer::handleRequest(http::request && req, beast::tcp_stream & stream) { // Log the request std::string clientIP = "unknown"; try { auto endpoint = stream.socket().remote_endpoint(); clientIP = endpoint.address().to_string(); - } catch(...) {} + } + 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"; @@ -131,102 +156,64 @@ void HttpApiServer::handleRequest(http::request && req, beast { JsonNode stats = getStats(); std::string json = stats.toCompactString(); - auto res = createResponse(http::status::ok, json); - http::write(stream, res); + return createResponse(http::status::ok, json); } else if (req.target().starts_with("/api/v1/chats")) { - // Parse query parameters std::string channelName = "english"; - auto target = std::string(req.target()); - auto queryPos = target.find('?'); - if (queryPos != std::string::npos) - { - auto query = target.substr(queryPos + 1); - auto channelPos = query.find("channelName="); - if (channelPos != std::string::npos) - { - auto valueStart = channelPos + 12; // length of "channelName=" - auto valueEnd = query.find('&', valueStart); - channelName = query.substr(valueStart, valueEnd == std::string::npos ? std::string::npos : valueEnd - valueStart); - } - } - + if (auto val = queryParam(req.target(), "channelName"); !val.empty()) + channelName = val; + JsonNode chats = getChats(channelName); std::string json = chats.toCompactString(); - auto res = createResponse(http::status::ok, json); - http::write(stream, res); + return createResponse(http::status::ok, json); } else if (req.target().starts_with("/api/v1/rooms")) { - // Parse query parameters int hours = -1; int limit = 50; - auto target = std::string(req.target()); - auto queryPos = target.find('?'); - if (queryPos != std::string::npos) + if (auto val = queryParam(req.target(), "hours"); !val.empty()) { - auto query = target.substr(queryPos + 1); - auto hoursPos = query.find("hours="); - if (hoursPos != std::string::npos) + try { hours = std::stoi(val); } + catch (const std::invalid_argument &) { hours = -1; } + catch (const std::out_of_range &) { hours = -1; } + } + if (auto val = queryParam(req.target(), "limit"); !val.empty()) + { + try { - auto valueStart = hoursPos + 6; // length of "hours=" - auto valueEnd = query.find('&', valueStart); - std::string hoursStr = query.substr(valueStart, valueEnd == std::string::npos ? std::string::npos : valueEnd - valueStart); - try { - hours = std::stoi(hoursStr); - } catch(...) { - hours = -1; - } - } - auto limitPos = query.find("limit="); - if (limitPos != std::string::npos) - { - auto valueStart = limitPos + 6; // length of "limit=" - auto valueEnd = query.find('&', valueStart); - std::string limitStr = query.substr(valueStart, valueEnd == std::string::npos ? std::string::npos : valueEnd - valueStart); - try { - limit = std::stoi(limitStr); - if (limit > 250) limit = 250; - if (limit < 1) limit = 50; - } catch(...) { - limit = 50; - } + limit = std::stoi(val); + limit = std::clamp(limit, 1, 250); } + catch (const std::invalid_argument &) { limit = 50; } + catch (const std::out_of_range &) { limit = 50; } } JsonNode rooms = getRooms(hours, limit); std::string json = rooms.toCompactString(); - auto res = createResponse(http::status::ok, json); - http::write(stream, res); + return createResponse(http::status::ok, json); } else if (req.target() == "/api/docs" || req.target() == "/") { std::string html = EmbeddedFiles::SWAGGER_CONTENT; - auto res = createResponse(http::status::ok, html, "text/html"); - http::write(stream, res); + return createResponse(http::status::ok, html, "text/html"); } else if (req.target() == "/api/openapi.yaml") { std::string spec = EmbeddedFiles::OPENAPI_CONTENT; - auto res = createResponse(http::status::ok, spec, "text/yaml"); - http::write(stream, res); + return createResponse(http::status::ok, spec, "text/yaml"); } else { // 404 Not Found std::string json = R"({ "error": "Not Found", "message": "The requested endpoint does not exist" })"; - auto res = createResponse(http::status::not_found, json); - http::write(stream, res); + return createResponse(http::status::not_found, json); } - - // Graceful shutdown - beast::error_code ec; - stream.socket().shutdown(tcp::socket::shutdown_send, ec); } catch (const std::exception & e) { logGlobal->error("Error handling HTTP request: %s", e.what()); + return createResponse(http::status::internal_server_error, R"({"error":"Internal Server Error"})"); } } @@ -244,40 +231,40 @@ JsonNode HttpApiServer::getStats() { JsonNode stats; stats["onlinePlayers"].Vector() = JsonVector(); - for (const auto & player : database->getActiveAccounts()) + for (const auto & player : database.getActiveAccounts()) stats["onlinePlayers"].Vector().push_back(JsonNode(player.displayName)); stats["onlinePlayersCount"].Struct() = JsonMap{ {"current", JsonNode(static_cast(stats["onlinePlayers"].Vector().size()))}, - {"lastHour", JsonNode(database->getActiveAccountsCount(1))}, - {"lastDay", JsonNode(database->getActiveAccountsCount(24))}, - {"lastWeek", JsonNode(database->getActiveAccountsCount(168))}, - {"lastMonth", JsonNode(database->getActiveAccountsCount(720))}, - {"lastYear", JsonNode(database->getActiveAccountsCount(8760))} + {"lastHour", JsonNode(database.getActiveAccountsCount(1))}, + {"lastDay", JsonNode(database.getActiveAccountsCount(24))}, + {"lastWeek", JsonNode(database.getActiveAccountsCount(168))}, + {"lastMonth", JsonNode(database.getActiveAccountsCount(720))}, + {"lastYear", JsonNode(database.getActiveAccountsCount(8760))} }; stats["registeredPlayersCount"].Struct() = JsonMap{ - {"total", JsonNode(database->getAccountCount())}, - {"lastDay", JsonNode(database->getRegisteredAccountsCount(24))}, - {"lastWeek", JsonNode(database->getRegisteredAccountsCount(168))}, - {"lastMonth", JsonNode(database->getRegisteredAccountsCount(720))}, - {"lastYear", JsonNode(database->getRegisteredAccountsCount(8760))} + {"total", JsonNode(database.getAccountCount())}, + {"lastDay", JsonNode(database.getRegisteredAccountsCount(24))}, + {"lastWeek", JsonNode(database.getRegisteredAccountsCount(168))}, + {"lastMonth", JsonNode(database.getRegisteredAccountsCount(720))}, + {"lastYear", JsonNode(database.getRegisteredAccountsCount(8760))} }; std::map lobbysCount; - for (const auto & room : database->getActiveGameRooms()) + for (const auto & room : database.getActiveGameRooms()) lobbysCount[room.roomState]++; stats["gameCount"].Struct() = JsonMap{ {"current", JsonNode(lobbysCount[LobbyRoomState::BUSY])}, - {"total", JsonNode(database->getClosedGameRoomsCount())}, - {"lastDay", JsonNode(database->getClosedGameRoomsCount(24))}, - {"lastWeek", JsonNode(database->getClosedGameRoomsCount(168))}, - {"lastMonth", JsonNode(database->getClosedGameRoomsCount(720))}, - {"lastYear", JsonNode(database->getClosedGameRoomsCount(8760))} + {"total", JsonNode(database.getClosedGameRoomsCount())}, + {"lastDay", JsonNode(database.getClosedGameRoomsCount(24))}, + {"lastWeek", JsonNode(database.getClosedGameRoomsCount(168))}, + {"lastMonth", JsonNode(database.getClosedGameRoomsCount(720))}, + {"lastYear", JsonNode(database.getClosedGameRoomsCount(8760))} }; 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["registeredPlayersCount"].Integer() = database->getAccountCount(); + stats["registeredPlayersCount"].Integer() = database.getAccountCount(); stats["lobbyStartTime"].String() = formatTimestamp(startTime); @@ -293,7 +280,7 @@ JsonNode HttpApiServer::getChats(const std::string & channelName) chats["messages"].Vector() = JsonVector(); chats["channelName"].String() = channelName; - auto messages = database->getRecentMessageHistory("global", channelName); + auto messages = database.getRecentMessageHistory("global", channelName); for (const auto & msg : messages) { @@ -320,7 +307,7 @@ JsonNode HttpApiServer::getRooms(int hours, int limit) result["hours"].Integer() = hours; result["limit"].Integer() = limit; - auto rooms = database->getRooms(hours, limit); + auto rooms = database.getRooms(hours, limit); for (const auto & room : rooms) { diff --git a/lobby/HttpApiServer.h b/lobby/HttpApiServer.h index cff7f1982..da896c8ed 100644 --- a/lobby/HttpApiServer.h +++ b/lobby/HttpApiServer.h @@ -12,7 +12,6 @@ #include #include #include -#include VCMI_LIB_NAMESPACE_BEGIN class JsonNode; @@ -23,27 +22,25 @@ class LobbyDatabase; class HttpApiServer { public: - HttpApiServer(const boost::filesystem::path & databasePath, unsigned short port); + HttpApiServer(boost::asio::io_context & ioc, LobbyDatabase & database, unsigned short port); ~HttpApiServer(); void start(); void stop(); private: - void run(); - void handleSession(boost::beast::tcp_stream stream); - void handleRequest(boost::beast::http::request && req, boost::beast::tcp_stream & stream); + void doAccept(); + boost::beast::http::response handleRequest(boost::beast::http::request && req, boost::beast::tcp_stream & stream); std::string formatTimestamp(std::chrono::system_clock::time_point timePoint); JsonNode getStats(); JsonNode getChats(const std::string & channelName); JsonNode getRooms(int hours, int limit); - std::unique_ptr database; + LobbyDatabase & database; unsigned short port; - boost::asio::io_context ioc; - std::unique_ptr thread; - bool running; + boost::asio::io_context & ioc; + std::unique_ptr acceptor; std::chrono::system_clock::time_point startTime; }; diff --git a/lobby/LobbyDatabase.cpp b/lobby/LobbyDatabase.cpp index fc3ed1629..047e811fb 100644 --- a/lobby/LobbyDatabase.cpp +++ b/lobby/LobbyDatabase.cpp @@ -377,15 +377,12 @@ void LobbyDatabase::prepareStatements() LobbyDatabase::~LobbyDatabase() = default; -LobbyDatabase::LobbyDatabase(const boost::filesystem::path & databasePath, bool write) +LobbyDatabase::LobbyDatabase(const boost::filesystem::path & databasePath) { - database = SQLiteInstance::open(databasePath, write); - if(write) - { - createTables(); - upgradeDatabase(); - clearOldData(); - } + database = SQLiteInstance::open(databasePath, true); + createTables(); + upgradeDatabase(); + clearOldData(); prepareStatements(); } diff --git a/lobby/LobbyDatabase.h b/lobby/LobbyDatabase.h index 8de35593f..1b3f8f04f 100644 --- a/lobby/LobbyDatabase.h +++ b/lobby/LobbyDatabase.h @@ -71,7 +71,7 @@ class LobbyDatabase void clearOldData(); public: - explicit LobbyDatabase(const boost::filesystem::path & databasePath, bool write); + explicit LobbyDatabase(const boost::filesystem::path & databasePath); ~LobbyDatabase(); void setAccountOnline(const std::string & accountID, bool isOnline); diff --git a/lobby/LobbyServer.cpp b/lobby/LobbyServer.cpp index 8c9d8c3f4..965b3263a 100644 --- a/lobby/LobbyServer.cpp +++ b/lobby/LobbyServer.cpp @@ -11,6 +11,7 @@ #include "LobbyServer.h" #include "LobbyDatabase.h" +#include "../lib/network/NetworkHandler.h" #include "../lib/json/JsonFormatException.h" #include "../lib/json/JsonNode.h" @@ -835,12 +836,22 @@ void LobbyServer::receiveSendInvite(const NetworkConnectionPtr & connection, con LobbyServer::~LobbyServer() = default; LobbyServer::LobbyServer(const boost::filesystem::path & databasePath) - : database(std::make_unique(databasePath, true)) - , networkHandler(INetworkHandler::createHandler()) + : database(std::make_unique(databasePath)) + , networkHandler(NetworkHandler::createHandlerWithContext(ioc)) , networkServer(networkHandler->createServerTCP(*this)) { } +LobbyDatabase * LobbyServer::getDatabase() const +{ + return database.get(); +} + +boost::asio::io_context & LobbyServer::getNetworkContext() +{ + return ioc; +} + void LobbyServer::start(uint16_t port) { networkServer->start(port); diff --git a/lobby/LobbyServer.h b/lobby/LobbyServer.h index 2ccff59cf..9bddae1c7 100644 --- a/lobby/LobbyServer.h +++ b/lobby/LobbyServer.h @@ -10,6 +10,8 @@ #pragma once #include "../lib/network/NetworkInterface.h" +#include "../lib/network/NetworkHandler.h" +#include #include "LobbyDefines.h" VCMI_LIB_NAMESPACE_BEGIN @@ -41,6 +43,7 @@ class LobbyServer final : public INetworkServerListener std::map activeGameRooms; std::unique_ptr database; + boost::asio::io_context ioc; std::unique_ptr networkHandler; std::unique_ptr networkServer; @@ -101,4 +104,7 @@ public: void start(uint16_t port); void run(); + + LobbyDatabase * getDatabase() const; + boost::asio::io_context & getNetworkContext(); }; From e0c47a8b407c4ede566206003eb3a30cf3e99545 Mon Sep 17 00:00:00 2001 From: Laserlicht <13953785+Laserlicht@users.noreply.github.com> Date: Mon, 23 Mar 2026 21:40:32 +0100 Subject: [PATCH 13/22] localhost and api error handling --- lobby/EntryPoint.cpp | 3 ++- lobby/HttpApiServer.cpp | 37 +++++++++++++++++++++++++------------ lobby/HttpApiServer.h | 3 ++- 3 files changed, 29 insertions(+), 14 deletions(-) diff --git a/lobby/EntryPoint.cpp b/lobby/EntryPoint.cpp index 7184df92a..75a76546e 100644 --- a/lobby/EntryPoint.cpp +++ b/lobby/EntryPoint.cpp @@ -20,6 +20,7 @@ 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[]) { @@ -49,7 +50,7 @@ int main(int argc, const char * argv[]) } // Start HTTP API Server - HttpApiServer httpServer(server.getNetworkContext(), *server.getDatabase(), HTTP_API_PORT); + HttpApiServer httpServer(server.getNetworkContext(), *server.getDatabase(), HTTP_API_PORT, HTTP_API_LOCALHOST_ONLY); try { httpServer.start(); diff --git a/lobby/HttpApiServer.cpp b/lobby/HttpApiServer.cpp index 906b16d32..e7b84972d 100644 --- a/lobby/HttpApiServer.cpp +++ b/lobby/HttpApiServer.cpp @@ -41,9 +41,10 @@ static std::string queryParam(boost::beast::string_view target, const std::strin return {}; } -HttpApiServer::HttpApiServer(boost::asio::io_context & ioc, LobbyDatabase & database, unsigned short port) +HttpApiServer::HttpApiServer(boost::asio::io_context & ioc, LobbyDatabase & database, unsigned short port, bool localhostOnly) : database(database) , port(port) + , localhostOnly(localhostOnly) , ioc(ioc) { } @@ -58,11 +59,21 @@ void HttpApiServer::start() startTime = std::chrono::system_clock::now(); acceptor = std::make_unique(ioc); - 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); + 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(); @@ -175,18 +186,19 @@ http::response HttpApiServer::handleRequest(http::request 250) + return createResponse(http::status::bad_request, R"({"error":"Parameter 'limit' must be between 1 and 250"})"); } - catch (const std::invalid_argument &) { limit = 50; } - catch (const std::out_of_range &) { limit = 50; } + catch (const std::invalid_argument &) { return createResponse(http::status::bad_request, R"({"error":"Parameter 'limit' must be an integer"})"); } + catch (const std::out_of_range &) { return createResponse(http::status::bad_request, R"({"error":"Parameter 'limit' must be between 1 and 250"})"); } } JsonNode rooms = getRooms(hours, limit); @@ -325,7 +337,8 @@ JsonNode HttpApiServer::getRooms(int hours, int limit) try { JsonNode modsNode(reinterpret_cast(room.modsJson.data()), room.modsJson.size(), ""); roomNode["mods"] = modsNode; - } catch(...) { + } catch(const std::exception & e) { + logGlobal->warn("HTTP API: failed to parse mods JSON: %s", e.what()); roomNode["mods"].Struct() = JsonMap{}; } diff --git a/lobby/HttpApiServer.h b/lobby/HttpApiServer.h index da896c8ed..7ea01741b 100644 --- a/lobby/HttpApiServer.h +++ b/lobby/HttpApiServer.h @@ -22,7 +22,7 @@ class LobbyDatabase; class HttpApiServer { public: - HttpApiServer(boost::asio::io_context & ioc, LobbyDatabase & database, unsigned short port); + HttpApiServer(boost::asio::io_context & ioc, LobbyDatabase & database, unsigned short port, bool localhostOnly); ~HttpApiServer(); void start(); @@ -40,6 +40,7 @@ private: LobbyDatabase & database; unsigned short port; + bool localhostOnly; boost::asio::io_context & ioc; std::unique_ptr acceptor; std::chrono::system_clock::time_point startTime; From 429710f189edc7ead73a2d406fa300d45dd4fc96 Mon Sep 17 00:00:00 2001 From: Laserlicht <13953785+Laserlicht@users.noreply.github.com> Date: Mon, 23 Mar 2026 23:50:47 +0100 Subject: [PATCH 14/22] optimize sql queries & remove version --- lobby/HttpApiServer.cpp | 35 ++++++++++++----------- lobby/LobbyDatabase.cpp | 57 +++++++++++++++++++++++++++++++++++++- lobby/LobbyDatabase.h | 3 ++ lobby/SQLiteConnection.cpp | 14 ++++++++++ lobby/SQLiteConnection.h | 4 +++ lobby/web/openapi.yaml | 3 -- 6 files changed, 94 insertions(+), 22 deletions(-) diff --git a/lobby/HttpApiServer.cpp b/lobby/HttpApiServer.cpp index e7b84972d..fbda5c144 100644 --- a/lobby/HttpApiServer.cpp +++ b/lobby/HttpApiServer.cpp @@ -14,7 +14,6 @@ #include "../lib/json/JsonNode.h" #include "../lib/logging/CLogger.h" -#include "../lib/GameConstants.h" namespace beast = boost::beast; namespace http = beast::http; @@ -245,43 +244,43 @@ JsonNode HttpApiServer::getStats() stats["onlinePlayers"].Vector() = JsonVector(); for (const auto & player : database.getActiveAccounts()) stats["onlinePlayers"].Vector().push_back(JsonNode(player.displayName)); + auto activeCounts = database.getActiveAccountsCounts({1, 24, 168, 720, 8760}); stats["onlinePlayersCount"].Struct() = JsonMap{ {"current", JsonNode(static_cast(stats["onlinePlayers"].Vector().size()))}, - {"lastHour", JsonNode(database.getActiveAccountsCount(1))}, - {"lastDay", JsonNode(database.getActiveAccountsCount(24))}, - {"lastWeek", JsonNode(database.getActiveAccountsCount(168))}, - {"lastMonth", JsonNode(database.getActiveAccountsCount(720))}, - {"lastYear", JsonNode(database.getActiveAccountsCount(8760))} + {"lastHour", JsonNode(activeCounts[0])}, + {"lastDay", JsonNode(activeCounts[1])}, + {"lastWeek", JsonNode(activeCounts[2])}, + {"lastMonth", JsonNode(activeCounts[3])}, + {"lastYear", JsonNode(activeCounts[4])} }; + auto registeredCounts = database.getRegisteredAccountsCounts({24, 168, 720, 8760}); stats["registeredPlayersCount"].Struct() = JsonMap{ {"total", JsonNode(database.getAccountCount())}, - {"lastDay", JsonNode(database.getRegisteredAccountsCount(24))}, - {"lastWeek", JsonNode(database.getRegisteredAccountsCount(168))}, - {"lastMonth", JsonNode(database.getRegisteredAccountsCount(720))}, - {"lastYear", JsonNode(database.getRegisteredAccountsCount(8760))} + {"lastDay", JsonNode(registeredCounts[0])}, + {"lastWeek", JsonNode(registeredCounts[1])}, + {"lastMonth", JsonNode(registeredCounts[2])}, + {"lastYear", JsonNode(registeredCounts[3])} }; std::map lobbysCount; for (const auto & room : database.getActiveGameRooms()) lobbysCount[room.roomState]++; + auto closedCounts = database.getClosedGameRoomsCounts({24, 168, 720, 8760}); stats["gameCount"].Struct() = JsonMap{ {"current", JsonNode(lobbysCount[LobbyRoomState::BUSY])}, - {"total", JsonNode(database.getClosedGameRoomsCount())}, - {"lastDay", JsonNode(database.getClosedGameRoomsCount(24))}, - {"lastWeek", JsonNode(database.getClosedGameRoomsCount(168))}, - {"lastMonth", JsonNode(database.getClosedGameRoomsCount(720))}, - {"lastYear", JsonNode(database.getClosedGameRoomsCount(8760))} + {"total", JsonNode(closedCounts[0])}, + {"lastDay", JsonNode(closedCounts[1])}, + {"lastWeek", JsonNode(closedCounts[2])}, + {"lastMonth", JsonNode(closedCounts[3])}, + {"lastYear", JsonNode(closedCounts[4])} }; 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["registeredPlayersCount"].Integer() = database.getAccountCount(); - stats["lobbyStartTime"].String() = formatTimestamp(startTime); stats["server"].String() = "VCMI Lobby"; - stats["lobbyVersion"].String() = GameConstants::VCMI_VERSION; stats["apiVersion"].String() = "1.0"; return stats; } diff --git a/lobby/LobbyDatabase.cpp b/lobby/LobbyDatabase.cpp index 047e811fb..f01762127 100644 --- a/lobby/LobbyDatabase.cpp +++ b/lobby/LobbyDatabase.cpp @@ -340,7 +340,7 @@ void LobbyDatabase::prepareStatements() SELECT COUNT(*) FROM gameRooms WHERE status = 5 - )");; + )"); isAccountCookieValidStatement = database->prepare(R"( SELECT COUNT(accountID) @@ -585,6 +585,61 @@ int LobbyDatabase::getClosedGameRoomsCount(int hours) return result; } +std::vector LobbyDatabase::getActiveAccountsCounts(const std::vector & hours) +{ + if(hours.empty()) + return {}; + std::string sql = "SELECT "; + for (size_t i = 0; i < hours.size(); ++i) + { + if (i > 0) sql += ", "; + sql += "COUNT(CASE WHEN lastLoginTime >= datetime('now', '-' || ? || ' hours') THEN 1 END)"; + } + sql += " FROM accounts"; + auto stmt = database->prepare(sql); + stmt->setBindVector(hours); + std::vector result; + if (stmt->execute()) + stmt->getColumnVector(result); + return result; +} + +std::vector LobbyDatabase::getRegisteredAccountsCounts(const std::vector & hours) +{ + if(hours.empty()) + return {}; + std::string sql = "SELECT "; + for (size_t i = 0; i < hours.size(); ++i) + { + if (i > 0) sql += ", "; + sql += "COUNT(CASE WHEN creationTime >= datetime('now', '-' || ? || ' hours') THEN 1 END)"; + } + sql += " FROM accounts"; + auto stmt = database->prepare(sql); + stmt->setBindVector(hours); + std::vector result; + if (stmt->execute()) + stmt->getColumnVector(result); + return result; +} + +std::vector LobbyDatabase::getClosedGameRoomsCounts(const std::vector & hours) +{ + if(hours.empty()) + return {}; + // First column is total (no time filter), remaining columns are per-hour + std::string sql = "SELECT COUNT(*)"; + for (size_t i = 0; i < hours.size(); ++i) + sql += ", COUNT(CASE WHEN creationTime >= datetime('now', '-' || ? || ' hours') THEN 1 END)"; + sql += " FROM gameRooms WHERE status = 5"; + auto stmt = database->prepare(sql); + stmt->setBindVector(hours); + std::vector result; + if (stmt->execute()) + stmt->getColumnVector(result); + return result; +} + LobbyCookieStatus LobbyDatabase::getAccountCookieStatus(const std::string & accountID, const std::string & accessCookieUUID) { bool result = false; diff --git a/lobby/LobbyDatabase.h b/lobby/LobbyDatabase.h index 1b3f8f04f..29fcda2b3 100644 --- a/lobby/LobbyDatabase.h +++ b/lobby/LobbyDatabase.h @@ -106,6 +106,9 @@ public: int getActiveAccountsCount(int hours); int getRegisteredAccountsCount(int hours); int getClosedGameRoomsCount(int hours = -1); + std::vector getActiveAccountsCounts(const std::vector & hours); + std::vector getRegisteredAccountsCounts(const std::vector & hours); + std::vector getClosedGameRoomsCounts(const std::vector & hours); LobbyCookieStatus getAccountCookieStatus(const std::string & accountID, const std::string & accessCookieUUID); LobbyInviteStatus getAccountInviteStatus(const std::string & accountID, const std::string & roomID); diff --git a/lobby/SQLiteConnection.cpp b/lobby/SQLiteConnection.cpp index 439ad839b..f72f006ff 100644 --- a/lobby/SQLiteConnection.cpp +++ b/lobby/SQLiteConnection.cpp @@ -157,6 +157,20 @@ void SQLiteStatement::getColumnSingle(size_t index, std::string & value) value = reinterpret_cast(value_raw); } +void SQLiteStatement::setBindVector(const std::vector & values) +{ + for (size_t i = 0; i < values.size(); ++i) + setBindSingle(i + 1, static_cast(values[i])); +} + +void SQLiteStatement::getColumnVector(std::vector & result) +{ + int count = sqlite3_column_count(m_statement); + result.resize(count); + for (int i = 0; i < count; ++i) + result[i] = sqlite3_column_int(m_statement, i); +} + SQLiteInstancePtr SQLiteInstance::open(const boost::filesystem::path & db_path, bool allow_write) { int flags = allow_write ? (SQLITE_OPEN_READWRITE | SQLITE_OPEN_CREATE) : SQLITE_OPEN_READONLY; diff --git a/lobby/SQLiteConnection.h b/lobby/SQLiteConnection.h index 7a8be71e8..981382cfc 100644 --- a/lobby/SQLiteConnection.h +++ b/lobby/SQLiteConnection.h @@ -43,12 +43,16 @@ public: setBindSingle(1, args...); // The leftmost SQL parameter has an index of 1 } + void setBindVector(const std::vector & values); + template void getColumns(Args &... args) { getColumnSingle(0, args...); // The leftmost column of the result set has the index 0 } + void getColumnVector(std::vector & result); + private: void setBindSingle(size_t index, const double & value); void setBindSingle(size_t index, const bool & value); diff --git a/lobby/web/openapi.yaml b/lobby/web/openapi.yaml index c3dda2ef0..35952a3de 100644 --- a/lobby/web/openapi.yaml +++ b/lobby/web/openapi.yaml @@ -103,9 +103,6 @@ paths: server: type: string description: Server name - lobbyVersion: - type: string - description: VCMI version apiVersion: type: string description: API version From d385f52a0af95678e91db5ac40df98fa278fa34f Mon Sep 17 00:00:00 2001 From: Laserlicht <13953785+Laserlicht@users.noreply.github.com> Date: Tue, 24 Mar 2026 03:56:11 +0100 Subject: [PATCH 15/22] add caching, to avoid querying database each api call. --- lobby/HttpApiServer.cpp | 99 +++++++++++++++++++++++++++++++---------- lobby/HttpApiServer.h | 36 +++++++++++---- 2 files changed, 103 insertions(+), 32 deletions(-) diff --git a/lobby/HttpApiServer.cpp b/lobby/HttpApiServer.cpp index fbda5c144..0fd3f76f5 100644 --- a/lobby/HttpApiServer.cpp +++ b/lobby/HttpApiServer.cpp @@ -164,9 +164,7 @@ http::response HttpApiServer::handleRequest(http::request HttpApiServer::handleRequest(http::request HttpApiServer::handleRequest(http::request lock(cacheMutex); + if (statsCache && isCacheValid(*statsCache)) + return statsCache->json; + } JsonNode stats; stats["onlinePlayers"].Vector() = JsonVector(); for (const auto & player : database.getActiveAccounts()) @@ -282,11 +286,24 @@ JsonNode HttpApiServer::getStats() stats["server"].String() = "VCMI Lobby"; stats["apiVersion"].String() = "1.0"; - return stats; + + std::string json = stats.toCompactString(); + { + std::lock_guard lock(cacheMutex); + statsCache = CacheEntry{json, std::chrono::system_clock::now()}; + } + return json; } -JsonNode HttpApiServer::getChats(const std::string & channelName) +std::string HttpApiServer::getChats(const std::string & channelName) { + { + std::lock_guard lock(cacheMutex); + 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; @@ -307,31 +324,40 @@ JsonNode HttpApiServer::getChats(const std::string & channelName) } chats["count"].Integer() = chats["messages"].Vector().size(); - - return chats; + + std::string json = chats.toCompactString(); + { + std::lock_guard lock(cacheMutex); + chatsCache[channelName] = CacheEntry{json, std::chrono::system_clock::now()}; + } + return json; } -JsonNode HttpApiServer::getRooms(int hours, int limit) +std::string HttpApiServer::serializeRooms(const std::vector & rooms, int hours, int limit) { JsonNode result; result["rooms"].Vector() = JsonVector(); result["hours"].Integer() = hours; result["limit"].Integer() = limit; - - auto rooms = database.getRooms(hours, 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(), ""); @@ -340,11 +366,36 @@ JsonNode HttpApiServer::getRooms(int hours, int limit) 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; + return result.toCompactString(); +} + +std::string HttpApiServer::getRooms(int hours, int limit) +{ + { + std::lock_guard lock(cacheMutex); + 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); + + { + std::lock_guard lock(cacheMutex); + roomsCache = RoomsCacheEntry{fetchedRooms, hours, limit, std::chrono::system_clock::now()}; + } + + return serializeRooms(fetchedRooms, hours, limit); } diff --git a/lobby/HttpApiServer.h b/lobby/HttpApiServer.h index 7ea01741b..96443de40 100644 --- a/lobby/HttpApiServer.h +++ b/lobby/HttpApiServer.h @@ -11,17 +11,15 @@ #include #include -#include - -VCMI_LIB_NAMESPACE_BEGIN -class JsonNode; -VCMI_LIB_NAMESPACE_END +#include "LobbyDefines.h" class LobbyDatabase; class HttpApiServer { public: + static constexpr int CACHE_TTL_SECONDS = 30; + HttpApiServer(boost::asio::io_context & ioc, LobbyDatabase & database, unsigned short port, bool localhostOnly); ~HttpApiServer(); @@ -29,13 +27,22 @@ public: void stop(); private: + struct CacheEntry + { + std::string json; + std::chrono::system_clock::time_point timestamp; + }; + + bool isCacheValid(const CacheEntry & entry) const; + void doAccept(); boost::beast::http::response handleRequest(boost::beast::http::request && req, boost::beast::tcp_stream & stream); std::string formatTimestamp(std::chrono::system_clock::time_point timePoint); - JsonNode getStats(); - JsonNode getChats(const std::string & channelName); - JsonNode getRooms(int hours, int limit); + std::string getStats(); + std::string getChats(const std::string & channelName); + std::string getRooms(int hours, int limit); + std::string serializeRooms(const std::vector & rooms, int hours, int limit); LobbyDatabase & database; @@ -44,4 +51,17 @@ private: boost::asio::io_context & ioc; std::unique_ptr acceptor; std::chrono::system_clock::time_point startTime; + + mutable std::mutex cacheMutex; + 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; }; From 7c02976caacdf7a425ccc7ce0f96b4d08ca7681d Mon Sep 17 00:00:00 2001 From: Laserlicht <13953785+Laserlicht@users.noreply.github.com> Date: Wed, 25 Mar 2026 21:51:23 +0100 Subject: [PATCH 16/22] code review --- lib/network/NetworkHandler.cpp | 11 +-- lib/network/NetworkHandler.h | 6 +- lobby/HttpApiServer.cpp | 45 ++++----- lobby/LobbyDatabase.cpp | 169 +++++++++------------------------ lobby/LobbyDatabase.h | 26 ++--- lobby/LobbyServer.cpp | 6 +- lobby/LobbyServer.h | 7 +- lobby/SQLiteConnection.cpp | 14 --- lobby/SQLiteConnection.h | 4 - 9 files changed, 94 insertions(+), 194 deletions(-) diff --git a/lib/network/NetworkHandler.cpp b/lib/network/NetworkHandler.cpp index 36b060dcb..5836c5a3b 100644 --- a/lib/network/NetworkHandler.cpp +++ b/lib/network/NetworkHandler.cpp @@ -21,12 +21,7 @@ std::unique_ptr INetworkHandler::createHandler() } NetworkHandler::NetworkHandler() - : ownedContext(std::make_unique()) - , context(ownedContext.get()) -{} - -NetworkHandler::NetworkHandler(NetworkContext & externalContext) - : context(&externalContext) + : context(std::make_unique()) {} std::unique_ptr NetworkHandler::createServerTCP(INetworkServerListener & listener) @@ -34,9 +29,9 @@ std::unique_ptr NetworkHandler::createServerTCP(INetworkServerLi return std::make_unique(listener, *context); } -std::unique_ptr NetworkHandler::createHandlerWithContext(NetworkContext & context) +NetworkContext & NetworkHandler::getContext() { - return std::make_unique(context); + return *context; } std::shared_ptr NetworkHandler::createAsyncConnection(INetworkConnectionListener & listener) diff --git a/lib/network/NetworkHandler.h b/lib/network/NetworkHandler.h index 27c2df832..88501b285 100644 --- a/lib/network/NetworkHandler.h +++ b/lib/network/NetworkHandler.h @@ -15,12 +15,10 @@ VCMI_LIB_NAMESPACE_BEGIN class DLL_LINKAGE NetworkHandler final : public INetworkHandler { - std::unique_ptr ownedContext; - NetworkContext * context; + std::unique_ptr context; public: NetworkHandler(); - explicit NetworkHandler(NetworkContext & externalContext); std::unique_ptr createServerTCP(INetworkServerListener & listener) override; void connectToRemote(INetworkClientListener & listener, const std::string & host, uint16_t port) override; @@ -31,7 +29,7 @@ public: void run() override; void stop() override; - static std::unique_ptr createHandlerWithContext(NetworkContext & context); + NetworkContext & getContext(); }; VCMI_LIB_NAMESPACE_END diff --git a/lobby/HttpApiServer.cpp b/lobby/HttpApiServer.cpp index 0fd3f76f5..2d0412d5e 100644 --- a/lobby/HttpApiServer.cpp +++ b/lobby/HttpApiServer.cpp @@ -20,7 +20,8 @@ namespace http = beast::http; namespace net = boost::asio; using tcp = net::ip::tcp; -static std::string queryParam(boost::beast::string_view target, const std::string & key) +/// 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('?'); @@ -169,7 +170,7 @@ http::response HttpApiServer::handleRequest(http::request HttpApiServer::handleRequest(http::request(stats["onlinePlayers"].Vector().size()))}, - {"lastHour", JsonNode(activeCounts[0])}, - {"lastDay", JsonNode(activeCounts[1])}, - {"lastWeek", JsonNode(activeCounts[2])}, - {"lastMonth", JsonNode(activeCounts[3])}, - {"lastYear", JsonNode(activeCounts[4])} + {"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({24, 168, 720, 8760}); + auto registeredCounts = database.getRegisteredAccountsCounts(); stats["registeredPlayersCount"].Struct() = JsonMap{ - {"total", JsonNode(database.getAccountCount())}, - {"lastDay", JsonNode(registeredCounts[0])}, - {"lastWeek", JsonNode(registeredCounts[1])}, - {"lastMonth", JsonNode(registeredCounts[2])}, - {"lastYear", JsonNode(registeredCounts[3])} + {"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({24, 168, 720, 8760}); + auto closedCounts = database.getClosedGameRoomsCounts(); stats["gameCount"].Struct() = JsonMap{ {"current", JsonNode(lobbysCount[LobbyRoomState::BUSY])}, - {"total", JsonNode(closedCounts[0])}, - {"lastDay", JsonNode(closedCounts[1])}, - {"lastWeek", JsonNode(closedCounts[2])}, - {"lastMonth", JsonNode(closedCounts[3])}, - {"lastYear", JsonNode(closedCounts[4])} + {"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]))}, diff --git a/lobby/LobbyDatabase.cpp b/lobby/LobbyDatabase.cpp index f01762127..2c4c27f3d 100644 --- a/lobby/LobbyDatabase.cpp +++ b/lobby/LobbyDatabase.cpp @@ -313,33 +313,34 @@ void LobbyDatabase::prepareStatements() WHERE accountID = ? )"); - getAccountCountStatement = database->prepare(R"( - SELECT COUNT(*) + getActiveAccountsCountsBatchStatement = database->prepare(R"( + SELECT + COUNT(CASE WHEN lastLoginTime >= datetime('now', '-' || ? || ' hours') THEN 1 END), + COUNT(CASE WHEN lastLoginTime >= datetime('now', '-' || ? || ' hours') THEN 1 END), + COUNT(CASE WHEN lastLoginTime >= datetime('now', '-' || ? || ' hours') THEN 1 END), + COUNT(CASE WHEN lastLoginTime >= datetime('now', '-' || ? || ' hours') THEN 1 END), + COUNT(CASE WHEN lastLoginTime >= datetime('now', '-' || ? || ' hours') THEN 1 END) FROM accounts )"); - getActiveAccountsCountStatement = database->prepare(R"( - SELECT COUNT(*) + getRegisteredAccountsCountsBatchStatement = database->prepare(R"( + SELECT + COUNT(*), + COUNT(CASE WHEN creationTime >= datetime('now', '-' || ? || ' hours') THEN 1 END), + COUNT(CASE WHEN creationTime >= datetime('now', '-' || ? || ' hours') THEN 1 END), + COUNT(CASE WHEN creationTime >= datetime('now', '-' || ? || ' hours') THEN 1 END), + COUNT(CASE WHEN creationTime >= datetime('now', '-' || ? || ' hours') THEN 1 END) FROM accounts - WHERE lastLoginTime >= datetime('now', '-' || ? || ' hours') )"); - getRegisteredAccountsCountStatement = database->prepare(R"( - SELECT COUNT(*) - FROM accounts - WHERE creationTime >= datetime('now', '-' || ? || ' hours') - )"); - - getClosedGameRoomsCountStatement = database->prepare(R"( - SELECT COUNT(*) - FROM gameRooms - WHERE status = 5 AND creationTime >= datetime('now', '-' || ? || ' hours') - )"); - - getClosedGameRoomsCountAllStatement = database->prepare(R"( - SELECT COUNT(*) - FROM gameRooms - WHERE status = 5 + getClosedGameRoomsCountsBatchStatement = database->prepare(R"( + SELECT + COUNT(*), + COUNT(CASE WHEN creationTime >= datetime('now', '-' || ? || ' hours') THEN 1 END), + COUNT(CASE WHEN creationTime >= datetime('now', '-' || ? || ' hours') THEN 1 END), + COUNT(CASE WHEN creationTime >= datetime('now', '-' || ? || ' hours') THEN 1 END), + COUNT(CASE WHEN creationTime >= datetime('now', '-' || ? || ' hours') THEN 1 END) + FROM gameRooms WHERE status = 5 )"); isAccountCookieValidStatement = database->prepare(R"( @@ -519,124 +520,48 @@ std::string LobbyDatabase::getAccountDisplayName(const std::string & accountID) return result; } -int LobbyDatabase::getAccountCount() +LobbyDatabase::ActiveAccountsCounts LobbyDatabase::getActiveAccountsCounts() { - int result = 0; + ActiveAccountsCounts result{}; - if(getAccountCountStatement->execute()) - getAccountCountStatement->getColumns(result); - getAccountCountStatement->reset(); + getActiveAccountsCountsBatchStatement->reset(); + getActiveAccountsCountsBatchStatement->setBinds(1, 24, 168, 720, 8760); + + if(getActiveAccountsCountsBatchStatement->execute()) + getActiveAccountsCountsBatchStatement->getColumns(result.h1, result.h24, result.h168, result.h720, result.h8760); + + getActiveAccountsCountsBatchStatement->reset(); return result; } -int LobbyDatabase::getActiveAccountsCount(int hours) +LobbyDatabase::RegisteredAccountsCounts LobbyDatabase::getRegisteredAccountsCounts() { - int result = 0; + RegisteredAccountsCounts result{}; - getActiveAccountsCountStatement->reset(); - getActiveAccountsCountStatement->setBinds(hours); - - if(getActiveAccountsCountStatement->execute()) - getActiveAccountsCountStatement->getColumns(result); - - getActiveAccountsCountStatement->reset(); + getRegisteredAccountsCountsBatchStatement->reset(); + getRegisteredAccountsCountsBatchStatement->setBinds(24, 168, 720, 8760); + + if(getRegisteredAccountsCountsBatchStatement->execute()) + getRegisteredAccountsCountsBatchStatement->getColumns(result.total, result.h24, result.h168, result.h720, result.h8760); + + getRegisteredAccountsCountsBatchStatement->reset(); return result; } -int LobbyDatabase::getRegisteredAccountsCount(int hours) +LobbyDatabase::ClosedGameRoomsCounts LobbyDatabase::getClosedGameRoomsCounts() { - int result = 0; + ClosedGameRoomsCounts result{}; - getRegisteredAccountsCountStatement->reset(); - getRegisteredAccountsCountStatement->setBinds(hours); - - if(getRegisteredAccountsCountStatement->execute()) - getRegisteredAccountsCountStatement->getColumns(result); - - getRegisteredAccountsCountStatement->reset(); + getClosedGameRoomsCountsBatchStatement->reset(); + getClosedGameRoomsCountsBatchStatement->setBinds(24, 168, 720, 8760); - return result; -} + if(getClosedGameRoomsCountsBatchStatement->execute()) + getClosedGameRoomsCountsBatchStatement->getColumns(result.total, result.h24, result.h168, result.h720, result.h8760); -int LobbyDatabase::getClosedGameRoomsCount(int hours) -{ - int result = 0; + getClosedGameRoomsCountsBatchStatement->reset(); - if(hours == -1) - { - getClosedGameRoomsCountAllStatement->reset(); - if(getClosedGameRoomsCountAllStatement->execute()) - getClosedGameRoomsCountAllStatement->getColumns(result); - getClosedGameRoomsCountAllStatement->reset(); - } - else - { - getClosedGameRoomsCountStatement->reset(); - getClosedGameRoomsCountStatement->setBinds(hours); - - if(getClosedGameRoomsCountStatement->execute()) - getClosedGameRoomsCountStatement->getColumns(result); - - getClosedGameRoomsCountStatement->reset(); - } - - return result; -} - -std::vector LobbyDatabase::getActiveAccountsCounts(const std::vector & hours) -{ - if(hours.empty()) - return {}; - std::string sql = "SELECT "; - for (size_t i = 0; i < hours.size(); ++i) - { - if (i > 0) sql += ", "; - sql += "COUNT(CASE WHEN lastLoginTime >= datetime('now', '-' || ? || ' hours') THEN 1 END)"; - } - sql += " FROM accounts"; - auto stmt = database->prepare(sql); - stmt->setBindVector(hours); - std::vector result; - if (stmt->execute()) - stmt->getColumnVector(result); - return result; -} - -std::vector LobbyDatabase::getRegisteredAccountsCounts(const std::vector & hours) -{ - if(hours.empty()) - return {}; - std::string sql = "SELECT "; - for (size_t i = 0; i < hours.size(); ++i) - { - if (i > 0) sql += ", "; - sql += "COUNT(CASE WHEN creationTime >= datetime('now', '-' || ? || ' hours') THEN 1 END)"; - } - sql += " FROM accounts"; - auto stmt = database->prepare(sql); - stmt->setBindVector(hours); - std::vector result; - if (stmt->execute()) - stmt->getColumnVector(result); - return result; -} - -std::vector LobbyDatabase::getClosedGameRoomsCounts(const std::vector & hours) -{ - if(hours.empty()) - return {}; - // First column is total (no time filter), remaining columns are per-hour - std::string sql = "SELECT COUNT(*)"; - for (size_t i = 0; i < hours.size(); ++i) - sql += ", COUNT(CASE WHEN creationTime >= datetime('now', '-' || ? || ' hours') THEN 1 END)"; - sql += " FROM gameRooms WHERE status = 5"; - auto stmt = database->prepare(sql); - stmt->setBindVector(hours); - std::vector result; - if (stmt->execute()) - stmt->getColumnVector(result); return result; } diff --git a/lobby/LobbyDatabase.h b/lobby/LobbyDatabase.h index 29fcda2b3..1a946d733 100644 --- a/lobby/LobbyDatabase.h +++ b/lobby/LobbyDatabase.h @@ -47,11 +47,9 @@ class LobbyDatabase SQLiteStatementPtr getAccountInviteStatusStatement; SQLiteStatementPtr getAccountGameRoomStatement; SQLiteStatementPtr getAccountDisplayNameStatement; - SQLiteStatementPtr getAccountCountStatement; - SQLiteStatementPtr getActiveAccountsCountStatement; - SQLiteStatementPtr getRegisteredAccountsCountStatement; - SQLiteStatementPtr getClosedGameRoomsCountStatement; - SQLiteStatementPtr getClosedGameRoomsCountAllStatement; + SQLiteStatementPtr getActiveAccountsCountsBatchStatement; + SQLiteStatementPtr getRegisteredAccountsCountsBatchStatement; + SQLiteStatementPtr getClosedGameRoomsCountsBatchStatement; SQLiteStatementPtr getRoomsStatement; SQLiteStatementPtr getGameRoomPlayersStatement; SQLiteStatementPtr getGameRoomInvitesStatement; @@ -102,13 +100,17 @@ public: std::string getIdleGameRoom(const std::string & hostAccountID); std::string getAccountGameRoom(const std::string & accountID); std::string getAccountDisplayName(const std::string & accountID); - int getAccountCount(); - int getActiveAccountsCount(int hours); - int getRegisteredAccountsCount(int hours); - int getClosedGameRoomsCount(int hours = -1); - std::vector getActiveAccountsCounts(const std::vector & hours); - std::vector getRegisteredAccountsCounts(const std::vector & hours); - std::vector getClosedGameRoomsCounts(const std::vector & hours); + /// 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/LobbyServer.cpp b/lobby/LobbyServer.cpp index 965b3263a..e017ffde4 100644 --- a/lobby/LobbyServer.cpp +++ b/lobby/LobbyServer.cpp @@ -837,7 +837,7 @@ LobbyServer::~LobbyServer() = default; LobbyServer::LobbyServer(const boost::filesystem::path & databasePath) : database(std::make_unique(databasePath)) - , networkHandler(NetworkHandler::createHandlerWithContext(ioc)) + , networkHandler(INetworkHandler::createHandler()) , networkServer(networkHandler->createServerTCP(*this)) { } @@ -847,9 +847,9 @@ LobbyDatabase * LobbyServer::getDatabase() const return database.get(); } -boost::asio::io_context & LobbyServer::getNetworkContext() +NetworkContext & LobbyServer::getNetworkContext() { - return ioc; + return static_cast(*networkHandler).getContext(); } void LobbyServer::start(uint16_t port) diff --git a/lobby/LobbyServer.h b/lobby/LobbyServer.h index 9bddae1c7..acb781bd9 100644 --- a/lobby/LobbyServer.h +++ b/lobby/LobbyServer.h @@ -9,9 +9,7 @@ */ #pragma once -#include "../lib/network/NetworkInterface.h" -#include "../lib/network/NetworkHandler.h" -#include +#include "../lib/network/NetworkDefines.h" #include "LobbyDefines.h" VCMI_LIB_NAMESPACE_BEGIN @@ -43,7 +41,6 @@ class LobbyServer final : public INetworkServerListener std::map activeGameRooms; std::unique_ptr database; - boost::asio::io_context ioc; std::unique_ptr networkHandler; std::unique_ptr networkServer; @@ -106,5 +103,5 @@ public: void run(); LobbyDatabase * getDatabase() const; - boost::asio::io_context & getNetworkContext(); + NetworkContext & getNetworkContext(); }; diff --git a/lobby/SQLiteConnection.cpp b/lobby/SQLiteConnection.cpp index f72f006ff..439ad839b 100644 --- a/lobby/SQLiteConnection.cpp +++ b/lobby/SQLiteConnection.cpp @@ -157,20 +157,6 @@ void SQLiteStatement::getColumnSingle(size_t index, std::string & value) value = reinterpret_cast(value_raw); } -void SQLiteStatement::setBindVector(const std::vector & values) -{ - for (size_t i = 0; i < values.size(); ++i) - setBindSingle(i + 1, static_cast(values[i])); -} - -void SQLiteStatement::getColumnVector(std::vector & result) -{ - int count = sqlite3_column_count(m_statement); - result.resize(count); - for (int i = 0; i < count; ++i) - result[i] = sqlite3_column_int(m_statement, i); -} - SQLiteInstancePtr SQLiteInstance::open(const boost::filesystem::path & db_path, bool allow_write) { int flags = allow_write ? (SQLITE_OPEN_READWRITE | SQLITE_OPEN_CREATE) : SQLITE_OPEN_READONLY; diff --git a/lobby/SQLiteConnection.h b/lobby/SQLiteConnection.h index 981382cfc..7a8be71e8 100644 --- a/lobby/SQLiteConnection.h +++ b/lobby/SQLiteConnection.h @@ -43,16 +43,12 @@ public: setBindSingle(1, args...); // The leftmost SQL parameter has an index of 1 } - void setBindVector(const std::vector & values); - template void getColumns(Args &... args) { getColumnSingle(0, args...); // The leftmost column of the result set has the index 0 } - void getColumnVector(std::vector & result); - private: void setBindSingle(size_t index, const double & value); void setBindSingle(size_t index, const bool & value); From d2e7e053080f9cbfcccc8d46ae3f0f1f9a531f71 Mon Sep 17 00:00:00 2001 From: Laserlicht <13953785+Laserlicht@users.noreply.github.com> Date: Wed, 25 Mar 2026 23:31:22 +0100 Subject: [PATCH 17/22] method to interface --- lib/network/NetworkHandler.h | 4 ++-- lib/network/NetworkInterface.h | 4 ++++ lobby/LobbyServer.cpp | 3 +-- 3 files changed, 7 insertions(+), 4 deletions(-) diff --git a/lib/network/NetworkHandler.h b/lib/network/NetworkHandler.h index 88501b285..64e4d6a82 100644 --- a/lib/network/NetworkHandler.h +++ b/lib/network/NetworkHandler.h @@ -13,7 +13,7 @@ VCMI_LIB_NAMESPACE_BEGIN -class DLL_LINKAGE NetworkHandler final : public INetworkHandler +class NetworkHandler final : public INetworkHandler { std::unique_ptr context; @@ -29,7 +29,7 @@ public: void run() override; void stop() override; - NetworkContext & getContext(); + NetworkContext & getContext() override; }; VCMI_LIB_NAMESPACE_END diff --git a/lib/network/NetworkInterface.h b/lib/network/NetworkInterface.h index fc8745931..0faa4ee4a 100644 --- a/lib/network/NetworkInterface.h +++ b/lib/network/NetworkInterface.h @@ -9,6 +9,8 @@ */ #pragma once +namespace boost::asio { class io_context; } + VCMI_LIB_NAMESPACE_BEGIN /// Base class for connections with other services, either incoming or outgoing @@ -119,6 +121,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 boost::asio::io_context & getContext() = 0; }; VCMI_LIB_NAMESPACE_END diff --git a/lobby/LobbyServer.cpp b/lobby/LobbyServer.cpp index e017ffde4..d3e5c6688 100644 --- a/lobby/LobbyServer.cpp +++ b/lobby/LobbyServer.cpp @@ -11,7 +11,6 @@ #include "LobbyServer.h" #include "LobbyDatabase.h" -#include "../lib/network/NetworkHandler.h" #include "../lib/json/JsonFormatException.h" #include "../lib/json/JsonNode.h" @@ -849,7 +848,7 @@ LobbyDatabase * LobbyServer::getDatabase() const NetworkContext & LobbyServer::getNetworkContext() { - return static_cast(*networkHandler).getContext(); + return networkHandler->getContext(); } void LobbyServer::start(uint16_t port) From 846d20196e23deb48e21d906816babb331f0f464 Mon Sep 17 00:00:00 2001 From: Laserlicht <13953785+Laserlicht@users.noreply.github.com> Date: Thu, 26 Mar 2026 23:31:59 +0100 Subject: [PATCH 18/22] split http server from implementation --- lobby/CMakeLists.txt | 6 +- lobby/EntryPoint.cpp | 6 +- lobby/HttpApiServer.cpp | 402 ---------------------- lobby/HttpServer.cpp | 217 ++++++++++++ lobby/HttpServer.h | 49 +++ lobby/LobbyHttpApi.cpp | 199 +++++++++++ lobby/{HttpApiServer.h => LobbyHttpApi.h} | 29 +- 7 files changed, 482 insertions(+), 426 deletions(-) delete mode 100644 lobby/HttpApiServer.cpp create mode 100644 lobby/HttpServer.cpp create mode 100644 lobby/HttpServer.h create mode 100644 lobby/LobbyHttpApi.cpp rename lobby/{HttpApiServer.h => LobbyHttpApi.h} (59%) diff --git a/lobby/CMakeLists.txt b/lobby/CMakeLists.txt index c834a095b..7711dd677 100644 --- a/lobby/CMakeLists.txt +++ b/lobby/CMakeLists.txt @@ -2,8 +2,9 @@ set(lobby_SRCS StdInc.cpp EntryPoint.cpp - HttpApiServer.cpp + HttpServer.cpp LobbyDatabase.cpp + LobbyHttpApi.cpp LobbyServer.cpp SQLiteConnection.cpp ) @@ -11,9 +12,10 @@ set(lobby_SRCS set(lobby_HEADERS StdInc.h - HttpApiServer.h + HttpServer.h LobbyDatabase.h LobbyDefines.h + LobbyHttpApi.h LobbyServer.h SQLiteConnection.h ) diff --git a/lobby/EntryPoint.cpp b/lobby/EntryPoint.cpp index 75a76546e..592981f12 100644 --- a/lobby/EntryPoint.cpp +++ b/lobby/EntryPoint.cpp @@ -10,7 +10,8 @@ #include "StdInc.h" #include "LobbyServer.h" -#include "HttpApiServer.h" +#include "HttpServer.h" +#include "LobbyHttpApi.h" #include "../lib/CConsoleHandler.h" #include "../lib/logging/CBasicLogConfigurator.h" @@ -50,7 +51,8 @@ int main(int argc, const char * argv[]) } // Start HTTP API Server - HttpApiServer httpServer(server.getNetworkContext(), *server.getDatabase(), HTTP_API_PORT, HTTP_API_LOCALHOST_ONLY); + LobbyHttpApi lobbyApi(*server.getDatabase()); + HttpServer httpServer(server.getNetworkContext(), lobbyApi, HTTP_API_PORT, HTTP_API_LOCALHOST_ONLY); try { httpServer.start(); diff --git a/lobby/HttpApiServer.cpp b/lobby/HttpApiServer.cpp deleted file mode 100644 index 2d0412d5e..000000000 --- a/lobby/HttpApiServer.cpp +++ /dev/null @@ -1,402 +0,0 @@ -/* - * HttpApiServer.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 "HttpApiServer.h" -#include "LobbyDatabase.h" -#include "EmbeddedWebAssets.h" - -#include "../lib/json/JsonNode.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 {}; -} - -HttpApiServer::HttpApiServer(boost::asio::io_context & ioc, LobbyDatabase & database, unsigned short port, bool localhostOnly) - : database(database) - , port(port) - , localhostOnly(localhostOnly) - , ioc(ioc) -{ -} - -HttpApiServer::~HttpApiServer() -{ - stop(); -} - -void HttpApiServer::start() -{ - startTime = std::chrono::system_clock::now(); - - 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 HttpApiServer::stop() -{ - if (acceptor && acceptor->is_open()) - { - acceptor->close(); - logGlobal->info("HTTP API Server stopped"); - } -} - -void HttpApiServer::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(); - }); -} - -http::response HttpApiServer::handleRequest(http::request && req, beast::tcp_stream & stream) -{ - // Log the request - std::string clientIP = "unknown"; - try { - auto endpoint = stream.socket().remote_endpoint(); - clientIP = 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()); - - auto const createResponse = [&req](http::status status, const std::string & body, const std::string & contentType = "application/json") - { - http::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() = body; - res.prepare_payload(); - return res; - }; - - try - { - if (req.target() == "/api/v1/stats") - { - return createResponse(http::status::ok, getStats()); - } - else if (req.target().starts_with("/api/v1/chats")) - { - std::string channelName = "english"; - if (auto val = extractQueryParameter(req.target(), "channelName"); !val.empty()) - channelName = val; - - return createResponse(http::status::ok, getChats(channelName)); - } - else if (req.target().starts_with("/api/v1/rooms")) - { - 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 createResponse(http::status::bad_request, R"({"error":"Parameter 'hours' must be an integer"})"); } - catch (const std::out_of_range &) { return createResponse(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 createResponse(http::status::bad_request, R"({"error":"Parameter 'limit' must be between 1 and 250"})"); - } - catch (const std::invalid_argument &) { return createResponse(http::status::bad_request, R"({"error":"Parameter 'limit' must be an integer"})"); } - catch (const std::out_of_range &) { return createResponse(http::status::bad_request, R"({"error":"Parameter 'limit' must be between 1 and 250"})"); } - } - - return createResponse(http::status::ok, getRooms(hours, limit)); - } - else if (req.target() == "/api/docs" || req.target() == "/") - { - std::string html = EmbeddedFiles::SWAGGER_CONTENT; - return createResponse(http::status::ok, html, "text/html"); - } - else if (req.target() == "/api/openapi.yaml") - { - std::string spec = EmbeddedFiles::OPENAPI_CONTENT; - return createResponse(http::status::ok, spec, "text/yaml"); - } - else - { - // 404 Not Found - std::string json = R"({ "error": "Not Found", "message": "The requested endpoint does not exist" })"; - return createResponse(http::status::not_found, json); - } - } - catch (const std::exception & e) - { - logGlobal->error("Error handling HTTP request: %s", e.what()); - return createResponse(http::status::internal_server_error, R"({"error":"Internal Server Error"})"); - } -} - -std::string HttpApiServer::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(); -} - -bool HttpApiServer::isCacheValid(const CacheEntry & entry) const -{ - return std::chrono::system_clock::now() - entry.timestamp < std::chrono::seconds(CACHE_TTL_SECONDS); -} - -std::string HttpApiServer::getStats() -{ - { - std::lock_guard lock(cacheMutex); - 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(); - { - std::lock_guard lock(cacheMutex); - statsCache = CacheEntry{json, std::chrono::system_clock::now()}; - } - return json; -} - -std::string HttpApiServer::getChats(const std::string & channelName) -{ - { - std::lock_guard lock(cacheMutex); - 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(); - { - std::lock_guard lock(cacheMutex); - chatsCache[channelName] = CacheEntry{json, std::chrono::system_clock::now()}; - } - return json; -} - -std::string HttpApiServer::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 HttpApiServer::getRooms(int hours, int limit) -{ - { - std::lock_guard lock(cacheMutex); - 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); - - { - std::lock_guard lock(cacheMutex); - roomsCache = RoomsCacheEntry{fetchedRooms, hours, limit, std::chrono::system_clock::now()}; - } - - return serializeRooms(fetchedRooms, hours, limit); -} diff --git a/lobby/HttpServer.cpp b/lobby/HttpServer.cpp new file mode 100644 index 000000000..8143625f9 --- /dev/null +++ b/lobby/HttpServer.cpp @@ -0,0 +1,217 @@ +/* + * 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(); + }); +} + +http::response HttpServer::handleRequest(http::request && req, beast::tcp_stream & stream) +{ + // Log the request + std::string clientIP = "unknown"; + try { + auto endpoint = stream.socket().remote_endpoint(); + clientIP = 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()); + + auto const createResponse = [&req](http::status status, const std::string & body, const std::string & contentType = "application/json") + { + http::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() = body; + res.prepare_payload(); + return res; + }; + + try + { + if (req.target() == "/api/v1/stats") + { + return createResponse(http::status::ok, handler.getApiStats()); + } + else if (req.target().starts_with("/api/v1/chats")) + { + std::string channelName = "english"; + if (auto val = extractQueryParameter(req.target(), "channelName"); !val.empty()) + channelName = val; + + return createResponse(http::status::ok, handler.getApiChats(channelName)); + } + else if (req.target().starts_with("/api/v1/rooms")) + { + 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 createResponse(http::status::bad_request, R"({"error":"Parameter 'hours' must be an integer"})"); } + catch (const std::out_of_range &) { return createResponse(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 createResponse(http::status::bad_request, R"({"error":"Parameter 'limit' must be between 1 and 250"})"); + } + catch (const std::invalid_argument &) { return createResponse(http::status::bad_request, R"({"error":"Parameter 'limit' must be an integer"})"); } + catch (const std::out_of_range &) { return createResponse(http::status::bad_request, R"({"error":"Parameter 'limit' must be between 1 and 250"})"); } + } + + return createResponse(http::status::ok, handler.getApiRooms(hours, limit)); + } + else if (req.target() == "/api/docs" || req.target() == "/") + { + return createResponse(http::status::ok, EmbeddedFiles::SWAGGER_CONTENT, "text/html"); + } + else if (req.target() == "/api/openapi.yaml") + { + return createResponse(http::status::ok, EmbeddedFiles::OPENAPI_CONTENT, "text/yaml"); + } + else + { + std::string json = R"({ "error": "Not Found", "message": "The requested endpoint does not exist" })"; + return createResponse(http::status::not_found, json); + } + } + catch (const std::exception & e) + { + logGlobal->error("Error handling HTTP request: %s", e.what()); + return createResponse(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..73fbee705 --- /dev/null +++ b/lobby/HttpServer.h @@ -0,0 +1,49 @@ +/* + * 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: + void doAccept(); + boost::beast::http::response handleRequest( + boost::beast::http::request && req, + boost::beast::tcp_stream & stream); + + ILobbyHttpHandler & handler; + unsigned short port; + bool localhostOnly; + boost::asio::io_context & ioc; + std::unique_ptr acceptor; +}; diff --git a/lobby/LobbyHttpApi.cpp b/lobby/LobbyHttpApi.cpp new file mode 100644 index 000000000..eff9be078 --- /dev/null +++ b/lobby/LobbyHttpApi.cpp @@ -0,0 +1,199 @@ +/* + * 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() +{ + { + std::lock_guard lock(cacheMutex); + 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(); + { + std::lock_guard lock(cacheMutex); + statsCache = CacheEntry{json, std::chrono::system_clock::now()}; + } + return json; +} + +std::string LobbyHttpApi::getApiChats(const std::string & channelName) +{ + { + std::lock_guard lock(cacheMutex); + 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(); + { + std::lock_guard lock(cacheMutex); + 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) +{ + { + std::lock_guard lock(cacheMutex); + 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); + + { + std::lock_guard lock(cacheMutex); + roomsCache = RoomsCacheEntry{fetchedRooms, hours, limit, std::chrono::system_clock::now()}; + } + + return serializeRooms(fetchedRooms, hours, limit); +} diff --git a/lobby/HttpApiServer.h b/lobby/LobbyHttpApi.h similarity index 59% rename from lobby/HttpApiServer.h rename to lobby/LobbyHttpApi.h index 96443de40..33a6eaed7 100644 --- a/lobby/HttpApiServer.h +++ b/lobby/LobbyHttpApi.h @@ -1,5 +1,5 @@ /* - * HttpApiServer.h, part of VCMI engine + * LobbyHttpApi.h, part of VCMI engine * * Authors: listed in file AUTHORS in main folder * @@ -9,22 +9,23 @@ */ #pragma once -#include -#include +#include "HttpServer.h" #include "LobbyDefines.h" class LobbyDatabase; -class HttpApiServer +/// 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; - HttpApiServer(boost::asio::io_context & ioc, LobbyDatabase & database, unsigned short port, bool localhostOnly); - ~HttpApiServer(); + explicit LobbyHttpApi(LobbyDatabase & database); - void start(); - void stop(); + std::string getApiStats() override; + std::string getApiChats(const std::string & channelName) override; + std::string getApiRooms(int hours, int limit) override; private: struct CacheEntry @@ -34,22 +35,10 @@ private: }; bool isCacheValid(const CacheEntry & entry) const; - - void doAccept(); - boost::beast::http::response handleRequest(boost::beast::http::request && req, boost::beast::tcp_stream & stream); std::string formatTimestamp(std::chrono::system_clock::time_point timePoint); - - std::string getStats(); - std::string getChats(const std::string & channelName); - std::string getRooms(int hours, int limit); std::string serializeRooms(const std::vector & rooms, int hours, int limit); LobbyDatabase & database; - - unsigned short port; - bool localhostOnly; - boost::asio::io_context & ioc; - std::unique_ptr acceptor; std::chrono::system_clock::time_point startTime; mutable std::mutex cacheMutex; From 03baf08accfd1a4742165b06c1c77ae506a150b7 Mon Sep 17 00:00:00 2001 From: Laserlicht <13953785+Laserlicht@users.noreply.github.com> Date: Thu, 26 Mar 2026 23:34:02 +0100 Subject: [PATCH 19/22] bind directly to statement --- lobby/LobbyDatabase.cpp | 29 +++++++++++++---------------- 1 file changed, 13 insertions(+), 16 deletions(-) diff --git a/lobby/LobbyDatabase.cpp b/lobby/LobbyDatabase.cpp index 2c4c27f3d..159cec20e 100644 --- a/lobby/LobbyDatabase.cpp +++ b/lobby/LobbyDatabase.cpp @@ -315,31 +315,31 @@ void LobbyDatabase::prepareStatements() getActiveAccountsCountsBatchStatement = database->prepare(R"( SELECT - COUNT(CASE WHEN lastLoginTime >= datetime('now', '-' || ? || ' hours') THEN 1 END), - COUNT(CASE WHEN lastLoginTime >= datetime('now', '-' || ? || ' hours') THEN 1 END), - COUNT(CASE WHEN lastLoginTime >= datetime('now', '-' || ? || ' hours') THEN 1 END), - COUNT(CASE WHEN lastLoginTime >= datetime('now', '-' || ? || ' hours') THEN 1 END), - COUNT(CASE WHEN lastLoginTime >= datetime('now', '-' || ? || ' hours') THEN 1 END) + COUNT(CASE WHEN lastLoginTime >= datetime('now', '-1 hours') THEN 1 END), + COUNT(CASE WHEN lastLoginTime >= datetime('now', '-24 hours') THEN 1 END), + COUNT(CASE WHEN lastLoginTime >= datetime('now', '-168 hours') THEN 1 END), + COUNT(CASE WHEN lastLoginTime >= datetime('now', '-720 hours') THEN 1 END), + COUNT(CASE WHEN lastLoginTime >= datetime('now', '-8760 hours') THEN 1 END) FROM accounts )"); getRegisteredAccountsCountsBatchStatement = database->prepare(R"( SELECT COUNT(*), - COUNT(CASE WHEN creationTime >= datetime('now', '-' || ? || ' hours') THEN 1 END), - COUNT(CASE WHEN creationTime >= datetime('now', '-' || ? || ' hours') THEN 1 END), - COUNT(CASE WHEN creationTime >= datetime('now', '-' || ? || ' hours') THEN 1 END), - COUNT(CASE WHEN creationTime >= datetime('now', '-' || ? || ' hours') THEN 1 END) + COUNT(CASE WHEN creationTime >= datetime('now', '-24 hours') THEN 1 END), + COUNT(CASE WHEN creationTime >= datetime('now', '-168 hours') THEN 1 END), + COUNT(CASE WHEN creationTime >= datetime('now', '-720 hours') THEN 1 END), + COUNT(CASE WHEN creationTime >= datetime('now', '-8760 hours') THEN 1 END) FROM accounts )"); getClosedGameRoomsCountsBatchStatement = database->prepare(R"( SELECT COUNT(*), - COUNT(CASE WHEN creationTime >= datetime('now', '-' || ? || ' hours') THEN 1 END), - COUNT(CASE WHEN creationTime >= datetime('now', '-' || ? || ' hours') THEN 1 END), - COUNT(CASE WHEN creationTime >= datetime('now', '-' || ? || ' hours') THEN 1 END), - COUNT(CASE WHEN creationTime >= datetime('now', '-' || ? || ' hours') THEN 1 END) + COUNT(CASE WHEN creationTime >= datetime('now', '-24 hours') THEN 1 END), + COUNT(CASE WHEN creationTime >= datetime('now', '-168 hours') THEN 1 END), + COUNT(CASE WHEN creationTime >= datetime('now', '-720 hours') THEN 1 END), + COUNT(CASE WHEN creationTime >= datetime('now', '-8760 hours') THEN 1 END) FROM gameRooms WHERE status = 5 )"); @@ -525,7 +525,6 @@ LobbyDatabase::ActiveAccountsCounts LobbyDatabase::getActiveAccountsCounts() ActiveAccountsCounts result{}; getActiveAccountsCountsBatchStatement->reset(); - getActiveAccountsCountsBatchStatement->setBinds(1, 24, 168, 720, 8760); if(getActiveAccountsCountsBatchStatement->execute()) getActiveAccountsCountsBatchStatement->getColumns(result.h1, result.h24, result.h168, result.h720, result.h8760); @@ -540,7 +539,6 @@ LobbyDatabase::RegisteredAccountsCounts LobbyDatabase::getRegisteredAccountsCoun RegisteredAccountsCounts result{}; getRegisteredAccountsCountsBatchStatement->reset(); - getRegisteredAccountsCountsBatchStatement->setBinds(24, 168, 720, 8760); if(getRegisteredAccountsCountsBatchStatement->execute()) getRegisteredAccountsCountsBatchStatement->getColumns(result.total, result.h24, result.h168, result.h720, result.h8760); @@ -555,7 +553,6 @@ LobbyDatabase::ClosedGameRoomsCounts LobbyDatabase::getClosedGameRoomsCounts() ClosedGameRoomsCounts result{}; getClosedGameRoomsCountsBatchStatement->reset(); - getClosedGameRoomsCountsBatchStatement->setBinds(24, 168, 720, 8760); if(getClosedGameRoomsCountsBatchStatement->execute()) getClosedGameRoomsCountsBatchStatement->getColumns(result.total, result.h24, result.h168, result.h720, result.h8760); From 518648f5269dbc943f37632eb0183923edd41950 Mon Sep 17 00:00:00 2001 From: Laserlicht <13953785+Laserlicht@users.noreply.github.com> Date: Thu, 26 Mar 2026 23:41:47 +0100 Subject: [PATCH 20/22] seperate function for each route --- lobby/HttpServer.cpp | 141 +++++++++++++++++++++++-------------------- lobby/HttpServer.h | 15 ++++- 2 files changed, 87 insertions(+), 69 deletions(-) diff --git a/lobby/HttpServer.cpp b/lobby/HttpServer.cpp index 8143625f9..66b2afea4 100644 --- a/lobby/HttpServer.cpp +++ b/lobby/HttpServer.cpp @@ -124,13 +124,72 @@ void HttpServer::doAccept() }); } -http::response HttpServer::handleRequest(http::request && req, beast::tcp_stream & stream) +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) { - // Log the request std::string clientIP = "unknown"; - try { - auto endpoint = stream.socket().remote_endpoint(); - clientIP = endpoint.address().to_string(); + try + { + clientIP = stream.socket().remote_endpoint().address().to_string(); } catch(const boost::system::system_error & e) { @@ -138,7 +197,8 @@ http::response HttpServer::handleRequest(http::requestinfo("HTTP API Request: %s %s from %s (User-Agent: %s)", req.method_string().data(), @@ -146,72 +206,21 @@ http::response HttpServer::handleRequest(http::request 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() = body; - res.prepare_payload(); - return res; - }; - try { - if (req.target() == "/api/v1/stats") - { - return createResponse(http::status::ok, handler.getApiStats()); - } - else if (req.target().starts_with("/api/v1/chats")) - { - std::string channelName = "english"; - if (auto val = extractQueryParameter(req.target(), "channelName"); !val.empty()) - channelName = val; + 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 createResponse(http::status::ok, handler.getApiChats(channelName)); - } - else if (req.target().starts_with("/api/v1/rooms")) - { - 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 createResponse(http::status::bad_request, R"({"error":"Parameter 'hours' must be an integer"})"); } - catch (const std::out_of_range &) { return createResponse(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 createResponse(http::status::bad_request, R"({"error":"Parameter 'limit' must be between 1 and 250"})"); - } - catch (const std::invalid_argument &) { return createResponse(http::status::bad_request, R"({"error":"Parameter 'limit' must be an integer"})"); } - catch (const std::out_of_range &) { return createResponse(http::status::bad_request, R"({"error":"Parameter 'limit' must be between 1 and 250"})"); } - } - - return createResponse(http::status::ok, handler.getApiRooms(hours, limit)); - } - else if (req.target() == "/api/docs" || req.target() == "/") - { - return createResponse(http::status::ok, EmbeddedFiles::SWAGGER_CONTENT, "text/html"); - } - else if (req.target() == "/api/openapi.yaml") - { - return createResponse(http::status::ok, EmbeddedFiles::OPENAPI_CONTENT, "text/yaml"); - } - else - { - std::string json = R"({ "error": "Not Found", "message": "The requested endpoint does not exist" })"; - return createResponse(http::status::not_found, json); - } + 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 createResponse(http::status::internal_server_error, R"({"error":"Internal Server Error"})"); + return makeResponse(req, http::status::internal_server_error, R"({"error":"Internal Server Error"})"); } } diff --git a/lobby/HttpServer.h b/lobby/HttpServer.h index 73fbee705..b9b52377f 100644 --- a/lobby/HttpServer.h +++ b/lobby/HttpServer.h @@ -36,10 +36,19 @@ public: void stop(); private: + using Request = boost::beast::http::request; + using Response = boost::beast::http::response; + void doAccept(); - boost::beast::http::response handleRequest( - boost::beast::http::request && req, - boost::beast::tcp_stream & stream); + 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; From 91c9f4a5f62fcfe8d7a74fb35762a63fc95ed28b Mon Sep 17 00:00:00 2001 From: Laserlicht <13953785+Laserlicht@users.noreply.github.com> Date: Thu, 26 Mar 2026 23:43:26 +0100 Subject: [PATCH 21/22] remove mutex --- lobby/LobbyHttpApi.cpp | 48 +++++++++++++----------------------------- lobby/LobbyHttpApi.h | 1 - 2 files changed, 15 insertions(+), 34 deletions(-) diff --git a/lobby/LobbyHttpApi.cpp b/lobby/LobbyHttpApi.cpp index eff9be078..b3fe0112b 100644 --- a/lobby/LobbyHttpApi.cpp +++ b/lobby/LobbyHttpApi.cpp @@ -37,11 +37,8 @@ std::string LobbyHttpApi::formatTimestamp(std::chrono::system_clock::time_point std::string LobbyHttpApi::getApiStats() { - { - std::lock_guard lock(cacheMutex); - if (statsCache && isCacheValid(*statsCache)) - return statsCache->json; - } + if (statsCache && isCacheValid(*statsCache)) + return statsCache->json; JsonNode stats; stats["onlinePlayers"].Vector() = JsonVector(); for (const auto & player : database.getActiveAccounts()) @@ -86,21 +83,15 @@ std::string LobbyHttpApi::getApiStats() stats["apiVersion"].String() = "1.0"; std::string json = stats.toCompactString(); - { - std::lock_guard lock(cacheMutex); - statsCache = CacheEntry{json, std::chrono::system_clock::now()}; - } + statsCache = CacheEntry{json, std::chrono::system_clock::now()}; return json; } std::string LobbyHttpApi::getApiChats(const std::string & channelName) { - { - std::lock_guard lock(cacheMutex); - auto it = chatsCache.find(channelName); - if (it != chatsCache.end() && isCacheValid(it->second)) - return it->second.json; - } + auto it = chatsCache.find(channelName); + if (it != chatsCache.end() && isCacheValid(it->second)) + return it->second.json; JsonNode chats; chats["messages"].Vector() = JsonVector(); @@ -124,10 +115,7 @@ std::string LobbyHttpApi::getApiChats(const std::string & channelName) chats["count"].Integer() = chats["messages"].Vector().size(); std::string json = chats.toCompactString(); - { - std::lock_guard lock(cacheMutex); - chatsCache[channelName] = CacheEntry{json, std::chrono::system_clock::now()}; - } + chatsCache[channelName] = CacheEntry{json, std::chrono::system_clock::now()}; return json; } @@ -175,25 +163,19 @@ std::string LobbyHttpApi::serializeRooms(const std::vector & room std::string LobbyHttpApi::getApiRooms(int hours, int limit) { + if (roomsCache) { - std::lock_guard lock(cacheMutex); - 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); - } + 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); - { - std::lock_guard lock(cacheMutex); - roomsCache = RoomsCacheEntry{fetchedRooms, hours, limit, std::chrono::system_clock::now()}; - } + 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 index 33a6eaed7..ad83df59c 100644 --- a/lobby/LobbyHttpApi.h +++ b/lobby/LobbyHttpApi.h @@ -41,7 +41,6 @@ private: LobbyDatabase & database; std::chrono::system_clock::time_point startTime; - mutable std::mutex cacheMutex; std::optional statsCache; std::map chatsCache; From 1bf32d8daf3c385ad6467264f4d0f17922250f77 Mon Sep 17 00:00:00 2001 From: Laserlicht <13953785+Laserlicht@users.noreply.github.com> Date: Thu, 26 Mar 2026 23:50:13 +0100 Subject: [PATCH 22/22] improve statements --- lobby/LobbyDatabase.cpp | 26 +++++++++++++------------- 1 file changed, 13 insertions(+), 13 deletions(-) diff --git a/lobby/LobbyDatabase.cpp b/lobby/LobbyDatabase.cpp index 159cec20e..d593bd893 100644 --- a/lobby/LobbyDatabase.cpp +++ b/lobby/LobbyDatabase.cpp @@ -315,31 +315,31 @@ void LobbyDatabase::prepareStatements() getActiveAccountsCountsBatchStatement = database->prepare(R"( SELECT - COUNT(CASE WHEN lastLoginTime >= datetime('now', '-1 hours') THEN 1 END), - COUNT(CASE WHEN lastLoginTime >= datetime('now', '-24 hours') THEN 1 END), - COUNT(CASE WHEN lastLoginTime >= datetime('now', '-168 hours') THEN 1 END), - COUNT(CASE WHEN lastLoginTime >= datetime('now', '-720 hours') THEN 1 END), - COUNT(CASE WHEN lastLoginTime >= datetime('now', '-8760 hours') THEN 1 END) + 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(*), - COUNT(CASE WHEN creationTime >= datetime('now', '-24 hours') THEN 1 END), - COUNT(CASE WHEN creationTime >= datetime('now', '-168 hours') THEN 1 END), - COUNT(CASE WHEN creationTime >= datetime('now', '-720 hours') THEN 1 END), - COUNT(CASE WHEN creationTime >= datetime('now', '-8760 hours') THEN 1 END) + 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(*), - COUNT(CASE WHEN creationTime >= datetime('now', '-24 hours') THEN 1 END), - COUNT(CASE WHEN creationTime >= datetime('now', '-168 hours') THEN 1 END), - COUNT(CASE WHEN creationTime >= datetime('now', '-720 hours') THEN 1 END), - COUNT(CASE WHEN creationTime >= datetime('now', '-8760 hours') THEN 1 END) + 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 )");