Merge pull request #6748 from Laserlicht/api_lobby

[1.8?] API for lobby
This commit is contained in:
Ivan Savenko
2026-03-28 01:42:16 +02:00
committed by GitHub
16 changed files with 1019 additions and 1 deletions
+52
View File
@@ -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}")
+5
View File
@@ -30,6 +30,11 @@ std::unique_ptr<INetworkServer> NetworkHandler::createServerTCP(INetworkServerLi
return std::make_unique<NetworkServer>(listener, *context);
}
NetworkContext & NetworkHandler::getContext()
{
return *context;
}
std::shared_ptr<INetworkConnection> NetworkHandler::createAsyncConnection(INetworkConnectionListener & listener)
{
auto loopbackConnection = std::make_shared<InternalConnection>(listener, *context);
+2
View File
@@ -30,6 +30,8 @@ public:
void run() override;
void stop() override;
NetworkContext & getContext() override;
};
VCMI_LIB_NAMESPACE_END
+2
View File
@@ -184,6 +184,8 @@ public:
/// Starts network processing on this thread. Does not returns until networking processing has been terminated
virtual void run() = 0;
virtual void stop() = 0;
virtual NetworkContext & getContext() = 0;
};
VCMI_LIB_NAMESPACE_END
+27
View File
@@ -2,7 +2,9 @@ set(lobby_SRCS
StdInc.cpp
EntryPoint.cpp
HttpServer.cpp
LobbyDatabase.cpp
LobbyHttpApi.cpp
LobbyServer.cpp
SQLiteConnection.cpp
)
@@ -10,12 +12,36 @@ set(lobby_SRCS
set(lobby_HEADERS
StdInc.h
HttpServer.h
LobbyDatabase.h
LobbyDefines.h
LobbyHttpApi.h
LobbyServer.h
SQLiteConnection.h
)
# Generate header file from web assets
set(WEB_ASSETS
"${CMAKE_CURRENT_SOURCE_DIR}/web/openapi.yaml"
"${CMAKE_CURRENT_SOURCE_DIR}/web/swagger.html"
)
set(WEB_HEADER_FILE "${CMAKE_CURRENT_BINARY_DIR}/EmbeddedWebAssets.h")
# Convert list to semicolon-separated string for passing to script
string(REPLACE ";" "|" WEB_ASSETS_STRING "${WEB_ASSETS}")
add_custom_command(
OUTPUT "${WEB_HEADER_FILE}"
COMMAND ${CMAKE_COMMAND}
-DINPUT_FILES=${WEB_ASSETS_STRING}
-DOUTPUT_FILE=${WEB_HEADER_FILE}
-P ${CMAKE_CURRENT_SOURCE_DIR}/../cmake_modules/embed_file.cmake
DEPENDS ${WEB_ASSETS}
COMMENT "Embedding web assets into C++ header"
VERBATIM
)
list(APPEND lobby_HEADERS "${WEB_HEADER_FILE}")
assign_source_group(${lobby_SRCS} ${lobby_HEADERS})
add_executable(vcmilobby ${lobby_SRCS} ${lobby_HEADERS})
@@ -29,6 +55,7 @@ target_link_libraries(vcmilobby PRIVATE ${lobby_LIBS} ${SQLite3_LIBRARIES})
target_include_directories(vcmilobby PRIVATE ${SQLite3_INCLUDE_DIRS})
target_include_directories(vcmilobby PUBLIC ${CMAKE_CURRENT_SOURCE_DIR})
target_include_directories(vcmilobby PRIVATE ${CMAKE_CURRENT_BINARY_DIR})
if(WIN32)
set_target_properties(vcmilobby
+20
View File
@@ -10,6 +10,8 @@
#include "StdInc.h"
#include "LobbyServer.h"
#include "HttpServer.h"
#include "LobbyHttpApi.h"
#include "../lib/CConsoleHandler.h"
#include "../lib/logging/CBasicLogConfigurator.h"
@@ -18,6 +20,8 @@
#include "../lib/VCMIDirs.h"
static const int LISTENING_PORT = 3031;
static const int HTTP_API_PORT = 3032;
static const bool HTTP_API_LOCALHOST_ONLY = true;
int main(int argc, const char * argv[])
{
@@ -45,7 +49,23 @@ int main(int argc, const char * argv[])
logGlobal->error("Failed to start server! Another server already uses the same port? Reason: '%s'", e.what());
return 1;
}
// Start HTTP API Server
LobbyHttpApi lobbyApi(*server.getDatabase());
HttpServer httpServer(server.getNetworkContext(), lobbyApi, HTTP_API_PORT, HTTP_API_LOCALHOST_ONLY);
try
{
httpServer.start();
}
catch (const std::exception & e)
{
logGlobal->error("Failed to start HTTP API server! Reason: '%s'", e.what());
return 1;
}
server.run();
httpServer.stop();
return 0;
}
+226
View File
@@ -0,0 +1,226 @@
/*
* HttpServer.cpp, part of VCMI engine
*
* Authors: listed in file AUTHORS in main folder
*
* License: GNU General Public License v2.0 or later
* Full text of license available in license.txt file, in main folder
*
*/
#include "StdInc.h"
#include "HttpServer.h"
#include "EmbeddedWebAssets.h"
#include "../lib/logging/CLogger.h"
namespace beast = boost::beast;
namespace http = beast::http;
namespace net = boost::asio;
using tcp = net::ip::tcp;
/// Extracts the value of a URL query parameter by key (e.g. "hours" from "/api?hours=24")
static std::string extractQueryParameter(boost::beast::string_view target, const std::string & key)
{
std::string t(target);
auto qpos = t.find('?');
if (qpos == std::string::npos)
return {};
std::string query = t.substr(qpos + 1);
const std::string prefix = key + '=';
for (std::string::size_type pos = 0; pos < query.size(); )
{
auto amp = query.find('&', pos);
std::string part = query.substr(pos, amp == std::string::npos ? std::string::npos : amp - pos);
if (part.substr(0, prefix.size()) == prefix)
return part.substr(prefix.size());
if (amp == std::string::npos) break;
pos = amp + 1;
}
return {};
}
HttpServer::HttpServer(boost::asio::io_context & ioc, ILobbyHttpHandler & handler, unsigned short port, bool localhostOnly)
: handler(handler)
, port(port)
, localhostOnly(localhostOnly)
, ioc(ioc)
{
}
HttpServer::~HttpServer()
{
stop();
}
void HttpServer::start()
{
acceptor = std::make_unique<tcp::acceptor>(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<beast::tcp_stream>(std::move(socket));
auto buffer = std::make_shared<beast::flat_buffer>();
auto req = std::make_shared<http::request<http::string_body>>();
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<http::response<http::string_body>>(handleRequest(std::move(*req), *stream));
http::async_write(*stream, *res,
[stream, res](boost::system::error_code, std::size_t)
{
beast::error_code shutdownEc;
stream->socket().shutdown(tcp::socket::shutdown_send, shutdownEc);
});
}
catch (const std::exception & e)
{
logGlobal->error("HTTP session error: %s", e.what());
}
});
}
if (acceptor && acceptor->is_open())
doAccept();
});
}
HttpServer::Response HttpServer::makeResponse(const Request & req, http::status status, std::string body, const std::string & contentType)
{
Response res{status, req.version()};
res.set(http::field::server, "VCMI-Lobby-API");
res.set(http::field::content_type, contentType);
res.keep_alive(req.keep_alive());
res.body() = std::move(body);
res.prepare_payload();
return res;
}
HttpServer::Response HttpServer::handleStatsV1(const Request & req)
{
return makeResponse(req, http::status::ok, handler.getApiStats());
}
HttpServer::Response HttpServer::handleChatsV1(const Request & req)
{
std::string channelName = "english";
if (auto val = extractQueryParameter(req.target(), "channelName"); !val.empty())
channelName = val;
return makeResponse(req, http::status::ok, handler.getApiChats(channelName));
}
HttpServer::Response HttpServer::handleRoomsV1(const Request & req)
{
int hours = -1;
int limit = 50;
if (auto val = extractQueryParameter(req.target(), "hours"); !val.empty())
{
try { hours = std::stoi(val); }
catch (const std::invalid_argument &) { return makeResponse(req, http::status::bad_request, R"({"error":"Parameter 'hours' must be an integer"})"); }
catch (const std::out_of_range &) { return makeResponse(req, http::status::bad_request, R"({"error":"Parameter 'hours' is out of range"})"); }
}
if (auto val = extractQueryParameter(req.target(), "limit"); !val.empty())
{
try
{
limit = std::stoi(val);
if (limit < 1 || limit > 250)
return makeResponse(req, http::status::bad_request, R"({"error":"Parameter 'limit' must be between 1 and 250"})");
}
catch (const std::invalid_argument &) { return makeResponse(req, http::status::bad_request, R"({"error":"Parameter 'limit' must be an integer"})"); }
catch (const std::out_of_range &) { return makeResponse(req, http::status::bad_request, R"({"error":"Parameter 'limit' must be between 1 and 250"})"); }
}
return makeResponse(req, http::status::ok, handler.getApiRooms(hours, limit));
}
HttpServer::Response HttpServer::handleDocs(const Request & req)
{
return makeResponse(req, http::status::ok, EmbeddedFiles::SWAGGER_CONTENT, "text/html");
}
HttpServer::Response HttpServer::handleOpenApiSpec(const Request & req)
{
return makeResponse(req, http::status::ok, EmbeddedFiles::OPENAPI_CONTENT, "text/yaml");
}
HttpServer::Response HttpServer::handleRequest(Request && req, beast::tcp_stream & stream)
{
std::string clientIP = "unknown";
try
{
clientIP = stream.socket().remote_endpoint().address().to_string();
}
catch(const boost::system::system_error & e)
{
logGlobal->warn("HTTP API: could not get client IP: %s", e.what());
}
std::string userAgent = std::string(req[http::field::user_agent]);
if (userAgent.empty())
userAgent = "unknown";
logGlobal->info("HTTP API Request: %s %s from %s (User-Agent: %s)",
req.method_string().data(),
req.target().data(),
clientIP.c_str(),
userAgent.c_str());
try
{
if (req.target() == "/api/v1/stats") return handleStatsV1(req);
if (req.target().starts_with("/api/v1/chats")) return handleChatsV1(req);
if (req.target().starts_with("/api/v1/rooms")) return handleRoomsV1(req);
if (req.target() == "/api/docs" ||
req.target() == "/") return handleDocs(req);
if (req.target() == "/api/openapi.yaml") return handleOpenApiSpec(req);
return makeResponse(req, http::status::not_found,
R"({ "error": "Not Found", "message": "The requested endpoint does not exist" })");
}
catch (const std::exception & e)
{
logGlobal->error("Error handling HTTP request: %s", e.what());
return makeResponse(req, http::status::internal_server_error, R"({"error":"Internal Server Error"})");
}
}
+58
View File
@@ -0,0 +1,58 @@
/*
* HttpServer.h, part of VCMI engine
*
* Authors: listed in file AUTHORS in main folder
*
* License: GNU General Public License v2.0 or later
* Full text of license available in license.txt file, in main folder
*
*/
#pragma once
#include <boost/asio.hpp>
#include <boost/beast.hpp>
/// Interface that must be implemented to handle the concrete API endpoints
class ILobbyHttpHandler
{
public:
virtual ~ILobbyHttpHandler() = default;
virtual std::string getApiStats() = 0;
virtual std::string getApiChats(const std::string & channelName) = 0;
virtual std::string getApiRooms(int hours, int limit) = 0;
};
/// Generic HTTP/REST server built on Boost.Beast.
/// Handles the TCP accept loop, request parsing, routing, and response writing.
/// Delegates concrete API data to ILobbyHttpHandler.
class HttpServer
{
public:
HttpServer(boost::asio::io_context & ioc, ILobbyHttpHandler & handler, unsigned short port, bool localhostOnly);
~HttpServer();
void start();
void stop();
private:
using Request = boost::beast::http::request<boost::beast::http::string_body>;
using Response = boost::beast::http::response<boost::beast::http::string_body>;
void doAccept();
Response handleRequest(Request && req, boost::beast::tcp_stream & stream);
static Response makeResponse(const Request & req, boost::beast::http::status status, std::string body, const std::string & contentType = "application/json");
Response handleStatsV1(const Request & req);
Response handleChatsV1(const Request & req);
Response handleRoomsV1(const Request & req);
Response handleDocs(const Request & req);
Response handleOpenApiSpec(const Request & req);
ILobbyHttpHandler & handler;
unsigned short port;
bool localhostOnly;
boost::asio::io_context & ioc;
std::unique_ptr<boost::asio::ip::tcp::acceptor> acceptor;
};
+120
View File
@@ -272,6 +272,15 @@ void LobbyDatabase::prepareStatements()
ORDER BY secondsElapsed ASC
)");
getRoomsStatement = database->prepare(R"(
SELECT roomID, hostAccountID, displayName, description, status, playerLimit, version, mods, strftime('%s',CURRENT_TIMESTAMP)- strftime('%s',gr.creationTime) AS secondsElapsed
FROM gameRooms gr
LEFT JOIN accounts a ON gr.hostAccountID = a.accountID
WHERE (? = -1 OR strftime('%s',CURRENT_TIMESTAMP) - strftime('%s',gr.creationTime) < ? * 3600)
ORDER BY gr.creationTime DESC
LIMIT ?
)");
getGameRoomInvitesStatement = database->prepare(R"(
SELECT a.accountID, a.displayName
FROM gameRoomInvites gri
@@ -304,6 +313,36 @@ void LobbyDatabase::prepareStatements()
WHERE accountID = ?
)");
getActiveAccountsCountsBatchStatement = database->prepare(R"(
SELECT
SUM(lastLoginTime >= datetime('now', '-1 hours')),
SUM(lastLoginTime >= datetime('now', '-24 hours')),
SUM(lastLoginTime >= datetime('now', '-168 hours')),
SUM(lastLoginTime >= datetime('now', '-720 hours')),
SUM(lastLoginTime >= datetime('now', '-8760 hours'))
FROM accounts
)");
getRegisteredAccountsCountsBatchStatement = database->prepare(R"(
SELECT
COUNT(*),
SUM(creationTime >= datetime('now', '-24 hours')),
SUM(creationTime >= datetime('now', '-168 hours')),
SUM(creationTime >= datetime('now', '-720 hours')),
SUM(creationTime >= datetime('now', '-8760 hours'))
FROM accounts
)");
getClosedGameRoomsCountsBatchStatement = database->prepare(R"(
SELECT
COUNT(*),
SUM(creationTime >= datetime('now', '-24 hours')),
SUM(creationTime >= datetime('now', '-168 hours')),
SUM(creationTime >= datetime('now', '-720 hours')),
SUM(creationTime >= datetime('now', '-8760 hours'))
FROM gameRooms WHERE status = 5
)");
isAccountCookieValidStatement = database->prepare(R"(
SELECT COUNT(accountID)
FROM accountCookies
@@ -481,6 +520,48 @@ std::string LobbyDatabase::getAccountDisplayName(const std::string & accountID)
return result;
}
LobbyDatabase::ActiveAccountsCounts LobbyDatabase::getActiveAccountsCounts()
{
ActiveAccountsCounts result{};
getActiveAccountsCountsBatchStatement->reset();
if(getActiveAccountsCountsBatchStatement->execute())
getActiveAccountsCountsBatchStatement->getColumns(result.h1, result.h24, result.h168, result.h720, result.h8760);
getActiveAccountsCountsBatchStatement->reset();
return result;
}
LobbyDatabase::RegisteredAccountsCounts LobbyDatabase::getRegisteredAccountsCounts()
{
RegisteredAccountsCounts result{};
getRegisteredAccountsCountsBatchStatement->reset();
if(getRegisteredAccountsCountsBatchStatement->execute())
getRegisteredAccountsCountsBatchStatement->getColumns(result.total, result.h24, result.h168, result.h720, result.h8760);
getRegisteredAccountsCountsBatchStatement->reset();
return result;
}
LobbyDatabase::ClosedGameRoomsCounts LobbyDatabase::getClosedGameRoomsCounts()
{
ClosedGameRoomsCounts result{};
getClosedGameRoomsCountsBatchStatement->reset();
if(getClosedGameRoomsCountsBatchStatement->execute())
getClosedGameRoomsCountsBatchStatement->getColumns(result.total, result.h24, result.h168, result.h720, result.h8760);
getClosedGameRoomsCountsBatchStatement->reset();
return result;
}
LobbyCookieStatus LobbyDatabase::getAccountCookieStatus(const std::string & accountID, const std::string & accessCookieUUID)
{
bool result = false;
@@ -645,6 +726,45 @@ std::vector<LobbyAccount> LobbyDatabase::getActiveAccounts()
return result;
}
std::vector<LobbyGameRoom> LobbyDatabase::getRooms(int hours, int limit)
{
std::vector<LobbyGameRoom> 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;
+16
View File
@@ -47,6 +47,10 @@ class LobbyDatabase
SQLiteStatementPtr getAccountInviteStatusStatement;
SQLiteStatementPtr getAccountGameRoomStatement;
SQLiteStatementPtr getAccountDisplayNameStatement;
SQLiteStatementPtr getActiveAccountsCountsBatchStatement;
SQLiteStatementPtr getRegisteredAccountsCountsBatchStatement;
SQLiteStatementPtr getClosedGameRoomsCountsBatchStatement;
SQLiteStatementPtr getRoomsStatement;
SQLiteStatementPtr getGameRoomPlayersStatement;
SQLiteStatementPtr getGameRoomInvitesStatement;
SQLiteStatementPtr countRoomUsedSlotsStatement;
@@ -89,12 +93,24 @@ public:
std::vector<LobbyGameRoom> getAccountGameHistory(const std::string & accountID);
std::vector<LobbyGameRoom> getActiveGameRooms();
std::vector<LobbyAccount> getActiveAccounts();
std::vector<LobbyGameRoom> getRooms(int hours, int limit);
std::vector<LobbyChatMessage> getRecentMessageHistory(const std::string & channelType, const std::string & channelName);
std::vector<LobbyChatMessage> getFullMessageHistory(const std::string & channelType, const std::string & channelName);
std::string getIdleGameRoom(const std::string & hostAccountID);
std::string getAccountGameRoom(const std::string & accountID);
std::string getAccountDisplayName(const std::string & accountID);
/// Batch account activity counts: total registered, plus active in last 1h/24h/1w/1m/1y
struct ActiveAccountsCounts { int h1, h24, h168, h720, h8760; };
ActiveAccountsCounts getActiveAccountsCounts();
/// Batch registration counts: total, plus registered in last 24h/1w/1m/1y
struct RegisteredAccountsCounts { int total, h24, h168, h720, h8760; };
RegisteredAccountsCounts getRegisteredAccountsCounts();
/// Batch closed game room counts: total, plus closed in last 24h/1w/1m/1y
struct ClosedGameRoomsCounts { int total, h24, h168, h720, h8760; };
ClosedGameRoomsCounts getClosedGameRoomsCounts();
LobbyCookieStatus getAccountCookieStatus(const std::string & accountID, const std::string & accessCookieUUID);
LobbyInviteStatus getAccountInviteStatus(const std::string & accountID, const std::string & roomID);
+181
View File
@@ -0,0 +1,181 @@
/*
* LobbyHttpApi.cpp, part of VCMI engine
*
* Authors: listed in file AUTHORS in main folder
*
* License: GNU General Public License v2.0 or later
* Full text of license available in license.txt file, in main folder
*
*/
#include "StdInc.h"
#include "LobbyHttpApi.h"
#include "LobbyDatabase.h"
#include "../lib/json/JsonNode.h"
#include "../lib/logging/CLogger.h"
LobbyHttpApi::LobbyHttpApi(LobbyDatabase & database)
: database(database)
, startTime(std::chrono::system_clock::now())
{
}
bool LobbyHttpApi::isCacheValid(const CacheEntry & entry) const
{
return std::chrono::system_clock::now() - entry.timestamp < std::chrono::seconds(CACHE_TTL_SECONDS);
}
std::string LobbyHttpApi::formatTimestamp(std::chrono::system_clock::time_point timePoint)
{
auto tt = std::chrono::system_clock::to_time_t(timePoint);
std::tm tm{};
localtime_r(&tt, &tm);
std::ostringstream oss;
oss << std::put_time(&tm, "%Y-%m-%dT%H:%M:%S%z");
return oss.str();
}
std::string LobbyHttpApi::getApiStats()
{
if (statsCache && isCacheValid(*statsCache))
return statsCache->json;
JsonNode stats;
stats["onlinePlayers"].Vector() = JsonVector();
for (const auto & player : database.getActiveAccounts())
stats["onlinePlayers"].Vector().push_back(JsonNode(player.displayName));
auto activeCounts = database.getActiveAccountsCounts();
stats["onlinePlayersCount"].Struct() = JsonMap{
{"current", JsonNode(static_cast<int64_t>(stats["onlinePlayers"].Vector().size()))},
{"lastHour", JsonNode(static_cast<int64_t>(activeCounts.h1))},
{"lastDay", JsonNode(static_cast<int64_t>(activeCounts.h24))},
{"lastWeek", JsonNode(static_cast<int64_t>(activeCounts.h168))},
{"lastMonth", JsonNode(static_cast<int64_t>(activeCounts.h720))},
{"lastYear", JsonNode(static_cast<int64_t>(activeCounts.h8760))}
};
auto registeredCounts = database.getRegisteredAccountsCounts();
stats["registeredPlayersCount"].Struct() = JsonMap{
{"total", JsonNode(static_cast<int64_t>(registeredCounts.total))},
{"lastDay", JsonNode(static_cast<int64_t>(registeredCounts.h24))},
{"lastWeek", JsonNode(static_cast<int64_t>(registeredCounts.h168))},
{"lastMonth", JsonNode(static_cast<int64_t>(registeredCounts.h720))},
{"lastYear", JsonNode(static_cast<int64_t>(registeredCounts.h8760))}
};
std::map<LobbyRoomState, int> 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<int64_t>(closedCounts.total))},
{"lastDay", JsonNode(static_cast<int64_t>(closedCounts.h24))},
{"lastWeek", JsonNode(static_cast<int64_t>(closedCounts.h168))},
{"lastMonth", JsonNode(static_cast<int64_t>(closedCounts.h720))},
{"lastYear", JsonNode(static_cast<int64_t>(closedCounts.h8760))}
};
stats["lobbyCount"].Struct() = JsonMap{
{"current", JsonNode(static_cast<int64_t>(lobbysCount[LobbyRoomState::PUBLIC] + lobbysCount[LobbyRoomState::PRIVATE]))},
{"public", JsonNode(static_cast<int64_t>(lobbysCount[LobbyRoomState::PUBLIC]))},
{"private", JsonNode(static_cast<int64_t>(lobbysCount[LobbyRoomState::PRIVATE]))}
};
stats["lobbyStartTime"].String() = formatTimestamp(startTime);
stats["server"].String() = "VCMI Lobby";
stats["apiVersion"].String() = "1.0";
std::string json = stats.toCompactString();
statsCache = CacheEntry{json, std::chrono::system_clock::now()};
return json;
}
std::string LobbyHttpApi::getApiChats(const std::string & channelName)
{
auto it = chatsCache.find(channelName);
if (it != chatsCache.end() && isCacheValid(it->second))
return it->second.json;
JsonNode chats;
chats["messages"].Vector() = JsonVector();
chats["channelName"].String() = channelName;
auto messages = database.getRecentMessageHistory("global", channelName);
for (const auto & msg : messages)
{
JsonNode message;
message["displayName"].String() = msg.displayName;
message["messageText"].String() = msg.messageText;
message["ageSeconds"].Integer() = msg.age.count();
auto messageTime = std::chrono::system_clock::now() - msg.age;
message["timestamp"].String() = formatTimestamp(messageTime);
chats["messages"].Vector().push_back(message);
}
chats["count"].Integer() = chats["messages"].Vector().size();
std::string json = chats.toCompactString();
chatsCache[channelName] = CacheEntry{json, std::chrono::system_clock::now()};
return json;
}
std::string LobbyHttpApi::serializeRooms(const std::vector<LobbyGameRoom> & 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<int>(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<const std::byte*>(room.modsJson.data()), room.modsJson.size(), "");
roomNode["mods"] = modsNode;
} catch(const std::exception & e) {
logGlobal->warn("HTTP API: failed to parse mods JSON: %s", e.what());
roomNode["mods"].Struct() = JsonMap{};
}
result["rooms"].Vector().push_back(roomNode);
++count;
}
result["count"].Integer() = result["rooms"].Vector().size();
return result.toCompactString();
}
std::string LobbyHttpApi::getApiRooms(int hours, int limit)
{
if (roomsCache)
{
const auto & c = *roomsCache;
const bool ttlOk = std::chrono::system_clock::now() - c.timestamp < std::chrono::seconds(CACHE_TTL_SECONDS);
const bool hoursOk = c.fetchedHours == -1 || (hours != -1 && c.fetchedHours >= hours);
const bool limitOk = c.fetchedLimit >= limit;
if (ttlOk && hoursOk && limitOk)
return serializeRooms(c.rooms, hours, limit);
}
auto fetchedRooms = database.getRooms(hours, limit);
roomsCache = RoomsCacheEntry{fetchedRooms, hours, limit, std::chrono::system_clock::now()};
return serializeRooms(fetchedRooms, hours, limit);
}
+55
View File
@@ -0,0 +1,55 @@
/*
* LobbyHttpApi.h, part of VCMI engine
*
* Authors: listed in file AUTHORS in main folder
*
* License: GNU General Public License v2.0 or later
* Full text of license available in license.txt file, in main folder
*
*/
#pragma once
#include "HttpServer.h"
#include "LobbyDefines.h"
class LobbyDatabase;
/// Concrete implementation of the VCMI lobby REST API.
/// Fetches data from LobbyDatabase, applies caching, and serializes to JSON.
class LobbyHttpApi : public ILobbyHttpHandler
{
public:
static constexpr int CACHE_TTL_SECONDS = 30;
explicit LobbyHttpApi(LobbyDatabase & database);
std::string getApiStats() override;
std::string getApiChats(const std::string & channelName) override;
std::string getApiRooms(int hours, int limit) override;
private:
struct CacheEntry
{
std::string json;
std::chrono::system_clock::time_point timestamp;
};
bool isCacheValid(const CacheEntry & entry) const;
std::string formatTimestamp(std::chrono::system_clock::time_point timePoint);
std::string serializeRooms(const std::vector<LobbyGameRoom> & rooms, int hours, int limit);
LobbyDatabase & database;
std::chrono::system_clock::time_point startTime;
std::optional<CacheEntry> statsCache;
std::map<std::string, CacheEntry> chatsCache;
struct RoomsCacheEntry
{
std::vector<LobbyGameRoom> rooms;
int fetchedHours; // -1 means all time
int fetchedLimit;
std::chrono::system_clock::time_point timestamp;
};
std::optional<RoomsCacheEntry> roomsCache;
};
+10
View File
@@ -841,6 +841,16 @@ LobbyServer::LobbyServer(const boost::filesystem::path & databasePath)
{
}
LobbyDatabase * LobbyServer::getDatabase() const
{
return database.get();
}
NetworkContext & LobbyServer::getNetworkContext()
{
return networkHandler->getContext();
}
void LobbyServer::start(uint16_t port)
{
networkServer->start(port);
+4 -1
View File
@@ -9,7 +9,7 @@
*/
#pragma once
#include "../lib/network/NetworkInterface.h"
#include "../lib/network/NetworkDefines.h"
#include "LobbyDefines.h"
VCMI_LIB_NAMESPACE_BEGIN
@@ -101,4 +101,7 @@ public:
void start(uint16_t port);
void run();
LobbyDatabase * getDatabase() const;
NetworkContext & getNetworkContext();
};
+215
View File
@@ -0,0 +1,215 @@
openapi: 3.0.0
info:
title: VCMI Lobby API
description: REST API for VCMI Lobby Server statistics and information
version: 1.0.0
servers:
- url: /api/v1
description: API v1
paths:
/stats:
get:
summary: Get lobby statistics
description: Returns comprehensive statistics about the VCMI lobby including online players, registered players, games, and lobbies
responses:
'200':
description: Successful response
content:
application/json:
schema:
type: object
properties:
onlinePlayers:
type: array
items:
type: string
description: List of currently online player display names
onlinePlayersCount:
type: object
properties:
current:
type: integer
description: Currently online players
lastHour:
type: integer
description: Active in last hour
lastDay:
type: integer
description: Active in last 24 hours
lastWeek:
type: integer
description: Active in last week
lastMonth:
type: integer
description: Active in last month
lastYear:
type: integer
description: Active in last year
registeredPlayersCount:
type: object
properties:
total:
type: integer
description: Total registered players
lastDay:
type: integer
description: Registered in last 24 hours
lastWeek:
type: integer
description: Registered in last week
lastMonth:
type: integer
description: Registered in last month
lastYear:
type: integer
description: Registered in last year
gameCount:
type: object
properties:
current:
type: integer
description: Currently running games
total:
type: integer
description: Total completed games
lastDay:
type: integer
description: Games in last 24 hours
lastWeek:
type: integer
description: Games in last week
lastMonth:
type: integer
description: Games in last month
lastYear:
type: integer
description: Games in last year
lobbyCount:
type: object
properties:
current:
type: integer
description: Currently open lobbies
public:
type: integer
description: Public lobbies
private:
type: integer
description: Private lobbies
lobbyStartTime:
type: string
format: date-time
description: Server start timestamp
server:
type: string
description: Server name
apiVersion:
type: string
description: API version
/chats:
get:
summary: Get public chat messages
description: Returns recent messages from a public chat channel
parameters:
- in: query
name: channelName
schema:
type: string
default: english
required: false
description: Name of the chat channel (default is 'english')
responses:
'200':
description: Successful response
content:
application/json:
schema:
type: object
properties:
channelName:
type: string
description: Name of the requested chat channel
messages:
type: array
items:
type: object
properties:
displayName:
type: string
description: Display name of the message sender
messageText:
type: string
description: The message content
ageSeconds:
type: integer
description: Message age in seconds
timestamp:
type: string
format: date-time
description: Message creation timestamp
/rooms:
get:
summary: Get game rooms
description: Returns recent game rooms with optional time filter
parameters:
- in: query
name: hours
schema:
type: integer
default: -1
required: false
description: Filter rooms created in last N hours (-1 for all rooms, default is -1)
- in: query
name: limit
schema:
type: integer
default: 50
minimum: 1
maximum: 250
required: false
description: Maximum number of rooms to return (default 50, max 250)
responses:
'200':
description: Successful response
content:
application/json:
schema:
type: object
properties:
hours:
type: integer
description: Hours filter applied
limit:
type: integer
description: Limit applied
count:
type: integer
description: Number of rooms returned
rooms:
type: array
items:
type: object
properties:
description:
type: string
description: Room description
status:
type: integer
description: Room status (0=preparing, 1=public, 2=private, 3=busy, 4=closed, 5=abandoned)
playerLimit:
type: integer
description: Maximum number of players
version:
type: string
description: Game version
secondsElapsed:
type: integer
description: Seconds since room creation
createdAt:
type: string
format: date-time
description: Room creation timestamp
mods:
type: object
description: JSON object containing mod configuration
+26
View File
@@ -0,0 +1,26 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>VCMI Lobby API - Swagger UI</title>
<link rel="stylesheet" href="https://unpkg.com/swagger-ui-dist@5/swagger-ui.css">
</head>
<body>
<div id="swagger-ui"></div>
<script src="https://unpkg.com/swagger-ui-dist@5/swagger-ui-bundle.js"></script>
<script src="https://unpkg.com/swagger-ui-dist@5/swagger-ui-standalone-preset.js"></script>
<script>
window.onload = function() {
SwaggerUIBundle({
url: '/api/openapi.yaml',
dom_id: '#swagger-ui',
presets: [
SwaggerUIBundle.presets.apis,
SwaggerUIStandalonePreset
],
layout: 'StandaloneLayout'
});
};
</script>
</body>
</html>