mirror of
https://github.com/DarkflameUniverse/DarkflameServer.git
synced 2026-06-10 08:44:20 +00:00
WIP: basic server, no features
This commit is contained in:
@@ -110,6 +110,8 @@ set(CMAKE_ARCHIVE_OUTPUT_DIRECTORY ${CMAKE_BINARY_DIR})
|
||||
set(CMAKE_LIBRARY_OUTPUT_DIRECTORY ${CMAKE_BINARY_DIR})
|
||||
|
||||
find_package(MariaDB)
|
||||
find_package(OpenSSL REQUIRED)
|
||||
|
||||
|
||||
# Create a /resServer directory
|
||||
make_directory(${CMAKE_BINARY_DIR}/resServer)
|
||||
@@ -126,7 +128,7 @@ endif()
|
||||
message(STATUS "Variable: DLU_CONFIG_DIR = ${DLU_CONFIG_DIR}")
|
||||
|
||||
# Copy resource files on first build
|
||||
set(RESOURCE_FILES "sharedconfig.ini" "authconfig.ini" "chatconfig.ini" "worldconfig.ini" "masterconfig.ini" "blocklist.dcf")
|
||||
set(RESOURCE_FILES "sharedconfig.ini" "authconfig.ini" "chatconfig.ini" "worldconfig.ini" "masterconfig.ini" "dashboardconfig.ini" "blocklist.dcf")
|
||||
message(STATUS "Checking resource file integrity")
|
||||
|
||||
include(Utils)
|
||||
@@ -322,6 +324,7 @@ endif()
|
||||
add_subdirectory(dWorldServer)
|
||||
add_subdirectory(dAuthServer)
|
||||
add_subdirectory(dChatServer)
|
||||
add_subdirectory(dDashboardServer)
|
||||
add_subdirectory(dMasterServer) # Add MasterServer last so it can rely on the other binaries
|
||||
|
||||
target_precompile_headers(
|
||||
|
||||
@@ -19,23 +19,24 @@
|
||||
#include "eGameMasterLevel.h"
|
||||
#include "dChatFilter.h"
|
||||
#include "TeamContainer.h"
|
||||
#include "HTTPContext.h"
|
||||
|
||||
using json = nlohmann::json;
|
||||
|
||||
void HandleHTTPPlayersRequest(HTTPReply& reply, std::string body) {
|
||||
void HandleHTTPPlayersRequest(HTTPReply& reply, const HTTPContext& context) {
|
||||
const json data = Game::playerContainer;
|
||||
reply.status = data.empty() ? eHTTPStatusCode::NO_CONTENT : eHTTPStatusCode::OK;
|
||||
reply.message = data.empty() ? "{\"error\":\"No Players Online\"}" : data.dump();
|
||||
}
|
||||
|
||||
void HandleHTTPTeamsRequest(HTTPReply& reply, std::string body) {
|
||||
void HandleHTTPTeamsRequest(HTTPReply& reply, const HTTPContext& context) {
|
||||
const json data = TeamContainer::GetTeamContainer();
|
||||
reply.status = data.empty() ? eHTTPStatusCode::NO_CONTENT : eHTTPStatusCode::OK;
|
||||
reply.message = data.empty() ? "{\"error\":\"No Teams Online\"}" : data.dump();
|
||||
}
|
||||
|
||||
void HandleHTTPAnnounceRequest(HTTPReply& reply, std::string body) {
|
||||
auto data = GeneralUtils::TryParse<json>(body);
|
||||
void HandleHTTPAnnounceRequest(HTTPReply& reply, const HTTPContext& context) {
|
||||
auto data = GeneralUtils::TryParse<json>(context.body);
|
||||
if (!data) {
|
||||
reply.status = eHTTPStatusCode::BAD_REQUEST;
|
||||
reply.message = "{\"error\":\"Invalid JSON\"}";
|
||||
@@ -96,18 +97,21 @@ namespace ChatWeb {
|
||||
Game::web.RegisterHTTPRoute({
|
||||
.path = v1_route + "players",
|
||||
.method = eHTTPMethod::GET,
|
||||
.middleware = {},
|
||||
.handle = HandleHTTPPlayersRequest
|
||||
});
|
||||
|
||||
Game::web.RegisterHTTPRoute({
|
||||
.path = v1_route + "teams",
|
||||
.method = eHTTPMethod::GET,
|
||||
.middleware = {},
|
||||
.handle = HandleHTTPTeamsRequest
|
||||
});
|
||||
|
||||
Game::web.RegisterHTTPRoute({
|
||||
.path = v1_route + "announce",
|
||||
.method = eHTTPMethod::POST,
|
||||
.middleware = {},
|
||||
.handle = HandleHTTPAnnounceRequest
|
||||
});
|
||||
|
||||
|
||||
@@ -27,6 +27,8 @@ namespace MessageType {
|
||||
AFFIRM_TRANSFER_REQUEST,
|
||||
AFFIRM_TRANSFER_RESPONSE,
|
||||
|
||||
NEW_SESSION_ALERT
|
||||
NEW_SESSION_ALERT,
|
||||
|
||||
REQUEST_SERVER_LIST
|
||||
};
|
||||
}
|
||||
|
||||
@@ -5,7 +5,8 @@ enum class ServiceType : uint16_t {
|
||||
COMMON = 0,
|
||||
AUTH,
|
||||
CHAT,
|
||||
WORLD = 4,
|
||||
DASHBOARD,
|
||||
WORLD,
|
||||
CLIENT,
|
||||
MASTER,
|
||||
UNKNOWN
|
||||
|
||||
64
dDashboardServer/CMakeLists.txt
Normal file
64
dDashboardServer/CMakeLists.txt
Normal file
@@ -0,0 +1,64 @@
|
||||
set(DDASHBOARDSERVER_SOURCES
|
||||
"DashboardServer.cpp"
|
||||
"MasterPacketHandler.cpp"
|
||||
"routes/APIRoutes.cpp"
|
||||
"routes/StaticRoutes.cpp"
|
||||
"routes/DashboardRoutes.cpp"
|
||||
"routes/WSRoutes.cpp"
|
||||
"routes/AuthRoutes.cpp"
|
||||
"auth/JWTUtils.cpp"
|
||||
"auth/DashboardAuthService.cpp"
|
||||
"auth/AuthMiddleware.cpp"
|
||||
"auth/RequireAuthMiddleware.cpp"
|
||||
)
|
||||
|
||||
add_executable(DashboardServer ${DDASHBOARDSERVER_SOURCES})
|
||||
|
||||
target_include_directories(DashboardServer PRIVATE
|
||||
"${PROJECT_SOURCE_DIR}/dCommon"
|
||||
"${PROJECT_SOURCE_DIR}/dCommon/dClient"
|
||||
"${PROJECT_SOURCE_DIR}/dCommon/dEnums"
|
||||
"${PROJECT_SOURCE_DIR}/dDatabase"
|
||||
"${PROJECT_SOURCE_DIR}/dDatabase/CDClientDatabase"
|
||||
"${PROJECT_SOURCE_DIR}/dDatabase/CDClientDatabase/CDClientTables"
|
||||
"${PROJECT_SOURCE_DIR}/dDatabase/GameDatabase"
|
||||
"${PROJECT_SOURCE_DIR}/dDatabase/GameDatabase/ITables"
|
||||
"${PROJECT_SOURCE_DIR}/dDatabase/GameDatabase/MySQL"
|
||||
"${PROJECT_SOURCE_DIR}/dNet"
|
||||
"${PROJECT_SOURCE_DIR}/dWeb"
|
||||
"${PROJECT_SOURCE_DIR}/dServer"
|
||||
"${PROJECT_SOURCE_DIR}/thirdparty"
|
||||
"${PROJECT_SOURCE_DIR}/thirdparty/nlohmann"
|
||||
"${PROJECT_SOURCE_DIR}/dDashboardServer/auth"
|
||||
"${PROJECT_SOURCE_DIR}/dDashboardServer/routes"
|
||||
)
|
||||
|
||||
target_link_libraries(DashboardServer ${COMMON_LIBRARIES} dWeb dServer bcrypt OpenSSL::Crypto)
|
||||
|
||||
|
||||
# Copy static files and templates to build directory (always copy)
|
||||
add_custom_command(TARGET DashboardServer POST_BUILD
|
||||
COMMAND ${CMAKE_COMMAND} -E remove_directory
|
||||
${CMAKE_BINARY_DIR}/dDashboardServer/static
|
||||
COMMENT "Removing old static files"
|
||||
)
|
||||
|
||||
add_custom_command(TARGET DashboardServer POST_BUILD
|
||||
COMMAND ${CMAKE_COMMAND} -E copy_directory
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/static
|
||||
${CMAKE_BINARY_DIR}/dDashboardServer/static
|
||||
COMMENT "Copying DashboardServer static files"
|
||||
)
|
||||
|
||||
add_custom_command(TARGET DashboardServer POST_BUILD
|
||||
COMMAND ${CMAKE_COMMAND} -E remove_directory
|
||||
${CMAKE_BINARY_DIR}/dDashboardServer/templates
|
||||
COMMENT "Removing old templates"
|
||||
)
|
||||
|
||||
add_custom_command(TARGET DashboardServer POST_BUILD
|
||||
COMMAND ${CMAKE_COMMAND} -E copy_directory
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/templates
|
||||
${CMAKE_BINARY_DIR}/dDashboardServer/templates
|
||||
COMMENT "Copying DashboardServer templates"
|
||||
)
|
||||
192
dDashboardServer/DashboardServer.cpp
Normal file
192
dDashboardServer/DashboardServer.cpp
Normal file
@@ -0,0 +1,192 @@
|
||||
#include <chrono>
|
||||
#include <cstdlib>
|
||||
#include <iostream>
|
||||
#include <thread>
|
||||
#include <csignal>
|
||||
#include <memory>
|
||||
|
||||
#include "CDClientDatabase.h"
|
||||
#include "CDClientManager.h"
|
||||
#include "Database.h"
|
||||
#include "dConfig.h"
|
||||
#include "Logger.h"
|
||||
#include "dServer.h"
|
||||
#include "AssetManager.h"
|
||||
#include "BinaryPathFinder.h"
|
||||
#include "ServiceType.h"
|
||||
#include "MessageType/Master.h"
|
||||
#include "Game.h"
|
||||
#include "BitStreamUtils.h"
|
||||
#include "Diagnostics.h"
|
||||
#include "Web.h"
|
||||
#include "Server.h"
|
||||
#include "MasterPacketHandler.h"
|
||||
|
||||
#include "routes/ServerState.h"
|
||||
#include "routes/APIRoutes.h"
|
||||
#include "routes/StaticRoutes.h"
|
||||
#include "routes/DashboardRoutes.h"
|
||||
#include "routes/WSRoutes.h"
|
||||
#include "routes/AuthRoutes.h"
|
||||
#include "AuthMiddleware.h"
|
||||
|
||||
namespace Game {
|
||||
Logger* logger = nullptr;
|
||||
dServer* server = nullptr;
|
||||
dConfig* config = nullptr;
|
||||
Game::signal_t lastSignal = 0;
|
||||
std::mt19937 randomEngine;
|
||||
}
|
||||
|
||||
// Define global server state
|
||||
namespace ServerState {
|
||||
ServerStatus g_AuthStatus{};
|
||||
ServerStatus g_ChatStatus{};
|
||||
std::vector<WorldInstanceInfo> g_WorldInstances{};
|
||||
std::mutex g_StatusMutex{};
|
||||
}
|
||||
|
||||
namespace {
|
||||
dServer* g_Server = nullptr;
|
||||
bool g_RequestedServerList = false;
|
||||
}
|
||||
|
||||
int main(int argc, char** argv) {
|
||||
Diagnostics::SetProduceMemoryDump(true);
|
||||
std::signal(SIGINT, Game::OnSignal);
|
||||
std::signal(SIGTERM, Game::OnSignal);
|
||||
|
||||
uint32_t maxClients = 999;
|
||||
uint32_t ourPort = 2006;
|
||||
std::string ourIP = "127.0.0.1";
|
||||
|
||||
// Read config
|
||||
Game::config = new dConfig("dashboardconfig.ini");
|
||||
|
||||
// Setup logger
|
||||
Server::SetupLogger("DashboardServer");
|
||||
if (!Game::logger) return EXIT_FAILURE;
|
||||
Game::config->LogSettings();
|
||||
|
||||
LOG("Starting Dashboard Server");
|
||||
|
||||
// Load settings
|
||||
if (Game::config->GetValue("max_clients") != "")
|
||||
maxClients = std::stoi(Game::config->GetValue("max_clients"));
|
||||
|
||||
if (Game::config->GetValue("port") != "")
|
||||
ourPort = std::atoi(Game::config->GetValue("port").c_str());
|
||||
|
||||
if (Game::config->GetValue("listen_ip") != "")
|
||||
ourIP = Game::config->GetValue("listen_ip");
|
||||
|
||||
// Connect to CDClient database
|
||||
try {
|
||||
const std::string cdclientPath = BinaryPathFinder::GetBinaryDir() / "resServer/CDServer.sqlite";
|
||||
CDClientDatabase::Connect(cdclientPath);
|
||||
} catch (std::exception& ex) {
|
||||
LOG("Failed to connect to CDClient database: %s", ex.what());
|
||||
return EXIT_FAILURE;
|
||||
}
|
||||
|
||||
// Connect to the database
|
||||
try {
|
||||
Database::Connect();
|
||||
} catch (std::exception& ex) {
|
||||
LOG("Failed to connect to the database: %s", ex.what());
|
||||
return EXIT_FAILURE;
|
||||
}
|
||||
|
||||
// Get master info from database
|
||||
std::string masterIP = "localhost";
|
||||
uint32_t masterPort = 1000;
|
||||
std::string masterPassword;
|
||||
auto masterInfo = Database::Get()->GetMasterInfo();
|
||||
if (masterInfo) {
|
||||
masterIP = masterInfo->ip;
|
||||
masterPort = masterInfo->port;
|
||||
masterPassword = masterInfo->password;
|
||||
}
|
||||
|
||||
// Setup network server for communicating with Master
|
||||
g_Server = new dServer(
|
||||
masterIP,
|
||||
ourPort,
|
||||
0,
|
||||
maxClients,
|
||||
false,
|
||||
false,
|
||||
Game::logger,
|
||||
masterIP,
|
||||
masterPort,
|
||||
ServiceType::DASHBOARD, // Connect as dashboard to master
|
||||
Game::config,
|
||||
&Game::lastSignal,
|
||||
masterPassword
|
||||
);
|
||||
|
||||
// Initialize web server
|
||||
if (!Game::web.Startup(ourIP, ourPort)) {
|
||||
LOG("Failed to start web server on %s:%d", ourIP.c_str(), ourPort);
|
||||
return EXIT_FAILURE;
|
||||
}
|
||||
|
||||
// Register global middleware
|
||||
Game::web.AddGlobalMiddleware(std::make_shared<AuthMiddleware>());
|
||||
|
||||
// Register routes in order: API, Static, Auth, WebSocket, Dashboard (dashboard MUST be last)
|
||||
RegisterAPIRoutes();
|
||||
RegisterStaticRoutes();
|
||||
RegisterAuthRoutes();
|
||||
RegisterWSRoutes();
|
||||
RegisterDashboardRoutes(); // Must be last - catches all unmatched routes
|
||||
|
||||
LOG("Dashboard Server started successfully on %s:%d", ourIP.c_str(), ourPort);
|
||||
LOG("Connected to Master Server at %s:%d", masterIP.c_str(), masterPort);
|
||||
|
||||
// Main loop
|
||||
auto lastTime = std::chrono::high_resolution_clock::now();
|
||||
auto lastBroadcast = lastTime;
|
||||
auto currentTime = lastTime;
|
||||
constexpr float deltaTime = 1.0f / 60.0f; // 60 FPS
|
||||
constexpr float broadcastInterval = 2000.0f; // Broadcast every 2 seconds
|
||||
|
||||
while (!Game::ShouldShutdown()) {
|
||||
currentTime = std::chrono::high_resolution_clock::now();
|
||||
const auto elapsed = std::chrono::duration_cast<std::chrono::milliseconds>(currentTime - lastTime).count();
|
||||
const auto elapsedSinceBroadcast = std::chrono::duration_cast<std::chrono::milliseconds>(currentTime - lastBroadcast).count();
|
||||
|
||||
if (elapsed >= 1000.0f / 60.0f) {
|
||||
// Handle master server packets
|
||||
Packet* packet = g_Server->ReceiveFromMaster();
|
||||
if (packet) {
|
||||
MasterPacketHandler::HandleMasterPacket(packet);
|
||||
g_Server->DeallocateMasterPacket(packet);
|
||||
}
|
||||
|
||||
// Handle web requests
|
||||
Game::web.ReceiveRequests();
|
||||
|
||||
// Broadcast dashboard updates periodically
|
||||
if (elapsedSinceBroadcast >= broadcastInterval) {
|
||||
BroadcastDashboardUpdate();
|
||||
lastBroadcast = currentTime;
|
||||
}
|
||||
|
||||
lastTime = currentTime;
|
||||
}
|
||||
|
||||
std::this_thread::sleep_for(std::chrono::milliseconds(1));
|
||||
}
|
||||
|
||||
// Cleanup
|
||||
Database::Destroy("DashboardServer");
|
||||
delete g_Server;
|
||||
g_Server = nullptr;
|
||||
delete Game::logger;
|
||||
Game::logger = nullptr;
|
||||
delete Game::config;
|
||||
Game::config = nullptr;
|
||||
|
||||
return EXIT_SUCCESS;
|
||||
}
|
||||
209
dDashboardServer/MasterPacketHandler.cpp
Normal file
209
dDashboardServer/MasterPacketHandler.cpp
Normal file
@@ -0,0 +1,209 @@
|
||||
#include "MasterPacketHandler.h"
|
||||
|
||||
#include "BitStreamUtils.h"
|
||||
#include "dServer.h"
|
||||
#include "Game.h"
|
||||
#include "Logger.h"
|
||||
#include "RakNetTypes.h"
|
||||
#include "routes/ServerState.h"
|
||||
#include <chrono>
|
||||
#include <mutex>
|
||||
|
||||
namespace MasterPacketHandler {
|
||||
namespace {
|
||||
std::map<MessageType::Master, std::function<std::unique_ptr<MasterPacket>()>> g_Handlers = {
|
||||
{MessageType::Master::SERVER_INFO, []() {
|
||||
return std::make_unique<ServerInfo>();
|
||||
}},
|
||||
{MessageType::Master::PLAYER_ADDED, []() {
|
||||
return std::make_unique<PlayerAdded>();
|
||||
}},
|
||||
{MessageType::Master::PLAYER_REMOVED, []() {
|
||||
return std::make_unique<PlayerRemoved>();
|
||||
}},
|
||||
{MessageType::Master::SHUTDOWN_RESPONSE, []() {
|
||||
return std::make_unique<ShutdownResponse>();
|
||||
}},
|
||||
{MessageType::Master::SHUTDOWN, []() {
|
||||
return std::make_unique<Shutdown>();
|
||||
}},
|
||||
};
|
||||
}
|
||||
|
||||
bool ServerInfo::Deserialize(RakNet::BitStream& bitStream) {
|
||||
VALIDATE_READ(bitStream.Read(port));
|
||||
VALIDATE_READ(bitStream.Read(zoneID));
|
||||
VALIDATE_READ(bitStream.Read(instanceID));
|
||||
VALIDATE_READ(bitStream.Read(serverType));
|
||||
LUString ipStr{};
|
||||
VALIDATE_READ(bitStream.Read(ipStr));
|
||||
ip = ipStr.string;
|
||||
return true;
|
||||
}
|
||||
|
||||
void ServerInfo::Handle() {
|
||||
std::lock_guard<std::mutex> lock(ServerState::g_StatusMutex);
|
||||
|
||||
LOG("MasterPacketHandler: Processing SERVER_INFO for service type %i, zone %u, instance %u, port %u", serverType, zoneID, instanceID, port);
|
||||
|
||||
switch (serverType) {
|
||||
case ServiceType::AUTH:
|
||||
ServerState::g_AuthStatus.online = true;
|
||||
ServerState::g_AuthStatus.lastSeen = std::chrono::steady_clock::now();
|
||||
LOG("Updated Auth server status: online");
|
||||
break;
|
||||
case ServiceType::CHAT:
|
||||
ServerState::g_ChatStatus.online = true;
|
||||
ServerState::g_ChatStatus.lastSeen = std::chrono::steady_clock::now();
|
||||
LOG("Updated Chat server status: online");
|
||||
break;
|
||||
case ServiceType::WORLD: {
|
||||
// Update or add world instance
|
||||
bool found = false;
|
||||
for (auto& world : ServerState::g_WorldInstances) {
|
||||
if (world.mapID == zoneID && world.instanceID == instanceID) {
|
||||
world.ip = ip;
|
||||
world.port = port;
|
||||
found = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!found) {
|
||||
WorldInstanceInfo info{};
|
||||
info.mapID = zoneID;
|
||||
info.instanceID = instanceID;
|
||||
info.cloneID = 0;
|
||||
info.players = 0;
|
||||
info.ip = ip;
|
||||
info.port = port;
|
||||
info.isPrivate = false;
|
||||
ServerState::g_WorldInstances.push_back(info);
|
||||
LOG("Added world instance: map %u instance %u", zoneID, instanceID);
|
||||
}
|
||||
break;
|
||||
}
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
bool PlayerAdded::Deserialize(RakNet::BitStream& bitStream) {
|
||||
VALIDATE_READ(bitStream.Read(zoneID));
|
||||
VALIDATE_READ(bitStream.Read(instanceID));
|
||||
return true;
|
||||
}
|
||||
|
||||
void PlayerAdded::Handle() {
|
||||
std::lock_guard<std::mutex> lock(ServerState::g_StatusMutex);
|
||||
for (auto& world : ServerState::g_WorldInstances) {
|
||||
if (world.mapID == zoneID && world.instanceID == instanceID) {
|
||||
world.players++;
|
||||
LOG_DEBUG("Player added to map %u instance %u, now %u players", zoneID, instanceID, world.players);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
bool PlayerRemoved::Deserialize(RakNet::BitStream& bitStream) {
|
||||
VALIDATE_READ(bitStream.Read(zoneID));
|
||||
VALIDATE_READ(bitStream.Read(instanceID));
|
||||
return true;
|
||||
}
|
||||
|
||||
void PlayerRemoved::Handle() {
|
||||
std::lock_guard<std::mutex> lock(ServerState::g_StatusMutex);
|
||||
for (auto& world : ServerState::g_WorldInstances) {
|
||||
if (world.mapID == zoneID && world.instanceID == instanceID) {
|
||||
if (world.players > 0) world.players--;
|
||||
LOG_DEBUG("Player removed from map %u instance %u, now %u players", zoneID, instanceID, world.players);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
bool ShutdownResponse::Deserialize(RakNet::BitStream& bitStream) {
|
||||
VALIDATE_READ(bitStream.Read(zoneID));
|
||||
VALIDATE_READ(bitStream.Read(instanceID));
|
||||
VALIDATE_READ(bitStream.Read(serverType));
|
||||
return true;
|
||||
}
|
||||
|
||||
void ShutdownResponse::Handle() {
|
||||
std::lock_guard<std::mutex> lock(ServerState::g_StatusMutex);
|
||||
|
||||
switch (serverType) {
|
||||
case ServiceType::AUTH:
|
||||
ServerState::g_AuthStatus.online = false;
|
||||
LOG_DEBUG("Auth server shutdown");
|
||||
break;
|
||||
case ServiceType::CHAT:
|
||||
ServerState::g_ChatStatus.online = false;
|
||||
LOG_DEBUG("Chat server shutdown");
|
||||
break;
|
||||
case ServiceType::WORLD:
|
||||
for (auto it = ServerState::g_WorldInstances.begin(); it != ServerState::g_WorldInstances.end(); ++it) {
|
||||
if (it->mapID == zoneID && it->instanceID == instanceID) {
|
||||
ServerState::g_WorldInstances.erase(it);
|
||||
LOG_DEBUG("Removed shutdown instance: map %u instance %u", zoneID, instanceID);
|
||||
break;
|
||||
}
|
||||
}
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
bool Shutdown::Deserialize(RakNet::BitStream& bitStream) {
|
||||
// SHUTDOWN message has no additional data
|
||||
return true;
|
||||
}
|
||||
|
||||
void Shutdown::Handle() {
|
||||
LOG("Received SHUTDOWN command from Master");
|
||||
Game::lastSignal = -1; // Trigger shutdown
|
||||
}
|
||||
|
||||
void HandleMasterPacket(Packet* packet) {
|
||||
if (!packet) return;
|
||||
|
||||
switch (packet->data[0]) {
|
||||
case ID_DISCONNECTION_NOTIFICATION:
|
||||
case ID_CONNECTION_LOST:
|
||||
LOG("Lost connection to Master Server");
|
||||
{
|
||||
std::lock_guard<std::mutex> lock(ServerState::g_StatusMutex);
|
||||
ServerState::g_AuthStatus.online = false;
|
||||
ServerState::g_ChatStatus.online = false;
|
||||
ServerState::g_WorldInstances.clear();
|
||||
}
|
||||
break;
|
||||
case ID_CONNECTION_REQUEST_ACCEPTED:
|
||||
LOG("Connected to Master Server");
|
||||
break;
|
||||
case ID_USER_PACKET_ENUM: {
|
||||
RakNet::BitStream inStream(packet->data, packet->length, false);
|
||||
uint64_t header{};
|
||||
inStream.Read(header);
|
||||
|
||||
const auto packetType = static_cast<MessageType::Master>(header);
|
||||
LOG_DEBUG("Received Master packet type: %i", packetType);
|
||||
|
||||
auto it = g_Handlers.find(packetType);
|
||||
if (it != g_Handlers.end()) {
|
||||
auto handler = it->second();
|
||||
if (!handler->Deserialize(inStream)) {
|
||||
LOG_DEBUG("Error deserializing Master packet type %i", packetType);
|
||||
return;
|
||||
}
|
||||
handler->Handle();
|
||||
} else {
|
||||
LOG_DEBUG("Unhandled Master packet type: %i", packetType);
|
||||
}
|
||||
break;
|
||||
}
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
79
dDashboardServer/MasterPacketHandler.h
Normal file
79
dDashboardServer/MasterPacketHandler.h
Normal file
@@ -0,0 +1,79 @@
|
||||
#pragma once
|
||||
|
||||
#include <functional>
|
||||
#include <map>
|
||||
#include <memory>
|
||||
|
||||
#include "dCommonVars.h"
|
||||
#include "MessageType/Master.h"
|
||||
#include "BitStream.h"
|
||||
|
||||
struct Packet;
|
||||
|
||||
namespace MasterPacketHandler {
|
||||
// Base class for all master packet handlers
|
||||
class MasterPacket {
|
||||
public:
|
||||
virtual ~MasterPacket() = default;
|
||||
virtual bool Deserialize(RakNet::BitStream& bitStream) = 0;
|
||||
virtual void Handle() = 0;
|
||||
};
|
||||
|
||||
// SERVER_INFO packet handler
|
||||
class ServerInfo : public MasterPacket {
|
||||
public:
|
||||
bool Deserialize(RakNet::BitStream& bitStream) override;
|
||||
void Handle() override;
|
||||
|
||||
private:
|
||||
uint32_t port{0};
|
||||
uint32_t zoneID{0};
|
||||
uint32_t instanceID{0};
|
||||
ServiceType serverType{};
|
||||
std::string ip{};
|
||||
};
|
||||
|
||||
// PLAYER_ADDED packet handler
|
||||
class PlayerAdded : public MasterPacket {
|
||||
public:
|
||||
bool Deserialize(RakNet::BitStream& bitStream) override;
|
||||
void Handle() override;
|
||||
|
||||
private:
|
||||
LWOMAPID zoneID{};
|
||||
LWOINSTANCEID instanceID{};
|
||||
};
|
||||
|
||||
// PLAYER_REMOVED packet handler
|
||||
class PlayerRemoved : public MasterPacket {
|
||||
public:
|
||||
bool Deserialize(RakNet::BitStream& bitStream) override;
|
||||
void Handle() override;
|
||||
|
||||
private:
|
||||
LWOMAPID zoneID{};
|
||||
LWOINSTANCEID instanceID{};
|
||||
};
|
||||
|
||||
// SHUTDOWN_RESPONSE packet handler
|
||||
class ShutdownResponse : public MasterPacket {
|
||||
public:
|
||||
bool Deserialize(RakNet::BitStream& bitStream) override;
|
||||
void Handle() override;
|
||||
|
||||
private:
|
||||
uint32_t zoneID{};
|
||||
uint32_t instanceID{};
|
||||
ServiceType serverType{};
|
||||
};
|
||||
|
||||
// SHUTDOWN packet handler
|
||||
class Shutdown : public MasterPacket {
|
||||
public:
|
||||
bool Deserialize(RakNet::BitStream& bitStream) override;
|
||||
void Handle() override;
|
||||
};
|
||||
|
||||
// Main handler function
|
||||
void HandleMasterPacket(Packet* packet);
|
||||
}
|
||||
132
dDashboardServer/auth/AuthMiddleware.cpp
Normal file
132
dDashboardServer/auth/AuthMiddleware.cpp
Normal file
@@ -0,0 +1,132 @@
|
||||
#include "AuthMiddleware.h"
|
||||
#include "DashboardAuthService.h"
|
||||
#include "Game.h"
|
||||
#include "Logger.h"
|
||||
#include <string>
|
||||
#include <cctype>
|
||||
|
||||
// Helper to extract cookie value from header
|
||||
static std::string ExtractCookieValue(const std::string& cookieHeader, const std::string& cookieName) {
|
||||
std::string searchStr = cookieName + "=";
|
||||
size_t pos = cookieHeader.find(searchStr);
|
||||
|
||||
if (pos == std::string::npos) {
|
||||
return "";
|
||||
}
|
||||
|
||||
size_t valueStart = pos + searchStr.length();
|
||||
size_t valueEnd = cookieHeader.find(";", valueStart);
|
||||
|
||||
if (valueEnd == std::string::npos) {
|
||||
valueEnd = cookieHeader.length();
|
||||
}
|
||||
|
||||
std::string value = cookieHeader.substr(valueStart, valueEnd - valueStart);
|
||||
|
||||
// URL decode the value
|
||||
std::string decoded;
|
||||
for (size_t i = 0; i < value.length(); ++i) {
|
||||
if (value[i] == '%' && i + 2 < value.length()) {
|
||||
std::string hex = value.substr(i + 1, 2);
|
||||
char* endptr;
|
||||
int charCode = static_cast<int>(std::strtol(hex.c_str(), &endptr, 16));
|
||||
if (endptr - hex.c_str() == 2) {
|
||||
decoded += static_cast<char>(charCode);
|
||||
i += 2;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
decoded += value[i];
|
||||
}
|
||||
|
||||
return decoded;
|
||||
}
|
||||
|
||||
std::string AuthMiddleware::ExtractTokenFromQueryString(const std::string& queryString) {
|
||||
if (queryString.empty()) {
|
||||
return "";
|
||||
}
|
||||
|
||||
// Parse query string to find token parameter
|
||||
// Expected format: "?token=eyJhbGc..."
|
||||
std::string tokenPrefix = "token=";
|
||||
size_t tokenPos = queryString.find(tokenPrefix);
|
||||
|
||||
if (tokenPos == std::string::npos) {
|
||||
return "";
|
||||
}
|
||||
|
||||
// Extract token value (from "token=" to next "&" or end of string)
|
||||
size_t valueStart = tokenPos + tokenPrefix.length();
|
||||
size_t valueEnd = queryString.find("&", valueStart);
|
||||
|
||||
if (valueEnd == std::string::npos) {
|
||||
valueEnd = queryString.length();
|
||||
}
|
||||
|
||||
return queryString.substr(valueStart, valueEnd - valueStart);
|
||||
}
|
||||
|
||||
std::string AuthMiddleware::ExtractTokenFromCookies(const std::string& cookieHeader) {
|
||||
if (cookieHeader.empty()) {
|
||||
return "";
|
||||
}
|
||||
|
||||
// Extract dashboardToken cookie value
|
||||
return ExtractCookieValue(cookieHeader, "dashboardToken");
|
||||
}
|
||||
|
||||
std::string AuthMiddleware::ExtractTokenFromAuthHeader(const std::string& authHeader) {
|
||||
if (authHeader.empty()) {
|
||||
return "";
|
||||
}
|
||||
|
||||
// Check for "Bearer <token>" format
|
||||
if (authHeader.substr(0, 7) == "Bearer ") {
|
||||
return authHeader.substr(7);
|
||||
}
|
||||
|
||||
// Check for "Token <token>" format
|
||||
if (authHeader.substr(0, 6) == "Token ") {
|
||||
return authHeader.substr(6);
|
||||
}
|
||||
|
||||
// If no prefix, assume raw token
|
||||
return authHeader;
|
||||
}
|
||||
|
||||
bool AuthMiddleware::Process(HTTPContext& context, HTTPReply& reply) {
|
||||
// Try to extract token from various sources (in priority order)
|
||||
std::string token = ExtractTokenFromQueryString(context.queryString);
|
||||
|
||||
if (token.empty()) {
|
||||
const std::string& cookieHeader = context.GetHeader("Cookie");
|
||||
token = ExtractTokenFromCookies(cookieHeader);
|
||||
}
|
||||
|
||||
if (token.empty()) {
|
||||
const std::string& authHeader = context.GetHeader("Authorization");
|
||||
token = ExtractTokenFromAuthHeader(authHeader);
|
||||
}
|
||||
|
||||
// If we found a token, try to verify it
|
||||
if (!token.empty()) {
|
||||
std::string username;
|
||||
uint8_t gmLevel{};
|
||||
|
||||
if (DashboardAuthService::VerifyToken(token, username, gmLevel)) {
|
||||
context.isAuthenticated = true;
|
||||
context.authenticatedUser = username;
|
||||
context.gmLevel = gmLevel;
|
||||
LOG_DEBUG("User %s authenticated via API token (GM level %d)", username.c_str(), gmLevel);
|
||||
return true;
|
||||
} else {
|
||||
LOG_DEBUG("Invalid authentication token provided");
|
||||
return true; // Continue - let routes decide if auth is required
|
||||
}
|
||||
}
|
||||
|
||||
// No token found - continue without authentication
|
||||
// Routes can use RequireAuthMiddleware to enforce authentication
|
||||
return true;
|
||||
}
|
||||
34
dDashboardServer/auth/AuthMiddleware.h
Normal file
34
dDashboardServer/auth/AuthMiddleware.h
Normal file
@@ -0,0 +1,34 @@
|
||||
#ifndef __AUTHMIDDLEWARE_H__
|
||||
#define __AUTHMIDDLEWARE_H__
|
||||
|
||||
#include <string>
|
||||
#include <memory>
|
||||
#include "IHTTPMiddleware.h"
|
||||
|
||||
/**
|
||||
* AuthMiddleware: Extracts and verifies authentication tokens
|
||||
*
|
||||
* Token extraction sources (in priority order):
|
||||
* 1. Query parameter: ?token=eyJhbGc...
|
||||
* 2. Cookie: dashboardToken=...
|
||||
* 3. Authorization header: Bearer <token> or Token <token>
|
||||
*
|
||||
* Sets HTTPContext.isAuthenticated, HTTPContext.authenticatedUser,
|
||||
* and HTTPContext.gmLevel if token is valid.
|
||||
*/
|
||||
class AuthMiddleware final : public IHTTPMiddleware {
|
||||
public:
|
||||
AuthMiddleware() = default;
|
||||
~AuthMiddleware() override = default;
|
||||
|
||||
bool Process(HTTPContext& context, HTTPReply& reply) override;
|
||||
std::string GetName() const override { return "AuthMiddleware"; }
|
||||
|
||||
private:
|
||||
// Extract token from various sources
|
||||
static std::string ExtractTokenFromQueryString(const std::string& queryString);
|
||||
static std::string ExtractTokenFromCookies(const std::string& cookieHeader);
|
||||
static std::string ExtractTokenFromAuthHeader(const std::string& authHeader);
|
||||
};
|
||||
|
||||
#endif // !__AUTHMIDDLEWARE_H__
|
||||
144
dDashboardServer/auth/DashboardAuthService.cpp
Normal file
144
dDashboardServer/auth/DashboardAuthService.cpp
Normal file
@@ -0,0 +1,144 @@
|
||||
#include "DashboardAuthService.h"
|
||||
#include "JWTUtils.h"
|
||||
#include "Database.h"
|
||||
#include "Logger.h"
|
||||
#include "Game.h"
|
||||
#include "dConfig.h"
|
||||
#include "GeneralUtils.h"
|
||||
#include <bcrypt/bcrypt.h>
|
||||
#include <ctime>
|
||||
|
||||
namespace {
|
||||
constexpr int64_t LOCKOUT_DURATION = 15 * 60; // 15 minutes in seconds
|
||||
|
||||
}
|
||||
|
||||
DashboardAuthService::LoginResult DashboardAuthService::Login(
|
||||
const std::string& username,
|
||||
const std::string& password,
|
||||
bool rememberMe) {
|
||||
|
||||
LoginResult result;
|
||||
|
||||
if (username.empty() || password.empty()) {
|
||||
result.message = "Username and password are required";
|
||||
return result;
|
||||
}
|
||||
|
||||
if (password.length() > 40) {
|
||||
result.message = "Password exceeds maximum length (40 characters)";
|
||||
return result;
|
||||
}
|
||||
|
||||
try {
|
||||
// Get account info
|
||||
auto accountInfo = Database::Get()->GetAccountInfo(username);
|
||||
if (!accountInfo) {
|
||||
result.message = "Invalid username or password";
|
||||
LOG_DEBUG("Login attempt for non-existent user: %s", username.c_str());
|
||||
return result;
|
||||
}
|
||||
|
||||
uint32_t accountId = accountInfo->id;
|
||||
|
||||
// Check if account is locked
|
||||
bool isLockedOut = Database::Get()->IsLockedOut(accountId);
|
||||
|
||||
if (isLockedOut) {
|
||||
// Record failed attempt even without checking password
|
||||
Database::Get()->RecordFailedAttempt(accountId);
|
||||
uint8_t failedAttempts = Database::Get()->GetFailedAttempts(accountId);
|
||||
|
||||
result.message = "Account is locked due to too many failed attempts";
|
||||
result.accountLocked = true;
|
||||
LOG("Login attempt on locked account: %s (failed attempts: %d)", username.c_str(), failedAttempts);
|
||||
return result;
|
||||
}
|
||||
|
||||
// Check password
|
||||
if (::bcrypt_checkpw(password.c_str(), accountInfo->bcryptPassword.c_str()) != 0) {
|
||||
// Record failed attempt
|
||||
Database::Get()->RecordFailedAttempt(accountId);
|
||||
uint8_t newFailedAttempts = Database::Get()->GetFailedAttempts(accountId);
|
||||
|
||||
// Lock account after 3 failed attempts
|
||||
if (newFailedAttempts >= 3) {
|
||||
int64_t lockoutUntil = std::time(nullptr) + LOCKOUT_DURATION;
|
||||
Database::Get()->SetLockout(accountId, lockoutUntil);
|
||||
result.message = "Account locked due to too many failed attempts";
|
||||
result.accountLocked = true;
|
||||
LOG("Account locked after failed attempts: %s", username.c_str());
|
||||
} else {
|
||||
result.message = "Invalid username or password";
|
||||
LOG_DEBUG("Failed login attempt for user: %s (attempt %d/3)",
|
||||
username.c_str(), newFailedAttempts);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
// Check GM level
|
||||
if (!HasDashboardAccess(static_cast<uint8_t>(accountInfo->maxGmLevel))) {
|
||||
result.message = "Access denied: insufficient permissions";
|
||||
LOG("Access denied for non-admin user: %s", username.c_str());
|
||||
return result;
|
||||
}
|
||||
|
||||
// Successful login
|
||||
Database::Get()->ClearFailedAttempts(accountId);
|
||||
result.success = true;
|
||||
result.gmLevel = static_cast<uint8_t>(accountInfo->maxGmLevel);
|
||||
result.token = JWTUtils::GenerateToken(username, result.gmLevel, rememberMe);
|
||||
result.message = "Login successful";
|
||||
|
||||
LOG("Successful login: %s (GM Level: %d)", username.c_str(), result.gmLevel);
|
||||
return result;
|
||||
|
||||
} catch (const std::exception& ex) {
|
||||
result.message = "An error occurred during login";
|
||||
LOG("Error during login process: %s", ex.what());
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
bool DashboardAuthService::VerifyToken(const std::string& token, std::string& username, uint8_t& gmLevel) {
|
||||
JWTUtils::JWTPayload payload;
|
||||
if (!JWTUtils::ValidateToken(token, payload)) {
|
||||
LOG_DEBUG("Token validation failed: invalid or expired JWT");
|
||||
return false;
|
||||
}
|
||||
|
||||
username = payload.username;
|
||||
gmLevel = payload.gmLevel;
|
||||
|
||||
// Optionally verify user still exists and has access
|
||||
try {
|
||||
auto accountInfo = Database::Get()->GetAccountInfo(username);
|
||||
if (!accountInfo || !HasDashboardAccess(static_cast<uint8_t>(accountInfo->maxGmLevel))) {
|
||||
LOG_DEBUG("Token verification failed: user no longer has access");
|
||||
return false;
|
||||
}
|
||||
} catch (const std::exception& ex) {
|
||||
LOG_DEBUG("Error verifying user during token validation: %s", ex.what());
|
||||
return false;
|
||||
}
|
||||
|
||||
LOG_DEBUG("Token verified successfully for user: %s (GM Level: %d)", username.c_str(), gmLevel);
|
||||
return true;
|
||||
}
|
||||
|
||||
bool DashboardAuthService::HasDashboardAccess(uint8_t gmLevel) {
|
||||
// Get minimum GM level from config (default 0 = any user)
|
||||
uint8_t minGmLevel = 0;
|
||||
|
||||
if (Game::config) {
|
||||
const std::string& minGmLevelStr = Game::config->GetValue("min_dashboard_gm_level");
|
||||
if (!minGmLevelStr.empty()) {
|
||||
const auto parsed = GeneralUtils::TryParse<uint8_t>(minGmLevelStr);
|
||||
if (parsed) {
|
||||
minGmLevel = parsed.value();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return gmLevel >= minGmLevel;
|
||||
}
|
||||
47
dDashboardServer/auth/DashboardAuthService.h
Normal file
47
dDashboardServer/auth/DashboardAuthService.h
Normal file
@@ -0,0 +1,47 @@
|
||||
#pragma once
|
||||
|
||||
#include <string>
|
||||
#include <cstdint>
|
||||
|
||||
/**
|
||||
* Dashboard authentication service
|
||||
* Handles user login, password verification, and account lockout
|
||||
*/
|
||||
class DashboardAuthService {
|
||||
public:
|
||||
/**
|
||||
* Login result structure
|
||||
*/
|
||||
struct LoginResult {
|
||||
bool success{false};
|
||||
std::string message{};
|
||||
std::string token{}; // JWT token if successful
|
||||
uint8_t gmLevel{0}; // GM level if successful
|
||||
bool accountLocked{false}; // Account is locked out
|
||||
};
|
||||
|
||||
/**
|
||||
* Attempt to log in with username and password
|
||||
* @param username The username
|
||||
* @param password The plaintext password (max 40 characters)
|
||||
* @param rememberMe If true, extends token expiration to 30 days
|
||||
* @return LoginResult with success status and JWT token if successful
|
||||
*/
|
||||
static LoginResult Login(const std::string& username, const std::string& password, bool rememberMe = false);
|
||||
|
||||
/**
|
||||
* Verify that a token is valid and get the username
|
||||
* @param token The JWT token
|
||||
* @param username Output parameter for the username
|
||||
* @param gmLevel Output parameter for the GM level
|
||||
* @return true if token is valid
|
||||
*/
|
||||
static bool VerifyToken(const std::string& token, std::string& username, uint8_t& gmLevel);
|
||||
|
||||
/**
|
||||
* Check if user has required GM level for dashboard access
|
||||
* @param gmLevel The user's GM level
|
||||
* @return true if user can access dashboard (GM level > 0)
|
||||
*/
|
||||
static bool HasDashboardAccess(uint8_t gmLevel);
|
||||
};
|
||||
186
dDashboardServer/auth/JWTUtils.cpp
Normal file
186
dDashboardServer/auth/JWTUtils.cpp
Normal file
@@ -0,0 +1,186 @@
|
||||
#include "JWTUtils.h"
|
||||
#include "GeneralUtils.h"
|
||||
#include "Logger.h"
|
||||
#include "json.hpp"
|
||||
#include <ctime>
|
||||
#include <cstring>
|
||||
#include <openssl/hmac.h>
|
||||
#include <openssl/sha.h>
|
||||
|
||||
namespace {
|
||||
std::string g_Secret = "default-secret-change-me";
|
||||
|
||||
// Simple base64 encoding
|
||||
std::string Base64Encode(const std::string& input) {
|
||||
static const char* base64_chars =
|
||||
"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
|
||||
std::string ret;
|
||||
int i = 0;
|
||||
unsigned char char_array_3[3];
|
||||
unsigned char char_array_4[4];
|
||||
|
||||
for (size_t n = 0; n < input.length(); n++) {
|
||||
char_array_3[i++] = input[n];
|
||||
if (i == 3) {
|
||||
char_array_4[0] = (char_array_3[0] & 0xfc) >> 2;
|
||||
char_array_4[1] = ((char_array_3[0] & 0x03) << 4) + ((char_array_3[1] & 0xf0) >> 4);
|
||||
char_array_4[2] = ((char_array_3[1] & 0x0f) << 2) + ((char_array_3[2] & 0xc0) >> 6);
|
||||
char_array_4[3] = char_array_3[2] & 0x3f;
|
||||
for (i = 0; i < 4; i++) ret += base64_chars[char_array_4[i]];
|
||||
i = 0;
|
||||
}
|
||||
}
|
||||
|
||||
if (i) {
|
||||
for (int j = i; j < 3; j++) char_array_3[j] = '\0';
|
||||
char_array_4[0] = (char_array_3[0] & 0xfc) >> 2;
|
||||
char_array_4[1] = ((char_array_3[0] & 0x03) << 4) + ((char_array_3[1] & 0xf0) >> 4);
|
||||
char_array_4[2] = ((char_array_3[1] & 0x0f) << 2) + ((char_array_3[2] & 0xc0) >> 6);
|
||||
for (int j = 0; j <= i; j++) ret += base64_chars[char_array_4[j]];
|
||||
while (i++ < 3) ret += '=';
|
||||
}
|
||||
|
||||
return ret;
|
||||
}
|
||||
|
||||
// Simple base64 decoding
|
||||
std::string Base64Decode(const std::string& encoded_string) {
|
||||
static const std::string base64_chars =
|
||||
"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
|
||||
int in_len = encoded_string.size();
|
||||
int i = 0, j = 0, in_ = 0;
|
||||
unsigned char char_array_4[4], char_array_3[3];
|
||||
std::string ret;
|
||||
|
||||
while (in_len-- && (encoded_string[in_] != '=') &&
|
||||
(isalnum(encoded_string[in_]) || encoded_string[in_] == '+' || encoded_string[in_] == '/')) {
|
||||
char_array_4[i++] = encoded_string[in_]; in_++;
|
||||
if (i == 4) {
|
||||
for (i = 0; i < 4; i++) char_array_4[i] = base64_chars.find(char_array_4[i]);
|
||||
char_array_3[0] = (char_array_4[0] << 2) + ((char_array_4[1] & 0x30) >> 4);
|
||||
char_array_3[1] = ((char_array_4[1] & 0xf) << 4) + ((char_array_4[2] & 0x3c) >> 2);
|
||||
char_array_3[2] = ((char_array_4[2] & 0x3) << 6) + char_array_4[3];
|
||||
for (i = 0; i < 3; i++) ret += char_array_3[i];
|
||||
i = 0;
|
||||
}
|
||||
}
|
||||
|
||||
if (i) {
|
||||
for (j = i; j < 4; j++) char_array_4[j] = 0;
|
||||
for (j = 0; j < 4; j++) char_array_4[j] = base64_chars.find(char_array_4[j]);
|
||||
char_array_3[0] = (char_array_4[0] << 2) + ((char_array_4[1] & 0x30) >> 4);
|
||||
char_array_3[1] = ((char_array_4[1] & 0xf) << 4) + ((char_array_4[2] & 0x3c) >> 2);
|
||||
for (j = 0; j < i - 1; j++) ret += char_array_3[j];
|
||||
}
|
||||
|
||||
return ret;
|
||||
}
|
||||
|
||||
// HMAC-SHA256
|
||||
std::string HmacSha256(const std::string& key, const std::string& message) {
|
||||
unsigned char* digest = HMAC(EVP_sha256(),
|
||||
reinterpret_cast<const unsigned char*>(key.c_str()), key.length(),
|
||||
reinterpret_cast<const unsigned char*>(message.c_str()), message.length(),
|
||||
nullptr, nullptr);
|
||||
|
||||
std::string result(reinterpret_cast<char*>(digest), SHA256_DIGEST_LENGTH);
|
||||
return result;
|
||||
}
|
||||
|
||||
// Create signature for JWT
|
||||
std::string CreateSignature(const std::string& header, const std::string& payload, const std::string& secret) {
|
||||
std::string message = header + "." + payload;
|
||||
std::string signature = HmacSha256(secret, message);
|
||||
return Base64Encode(signature);
|
||||
}
|
||||
|
||||
// Verify JWT signature
|
||||
bool VerifySignature(const std::string& header, const std::string& payload, const std::string& signature, const std::string& secret) {
|
||||
std::string expected = CreateSignature(header, payload, secret);
|
||||
return signature == expected;
|
||||
}
|
||||
}
|
||||
|
||||
namespace JWTUtils {
|
||||
void SetSecretKey(const std::string& secret) {
|
||||
if (secret.empty()) {
|
||||
LOG("Warning: JWT secret key is empty, using default");
|
||||
return;
|
||||
}
|
||||
g_Secret = secret;
|
||||
}
|
||||
|
||||
std::string GenerateToken(const std::string& username, uint8_t gmLevel, bool rememberMe) {
|
||||
// Header
|
||||
std::string header = R"({"alg":"HS256","typ":"JWT"})";
|
||||
std::string encodedHeader = Base64Encode(header);
|
||||
|
||||
// Payload
|
||||
int64_t now = std::time(nullptr);
|
||||
int64_t expiresAt = now + (rememberMe ? 30 * 24 * 60 * 60 : 24 * 60 * 60); // 30 days or 24 hours
|
||||
|
||||
std::string payload = R"({"username":")" + username + R"(","gmLevel":)" + std::to_string(gmLevel) +
|
||||
R"(,"rememberMe":)" + (rememberMe ? "true" : "false") +
|
||||
R"(,"iat":)" + std::to_string(now) +
|
||||
R"(,"exp":)" + std::to_string(expiresAt) + "}";
|
||||
std::string encodedPayload = Base64Encode(payload);
|
||||
|
||||
// Signature
|
||||
std::string signature = CreateSignature(encodedHeader, encodedPayload, g_Secret);
|
||||
|
||||
return encodedHeader + "." + encodedPayload + "." + signature;
|
||||
}
|
||||
|
||||
bool ValidateToken(const std::string& token, JWTPayload& payload) {
|
||||
// Split token into parts
|
||||
size_t firstDot = token.find('.');
|
||||
size_t secondDot = token.find('.', firstDot + 1);
|
||||
|
||||
if (firstDot == std::string::npos || secondDot == std::string::npos) {
|
||||
LOG_DEBUG("Invalid JWT format");
|
||||
return false;
|
||||
}
|
||||
|
||||
std::string header = token.substr(0, firstDot);
|
||||
std::string encodedPayload = token.substr(firstDot + 1, secondDot - firstDot - 1);
|
||||
std::string signature = token.substr(secondDot + 1);
|
||||
|
||||
// Verify signature
|
||||
if (!VerifySignature(header, encodedPayload, signature, g_Secret)) {
|
||||
LOG_DEBUG("Invalid JWT signature");
|
||||
return false;
|
||||
}
|
||||
|
||||
// Decode and parse payload
|
||||
std::string decodedPayload = Base64Decode(encodedPayload);
|
||||
try {
|
||||
auto json = nlohmann::json::parse(decodedPayload);
|
||||
|
||||
payload.username = json.value("username", "");
|
||||
payload.gmLevel = json.value("gmLevel", 0);
|
||||
payload.rememberMe = json.value("rememberMe", false);
|
||||
payload.issuedAt = json.value("iat", 0);
|
||||
payload.expiresAt = json.value("exp", 0);
|
||||
|
||||
if (payload.username.empty()) {
|
||||
LOG_DEBUG("JWT missing username");
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check expiration
|
||||
if (IsTokenExpired(payload.expiresAt)) {
|
||||
LOG_DEBUG("JWT token expired");
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
} catch (const std::exception& ex) {
|
||||
LOG_DEBUG("Error parsing JWT payload: %s", ex.what());
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
bool IsTokenExpired(int64_t expiresAt) {
|
||||
return std::time(nullptr) > expiresAt;
|
||||
}
|
||||
}
|
||||
52
dDashboardServer/auth/JWTUtils.h
Normal file
52
dDashboardServer/auth/JWTUtils.h
Normal file
@@ -0,0 +1,52 @@
|
||||
#pragma once
|
||||
|
||||
#include <string>
|
||||
#include <ctime>
|
||||
#include "json_fwd.hpp"
|
||||
|
||||
/**
|
||||
* JWT Token utilities for dashboard authentication
|
||||
* Provides secure token generation, validation, and parsing
|
||||
*/
|
||||
namespace JWTUtils {
|
||||
/**
|
||||
* JWT payload structure
|
||||
*/
|
||||
struct JWTPayload {
|
||||
std::string username{};
|
||||
uint8_t gmLevel{0};
|
||||
bool rememberMe{false};
|
||||
int64_t issuedAt{0};
|
||||
int64_t expiresAt{0};
|
||||
};
|
||||
|
||||
/**
|
||||
* Generate a new JWT token
|
||||
* @param username The username to encode in the token
|
||||
* @param gmLevel The GM level of the user
|
||||
* @param rememberMe If true, extends token expiration to 30 days; otherwise 24 hours
|
||||
* @return Signed JWT token string
|
||||
*/
|
||||
std::string GenerateToken(const std::string& username, uint8_t gmLevel, bool rememberMe = false);
|
||||
|
||||
/**
|
||||
* Validate and decode a JWT token
|
||||
* @param token The JWT token to validate
|
||||
* @param payload Output parameter for the decoded payload
|
||||
* @return true if token is valid and not expired, false otherwise
|
||||
*/
|
||||
bool ValidateToken(const std::string& token, JWTPayload& payload);
|
||||
|
||||
/**
|
||||
* Check if a token is expired
|
||||
* @param expiresAt Expiration timestamp
|
||||
* @return true if token is expired
|
||||
*/
|
||||
bool IsTokenExpired(int64_t expiresAt);
|
||||
|
||||
/**
|
||||
* Set the JWT secret key (must be called once at startup)
|
||||
* @param secret The secret key for signing tokens
|
||||
*/
|
||||
void SetSecretKey(const std::string& secret);
|
||||
}
|
||||
35
dDashboardServer/auth/RequireAuthMiddleware.cpp
Normal file
35
dDashboardServer/auth/RequireAuthMiddleware.cpp
Normal file
@@ -0,0 +1,35 @@
|
||||
#include "RequireAuthMiddleware.h"
|
||||
#include "HTTPContext.h"
|
||||
#include "Web.h"
|
||||
#include "Game.h"
|
||||
#include "Logger.h"
|
||||
|
||||
RequireAuthMiddleware::RequireAuthMiddleware(uint8_t minGmLevel) : minGmLevel(minGmLevel) {}
|
||||
|
||||
bool RequireAuthMiddleware::Process(HTTPContext& context, HTTPReply& reply) {
|
||||
// Check if user is authenticated
|
||||
if (!context.isAuthenticated) {
|
||||
LOG_DEBUG("Unauthorized access attempt to %s from %s", context.path.c_str(), context.clientIP.c_str());
|
||||
reply.status = eHTTPStatusCode::FOUND;
|
||||
reply.message = "";
|
||||
reply.location = "/login";
|
||||
reply.contentType = eContentType::TEXT_HTML;
|
||||
return false; // Stop middleware chain and send reply
|
||||
}
|
||||
|
||||
// Check if user has required GM level
|
||||
if (context.gmLevel < minGmLevel) {
|
||||
LOG_DEBUG("Forbidden access attempt by user %s (GM level %d < %d required) to %s from %s",
|
||||
context.authenticatedUser.c_str(), context.gmLevel, minGmLevel,
|
||||
context.path.c_str(), context.clientIP.c_str());
|
||||
reply.status = eHTTPStatusCode::FORBIDDEN;
|
||||
reply.message = "{\"error\":\"Forbidden - Insufficient permissions\"}";
|
||||
reply.contentType = eContentType::APPLICATION_JSON;
|
||||
return false; // Stop middleware chain and send reply
|
||||
}
|
||||
|
||||
// Authentication passed
|
||||
LOG_DEBUG("User %s authenticated with GM level %d accessing %s",
|
||||
context.authenticatedUser.c_str(), context.gmLevel, context.path.c_str());
|
||||
return true; // Continue to next middleware or route handler
|
||||
}
|
||||
30
dDashboardServer/auth/RequireAuthMiddleware.h
Normal file
30
dDashboardServer/auth/RequireAuthMiddleware.h
Normal file
@@ -0,0 +1,30 @@
|
||||
#ifndef __REQUIREAUTHMIDDLEWARE_H__
|
||||
#define __REQUIREAUTHMIDDLEWARE_H__
|
||||
|
||||
#include <memory>
|
||||
#include <cstdint>
|
||||
#include "IHTTPMiddleware.h"
|
||||
|
||||
/**
|
||||
* RequireAuthMiddleware: Enforces authentication on protected routes
|
||||
*
|
||||
* Returns 401 Unauthorized if user is not authenticated
|
||||
* Returns 403 Forbidden if user's GM level is below minimum required
|
||||
*/
|
||||
class RequireAuthMiddleware final : public IHTTPMiddleware {
|
||||
public:
|
||||
/**
|
||||
* @param minGmLevel Minimum GM level required to access this route
|
||||
* 0 = any authenticated user, higher numbers = GM-only
|
||||
*/
|
||||
explicit RequireAuthMiddleware(uint8_t minGmLevel = 0);
|
||||
~RequireAuthMiddleware() override = default;
|
||||
|
||||
bool Process(HTTPContext& context, HTTPReply& reply) override;
|
||||
std::string GetName() const override { return "RequireAuthMiddleware"; }
|
||||
|
||||
private:
|
||||
uint8_t minGmLevel;
|
||||
};
|
||||
|
||||
#endif // !__REQUIREAUTHMIDDLEWARE_H__
|
||||
101
dDashboardServer/routes/APIRoutes.cpp
Normal file
101
dDashboardServer/routes/APIRoutes.cpp
Normal file
@@ -0,0 +1,101 @@
|
||||
#include "APIRoutes.h"
|
||||
#include "ServerState.h"
|
||||
#include "Web.h"
|
||||
#include "eHTTPMethod.h"
|
||||
#include "json.hpp"
|
||||
#include "Game.h"
|
||||
#include "Database.h"
|
||||
#include "Logger.h"
|
||||
#include "HTTPContext.h"
|
||||
#include "RequireAuthMiddleware.h"
|
||||
#include <memory>
|
||||
|
||||
void RegisterAPIRoutes() {
|
||||
// GET /api/status - Get overall server status
|
||||
Game::web.RegisterHTTPRoute({
|
||||
.path = "/api/status",
|
||||
.method = eHTTPMethod::GET,
|
||||
.middleware = { std::make_shared<RequireAuthMiddleware>(0) },
|
||||
.handle = [](HTTPReply& reply, const HTTPContext& context) {
|
||||
std::lock_guard<std::mutex> lock(ServerState::g_StatusMutex);
|
||||
|
||||
nlohmann::json response = {
|
||||
{"auth", {
|
||||
{"online", ServerState::g_AuthStatus.online},
|
||||
{"players", ServerState::g_AuthStatus.players},
|
||||
{"version", ServerState::g_AuthStatus.version}
|
||||
}},
|
||||
{"chat", {
|
||||
{"online", ServerState::g_ChatStatus.online},
|
||||
{"players", ServerState::g_ChatStatus.players}
|
||||
}},
|
||||
{"worlds", nlohmann::json::array()}
|
||||
};
|
||||
|
||||
for (const auto& world : ServerState::g_WorldInstances) {
|
||||
response["worlds"].push_back({
|
||||
{"mapID", world.mapID},
|
||||
{"instanceID", world.instanceID},
|
||||
{"cloneID", world.cloneID},
|
||||
{"players", world.players},
|
||||
{"isPrivate", world.isPrivate}
|
||||
});
|
||||
}
|
||||
|
||||
reply.status = eHTTPStatusCode::OK;
|
||||
reply.message = response.dump();
|
||||
reply.contentType = eContentType::APPLICATION_JSON;
|
||||
}
|
||||
});
|
||||
|
||||
// GET /api/players - Get list of online players
|
||||
Game::web.RegisterHTTPRoute({
|
||||
.path = "/api/players",
|
||||
.method = eHTTPMethod::GET,
|
||||
.middleware = { std::make_shared<RequireAuthMiddleware>(0) },
|
||||
.handle = [](HTTPReply& reply, const HTTPContext& context) {
|
||||
nlohmann::json response = {
|
||||
{"players", nlohmann::json::array()},
|
||||
{"count", 0}
|
||||
};
|
||||
|
||||
reply.status = eHTTPStatusCode::OK;
|
||||
reply.message = response.dump();
|
||||
reply.contentType = eContentType::APPLICATION_JSON;
|
||||
}
|
||||
});
|
||||
|
||||
// GET /api/accounts/count - Get total account count
|
||||
Game::web.RegisterHTTPRoute({
|
||||
.path = "/api/accounts/count",
|
||||
.method = eHTTPMethod::GET,
|
||||
.middleware = { std::make_shared<RequireAuthMiddleware>(0) },
|
||||
.handle = [](HTTPReply& reply, const HTTPContext& context) {
|
||||
try {
|
||||
const uint32_t count = Database::Get()->GetAccountCount();
|
||||
nlohmann::json response = {{"count", count}};
|
||||
reply.status = eHTTPStatusCode::OK;
|
||||
reply.message = response.dump();
|
||||
reply.contentType = eContentType::APPLICATION_JSON;
|
||||
} catch (std::exception& ex) {
|
||||
LOG("Error in /api/accounts/count: %s", ex.what());
|
||||
reply.status = eHTTPStatusCode::INTERNAL_SERVER_ERROR;
|
||||
reply.message = "{\"error\":\"Database error\"}";
|
||||
reply.contentType = eContentType::APPLICATION_JSON;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// GET /api/characters/count - Get total character count
|
||||
Game::web.RegisterHTTPRoute({
|
||||
.path = "/api/characters/count",
|
||||
.method = eHTTPMethod::GET,
|
||||
.middleware = { std::make_shared<RequireAuthMiddleware>(0) },
|
||||
.handle = [](HTTPReply& reply, const HTTPContext& context) {
|
||||
nlohmann::json response = {{"count", 0}, {"note", "Not yet implemented"}};
|
||||
reply.status = eHTTPStatusCode::OK;
|
||||
reply.message = response.dump();
|
||||
reply.contentType = eContentType::APPLICATION_JSON;
|
||||
}
|
||||
});
|
||||
}
|
||||
3
dDashboardServer/routes/APIRoutes.h
Normal file
3
dDashboardServer/routes/APIRoutes.h
Normal file
@@ -0,0 +1,3 @@
|
||||
#pragma once
|
||||
|
||||
void RegisterAPIRoutes();
|
||||
102
dDashboardServer/routes/AuthRoutes.cpp
Normal file
102
dDashboardServer/routes/AuthRoutes.cpp
Normal file
@@ -0,0 +1,102 @@
|
||||
#include "AuthRoutes.h"
|
||||
#include "DashboardAuthService.h"
|
||||
#include "json.hpp"
|
||||
#include "Logger.h"
|
||||
#include "GeneralUtils.h"
|
||||
#include "Web.h"
|
||||
#include "eHTTPMethod.h"
|
||||
#include "HTTPContext.h"
|
||||
|
||||
void RegisterAuthRoutes() {
|
||||
// POST /api/auth/login
|
||||
// Request body: { "username": "string", "password": "string", "rememberMe": boolean }
|
||||
// Response: { "success": boolean, "message": "string", "token": "string", "gmLevel": number }
|
||||
Game::web.RegisterHTTPRoute({
|
||||
.path = "/api/auth/login",
|
||||
.method = eHTTPMethod::POST,
|
||||
.middleware = {},
|
||||
.handle = [](HTTPReply& reply, const HTTPContext& context) {
|
||||
try {
|
||||
auto json = nlohmann::json::parse(context.body);
|
||||
std::string username = json.value("username", "");
|
||||
std::string password = json.value("password", "");
|
||||
bool rememberMe = json.value("rememberMe", false);
|
||||
|
||||
// Validate input
|
||||
if (username.empty() || password.empty()) {
|
||||
reply.message = R"({"success":false,"message":"Username and password are required"})";
|
||||
reply.status = eHTTPStatusCode::BAD_REQUEST;
|
||||
return;
|
||||
}
|
||||
|
||||
if (password.length() > 40) {
|
||||
reply.message = R"({"success":false,"message":"Password exceeds maximum length"})";
|
||||
reply.status = eHTTPStatusCode::BAD_REQUEST;
|
||||
return;
|
||||
}
|
||||
|
||||
// Attempt login
|
||||
auto result = DashboardAuthService::Login(username, password, rememberMe);
|
||||
|
||||
nlohmann::json response;
|
||||
response["success"] = result.success;
|
||||
response["message"] = result.message;
|
||||
if (result.success) {
|
||||
response["token"] = result.token;
|
||||
response["gmLevel"] = result.gmLevel;
|
||||
}
|
||||
|
||||
reply.message = response.dump();
|
||||
reply.status = result.success ? eHTTPStatusCode::OK : eHTTPStatusCode::UNAUTHORIZED;
|
||||
reply.contentType = eContentType::APPLICATION_JSON;
|
||||
} catch (const std::exception& ex) {
|
||||
LOG("Error processing login request: %s", ex.what());
|
||||
reply.message = R"({"success":false,"message":"Internal server error"})";
|
||||
reply.status = eHTTPStatusCode::INTERNAL_SERVER_ERROR;
|
||||
reply.contentType = eContentType::APPLICATION_JSON;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// POST /api/auth/verify
|
||||
// Request body: { "token": "string" }
|
||||
// Response: { "valid": boolean, "username": "string", "gmLevel": number }
|
||||
Game::web.RegisterHTTPRoute({
|
||||
.path = "/api/auth/verify",
|
||||
.method = eHTTPMethod::POST,
|
||||
.middleware = {},
|
||||
.handle = [](HTTPReply& reply, const HTTPContext& context) {
|
||||
try {
|
||||
auto json = nlohmann::json::parse(context.body);
|
||||
std::string token = json.value("token", "");
|
||||
|
||||
if (token.empty()) {
|
||||
reply.message = R"({"valid":false})";
|
||||
reply.status = eHTTPStatusCode::BAD_REQUEST;
|
||||
reply.contentType = eContentType::APPLICATION_JSON;
|
||||
return;
|
||||
}
|
||||
|
||||
std::string username;
|
||||
uint8_t gmLevel{};
|
||||
bool valid = DashboardAuthService::VerifyToken(token, username, gmLevel);
|
||||
|
||||
nlohmann::json response;
|
||||
response["valid"] = valid;
|
||||
if (valid) {
|
||||
response["username"] = username;
|
||||
response["gmLevel"] = gmLevel;
|
||||
}
|
||||
|
||||
reply.message = response.dump();
|
||||
reply.status = eHTTPStatusCode::OK;
|
||||
reply.contentType = eContentType::APPLICATION_JSON;
|
||||
} catch (const std::exception& ex) {
|
||||
LOG("Error processing verify request: %s", ex.what());
|
||||
reply.message = R"({"valid":false})";
|
||||
reply.status = eHTTPStatusCode::INTERNAL_SERVER_ERROR;
|
||||
reply.contentType = eContentType::APPLICATION_JSON;
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
10
dDashboardServer/routes/AuthRoutes.h
Normal file
10
dDashboardServer/routes/AuthRoutes.h
Normal file
@@ -0,0 +1,10 @@
|
||||
#pragma once
|
||||
|
||||
#include "Web.h"
|
||||
|
||||
/**
|
||||
* Register authentication routes
|
||||
* /api/auth/login - POST login endpoint
|
||||
* /api/auth/verify - POST verify token endpoint
|
||||
*/
|
||||
void RegisterAuthRoutes();
|
||||
101
dDashboardServer/routes/DashboardRoutes.cpp
Normal file
101
dDashboardServer/routes/DashboardRoutes.cpp
Normal file
@@ -0,0 +1,101 @@
|
||||
#include "DashboardRoutes.h"
|
||||
#include "ServerState.h"
|
||||
#include "Web.h"
|
||||
#include "HTTPContext.h"
|
||||
#include "eHTTPMethod.h"
|
||||
#include "json.hpp"
|
||||
#include "Game.h"
|
||||
#include "Database.h"
|
||||
#include "Logger.h"
|
||||
#include "inja.hpp"
|
||||
#include "AuthMiddleware.h"
|
||||
#include "RequireAuthMiddleware.h"
|
||||
|
||||
void RegisterDashboardRoutes() {
|
||||
// GET / - Main dashboard page (requires authentication)
|
||||
Game::web.RegisterHTTPRoute({
|
||||
.path = "/",
|
||||
.method = eHTTPMethod::GET,
|
||||
.middleware = { std::make_shared<RequireAuthMiddleware>(0) },
|
||||
.handle = [](HTTPReply& reply, const HTTPContext& context) {
|
||||
try {
|
||||
// Initialize inja environment
|
||||
inja::Environment env{"dDashboardServer/templates/"};
|
||||
env.set_trim_blocks(true);
|
||||
env.set_lstrip_blocks(true);
|
||||
|
||||
// Prepare data for template
|
||||
nlohmann::json data;
|
||||
// Get username from auth context
|
||||
data["username"] = context.authenticatedUser;
|
||||
data["gmLevel"] = context.gmLevel;
|
||||
|
||||
// Server status (placeholder data - will be updated with real data from master)
|
||||
data["auth"]["online"] = ServerState::g_AuthStatus.online;
|
||||
data["auth"]["players"] = ServerState::g_AuthStatus.players;
|
||||
data["chat"]["online"] = ServerState::g_ChatStatus.online;
|
||||
data["chat"]["players"] = ServerState::g_ChatStatus.players;
|
||||
|
||||
// World instances
|
||||
std::lock_guard<std::mutex> lock(ServerState::g_StatusMutex);
|
||||
data["worlds"] = nlohmann::json::array();
|
||||
for (const auto& world : ServerState::g_WorldInstances) {
|
||||
data["worlds"].push_back({
|
||||
{"mapID", world.mapID},
|
||||
{"instanceID", world.instanceID},
|
||||
{"cloneID", world.cloneID},
|
||||
{"players", world.players},
|
||||
{"isPrivate", world.isPrivate}
|
||||
});
|
||||
}
|
||||
|
||||
// Statistics
|
||||
const uint32_t accountCount = Database::Get()->GetAccountCount();
|
||||
data["stats"]["onlinePlayers"] = 0; // TODO: Get from server communication
|
||||
data["stats"]["totalAccounts"] = accountCount;
|
||||
data["stats"]["totalCharacters"] = 0; // TODO: Add GetCharacterCount to database interface
|
||||
|
||||
// Render template
|
||||
const std::string html = env.render_file("index.jinja2", data);
|
||||
|
||||
reply.status = eHTTPStatusCode::OK;
|
||||
reply.message = html;
|
||||
reply.contentType = eContentType::TEXT_HTML;
|
||||
} catch (const std::exception& ex) {
|
||||
LOG("Error rendering template: %s", ex.what());
|
||||
reply.status = eHTTPStatusCode::INTERNAL_SERVER_ERROR;
|
||||
reply.message = "{\"error\":\"Failed to render template\"}";
|
||||
reply.contentType = eContentType::APPLICATION_JSON;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// GET /login - Login page (no authentication required)
|
||||
Game::web.RegisterHTTPRoute({
|
||||
.path = "/login",
|
||||
.method = eHTTPMethod::GET,
|
||||
.middleware = {},
|
||||
.handle = [](HTTPReply& reply, const HTTPContext& context) {
|
||||
try {
|
||||
// Initialize inja environment
|
||||
inja::Environment env{"dDashboardServer/templates/"};
|
||||
env.set_trim_blocks(true);
|
||||
env.set_lstrip_blocks(true);
|
||||
|
||||
// Render template with empty username
|
||||
nlohmann::json data;
|
||||
data["username"] = "";
|
||||
const std::string html = env.render_file("login.jinja2", data);
|
||||
|
||||
reply.status = eHTTPStatusCode::OK;
|
||||
reply.message = html;
|
||||
reply.contentType = eContentType::TEXT_HTML;
|
||||
} catch (const std::exception& ex) {
|
||||
LOG("Error rendering login template: %s", ex.what());
|
||||
reply.status = eHTTPStatusCode::INTERNAL_SERVER_ERROR;
|
||||
reply.message = "{\"error\":\"Failed to render login page\"}";
|
||||
reply.contentType = eContentType::APPLICATION_JSON;
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
3
dDashboardServer/routes/DashboardRoutes.h
Normal file
3
dDashboardServer/routes/DashboardRoutes.h
Normal file
@@ -0,0 +1,3 @@
|
||||
#pragma once
|
||||
|
||||
void RegisterDashboardRoutes();
|
||||
31
dDashboardServer/routes/ServerState.h
Normal file
31
dDashboardServer/routes/ServerState.h
Normal file
@@ -0,0 +1,31 @@
|
||||
#pragma once
|
||||
|
||||
#include <chrono>
|
||||
#include <mutex>
|
||||
#include <vector>
|
||||
#include <string>
|
||||
#include <cstdint>
|
||||
|
||||
struct ServerStatus {
|
||||
bool online{false};
|
||||
uint32_t players{0};
|
||||
std::string version{};
|
||||
std::chrono::steady_clock::time_point lastSeen{};
|
||||
};
|
||||
|
||||
struct WorldInstanceInfo {
|
||||
uint32_t mapID{0};
|
||||
uint32_t instanceID{0};
|
||||
uint32_t cloneID{0};
|
||||
uint32_t players{0};
|
||||
std::string ip{};
|
||||
uint32_t port{0};
|
||||
bool isPrivate{false};
|
||||
};
|
||||
|
||||
namespace ServerState {
|
||||
extern ServerStatus g_AuthStatus;
|
||||
extern ServerStatus g_ChatStatus;
|
||||
extern std::vector<WorldInstanceInfo> g_WorldInstances;
|
||||
extern std::mutex g_StatusMutex;
|
||||
}
|
||||
72
dDashboardServer/routes/StaticRoutes.cpp
Normal file
72
dDashboardServer/routes/StaticRoutes.cpp
Normal file
@@ -0,0 +1,72 @@
|
||||
#include "StaticRoutes.h"
|
||||
#include "Web.h"
|
||||
#include "HTTPContext.h"
|
||||
#include "eHTTPMethod.h"
|
||||
#include "Game.h"
|
||||
#include "Logger.h"
|
||||
#include <fstream>
|
||||
#include <sstream>
|
||||
|
||||
namespace {
|
||||
std::string ReadFileToString(const std::string& filePath) {
|
||||
std::ifstream file(filePath);
|
||||
if (!file.is_open()) {
|
||||
LOG("Failed to open file: %s", filePath.c_str());
|
||||
return "";
|
||||
}
|
||||
std::stringstream buffer{};
|
||||
buffer << file.rdbuf();
|
||||
return buffer.str();
|
||||
}
|
||||
|
||||
eContentType GetContentType(const std::string& filePath) {
|
||||
if (filePath.ends_with(".css")) {
|
||||
return eContentType::TEXT_CSS;
|
||||
} else if (filePath.ends_with(".js")) {
|
||||
return eContentType::TEXT_JAVASCRIPT;
|
||||
} else if (filePath.ends_with(".html")) {
|
||||
return eContentType::TEXT_HTML;
|
||||
} else if (filePath.ends_with(".png")) {
|
||||
return eContentType::IMAGE_PNG;
|
||||
} else if (filePath.ends_with(".jpg") || filePath.ends_with(".jpeg")) {
|
||||
return eContentType::IMAGE_JPEG;
|
||||
} else if (filePath.ends_with(".json")) {
|
||||
return eContentType::APPLICATION_JSON;
|
||||
}
|
||||
return eContentType::TEXT_PLAIN;
|
||||
}
|
||||
|
||||
void ServeStaticFile(const std::string& urlPath, const std::string& filePath) {
|
||||
Game::web.RegisterHTTPRoute({
|
||||
.path = urlPath,
|
||||
.method = eHTTPMethod::GET,
|
||||
.middleware = {},
|
||||
.handle = [filePath](HTTPReply& reply, const HTTPContext& context) {
|
||||
const std::string content = ReadFileToString(filePath);
|
||||
if (content.empty()) {
|
||||
reply.status = eHTTPStatusCode::NOT_FOUND;
|
||||
reply.message = "{\"error\":\"File not found\"}";
|
||||
reply.contentType = eContentType::APPLICATION_JSON;
|
||||
} else {
|
||||
reply.status = eHTTPStatusCode::OK;
|
||||
reply.message = content;
|
||||
reply.contentType = GetContentType(filePath);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
void RegisterStaticRoutes() {
|
||||
// Serve CSS files
|
||||
ServeStaticFile("/css/dashboard.css", "dDashboardServer/static/css/dashboard.css");
|
||||
ServeStaticFile("/css/login.css", "dDashboardServer/static/css/login.css");
|
||||
|
||||
// Serve JavaScript files
|
||||
ServeStaticFile("/js/dashboard.js", "dDashboardServer/static/js/dashboard.js");
|
||||
ServeStaticFile("/js/login.js", "dDashboardServer/static/js/login.js");
|
||||
|
||||
// Also serve from /static/ paths for backwards compatibility
|
||||
ServeStaticFile("/static/css/dashboard.css", "dDashboardServer/static/css/dashboard.css");
|
||||
ServeStaticFile("/static/js/dashboard.js", "dDashboardServer/static/js/dashboard.js");
|
||||
}
|
||||
3
dDashboardServer/routes/StaticRoutes.h
Normal file
3
dDashboardServer/routes/StaticRoutes.h
Normal file
@@ -0,0 +1,3 @@
|
||||
#pragma once
|
||||
|
||||
void RegisterStaticRoutes();
|
||||
58
dDashboardServer/routes/WSRoutes.cpp
Normal file
58
dDashboardServer/routes/WSRoutes.cpp
Normal file
@@ -0,0 +1,58 @@
|
||||
#include "WSRoutes.h"
|
||||
#include "ServerState.h"
|
||||
#include "Web.h"
|
||||
#include "json.hpp"
|
||||
#include "Game.h"
|
||||
#include "Database.h"
|
||||
#include "Logger.h"
|
||||
|
||||
void RegisterWSRoutes() {
|
||||
// Register WebSocket subscriptions for real-time updates
|
||||
Game::web.RegisterWSSubscription("dashboard_update");
|
||||
Game::web.RegisterWSSubscription("server_status");
|
||||
Game::web.RegisterWSSubscription("player_joined");
|
||||
Game::web.RegisterWSSubscription("player_left");
|
||||
|
||||
// dashboard_update: Broadcasts complete dashboard data every 2 seconds
|
||||
// Other subscriptions can be triggered by events from the master server
|
||||
}
|
||||
|
||||
void BroadcastDashboardUpdate() {
|
||||
std::lock_guard<std::mutex> lock(ServerState::g_StatusMutex);
|
||||
|
||||
nlohmann::json data = {
|
||||
{"auth", {
|
||||
{"online", ServerState::g_AuthStatus.online},
|
||||
{"players", ServerState::g_AuthStatus.players},
|
||||
{"version", ServerState::g_AuthStatus.version}
|
||||
}},
|
||||
{"chat", {
|
||||
{"online", ServerState::g_ChatStatus.online},
|
||||
{"players", ServerState::g_ChatStatus.players}
|
||||
}},
|
||||
{"worlds", nlohmann::json::array()}
|
||||
};
|
||||
|
||||
for (const auto& world : ServerState::g_WorldInstances) {
|
||||
data["worlds"].push_back({
|
||||
{"mapID", world.mapID},
|
||||
{"instanceID", world.instanceID},
|
||||
{"cloneID", world.cloneID},
|
||||
{"players", world.players},
|
||||
{"isPrivate", world.isPrivate}
|
||||
});
|
||||
}
|
||||
|
||||
// Add statistics
|
||||
try {
|
||||
const uint32_t accountCount = Database::Get()->GetAccountCount();
|
||||
data["stats"]["onlinePlayers"] = 0; // TODO: Get from server communication
|
||||
data["stats"]["totalAccounts"] = accountCount;
|
||||
data["stats"]["totalCharacters"] = 0; // TODO: Add GetCharacterCount to database interface
|
||||
} catch (const std::exception& ex) {
|
||||
LOG_DEBUG("Error getting stats: %s", ex.what());
|
||||
}
|
||||
|
||||
// Broadcast to all connected WebSocket clients subscribed to "dashboard_update"
|
||||
Game::web.SendWSMessage("dashboard_update", data);
|
||||
}
|
||||
4
dDashboardServer/routes/WSRoutes.h
Normal file
4
dDashboardServer/routes/WSRoutes.h
Normal file
@@ -0,0 +1,4 @@
|
||||
#pragma once
|
||||
|
||||
void RegisterWSRoutes();
|
||||
void BroadcastDashboardUpdate();
|
||||
177
dDashboardServer/static/css/dashboard.css
Normal file
177
dDashboardServer/static/css/dashboard.css
Normal file
@@ -0,0 +1,177 @@
|
||||
/* Minimal custom styling - mostly Bootstrap5 utilities */
|
||||
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif;
|
||||
background-color: #f8f9fa;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
/* Sidebar adjustments */
|
||||
.navbar.flex-column {
|
||||
box-shadow: 0.125rem 0 0.25rem rgba(0, 0, 0, 0.075);
|
||||
}
|
||||
|
||||
.navbar.flex-column .navbar-nav {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.navbar.flex-column .nav-link {
|
||||
padding: 0.75rem 1.25rem;
|
||||
border-left: 3px solid transparent;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.navbar.flex-column .nav-link:hover {
|
||||
background-color: rgba(255, 255, 255, 0.1);
|
||||
border-left-color: #667eea;
|
||||
padding-left: 1.5rem;
|
||||
}
|
||||
|
||||
.navbar.flex-column .nav-link.active {
|
||||
background-color: rgba(255, 255, 255, 0.1);
|
||||
border-left-color: #667eea;
|
||||
}
|
||||
|
||||
main {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 0;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
/* Responsive design */
|
||||
@media (max-width: 991.98px) {
|
||||
body {
|
||||
display: block !important;
|
||||
}
|
||||
|
||||
main {
|
||||
margin-left: 0 !important;
|
||||
}
|
||||
|
||||
.navbar.flex-column {
|
||||
width: 100% !important;
|
||||
height: auto !important;
|
||||
position: relative !important;
|
||||
top: auto !important;
|
||||
start: auto !important;
|
||||
}
|
||||
}
|
||||
|
||||
.navbar {
|
||||
box-shadow: 0 0.125rem 0.25rem rgba(0, 0, 0, 0.075);
|
||||
}
|
||||
|
||||
.username {
|
||||
font-weight: 600;
|
||||
color: #667eea;
|
||||
font-size: 1.1em;
|
||||
}
|
||||
|
||||
.logout-btn {
|
||||
padding: 10px 20px;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 5px;
|
||||
cursor: pointer;
|
||||
font-weight: 600;
|
||||
transition: opacity 0.3s;
|
||||
}
|
||||
|
||||
.logout-btn:hover {
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
.grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
|
||||
gap: 20px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.card {
|
||||
background: white;
|
||||
padding: 25px;
|
||||
border-radius: 10px;
|
||||
box-shadow: 0 10px 30px rgba(0,0,0,0.2);
|
||||
}
|
||||
|
||||
.card h2 {
|
||||
color: #333;
|
||||
margin-bottom: 15px;
|
||||
font-size: 1.5em;
|
||||
border-bottom: 2px solid #667eea;
|
||||
padding-bottom: 10px;
|
||||
}
|
||||
|
||||
.stat {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 10px 0;
|
||||
border-bottom: 1px solid #eee;
|
||||
}
|
||||
|
||||
.stat:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
color: #666;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.stat-value {
|
||||
color: #333;
|
||||
font-weight: bold;
|
||||
font-size: 1.2em;
|
||||
}
|
||||
|
||||
.status {
|
||||
display: inline-block;
|
||||
padding: 5px 15px;
|
||||
border-radius: 20px;
|
||||
font-size: 0.9em;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.status.online {
|
||||
background: #4caf50;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.status.offline {
|
||||
background: #f44336;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.world-list {
|
||||
max-height: 300px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.world-item {
|
||||
padding: 15px;
|
||||
background: #f5f5f5;
|
||||
border-radius: 5px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.world-item h3 {
|
||||
color: #333;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.world-detail {
|
||||
color: #666;
|
||||
font-size: 0.9em;
|
||||
margin: 3px 0;
|
||||
}
|
||||
|
||||
.loading {
|
||||
text-align: center;
|
||||
padding: 20px;
|
||||
color: #666;
|
||||
}
|
||||
30
dDashboardServer/static/css/login.css
Normal file
30
dDashboardServer/static/css/login.css
Normal file
@@ -0,0 +1,30 @@
|
||||
/* Custom styling for login page on top of Bootstrap5 */
|
||||
|
||||
body {
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif;
|
||||
}
|
||||
|
||||
.card {
|
||||
border-radius: 0.5rem;
|
||||
box-shadow: 0 10px 25px rgba(0, 0, 0, 0.2) !important;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
border: none;
|
||||
}
|
||||
|
||||
.btn-primary:hover {
|
||||
background: linear-gradient(135deg, #5568d3 0%, #6a3f93 100%);
|
||||
}
|
||||
|
||||
.form-control:focus {
|
||||
border-color: #667eea;
|
||||
box-shadow: 0 0 0 0.2rem rgba(102, 126, 234, 0.25);
|
||||
}
|
||||
|
||||
h1 {
|
||||
color: #333;
|
||||
font-weight: 600;
|
||||
}
|
||||
240
dDashboardServer/static/js/dashboard.js
Normal file
240
dDashboardServer/static/js/dashboard.js
Normal file
@@ -0,0 +1,240 @@
|
||||
let ws = null;
|
||||
let reconnectAttempts = 0;
|
||||
const maxReconnectAttempts = 5;
|
||||
const reconnectDelay = 3000;
|
||||
|
||||
// Helper function to get cookie value
|
||||
function getCookie(name) {
|
||||
const nameEQ = name + '=';
|
||||
const cookies = document.cookie.split(';');
|
||||
for (let cookie of cookies) {
|
||||
cookie = cookie.trim();
|
||||
if (cookie.indexOf(nameEQ) === 0) {
|
||||
return decodeURIComponent(cookie.substring(nameEQ.length));
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// Helper function to delete cookie
|
||||
function deleteCookie(name) {
|
||||
document.cookie = `${name}=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/; SameSite=Strict`;
|
||||
}
|
||||
|
||||
// Check authentication on page load
|
||||
function checkAuthentication() {
|
||||
// Check localStorage first (most secure)
|
||||
let token = localStorage.getItem('dashboardToken');
|
||||
|
||||
// Fallback to cookie if localStorage empty
|
||||
if (!token) {
|
||||
token = getCookie('dashboardToken');
|
||||
}
|
||||
|
||||
if (!token) {
|
||||
// Redirect to login if no token
|
||||
window.location.href = '/login';
|
||||
return false;
|
||||
}
|
||||
|
||||
// Verify token is valid (asynchronous)
|
||||
fetch('/api/auth/verify', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ token: token })
|
||||
})
|
||||
.then(res => {
|
||||
if (!res.ok) {
|
||||
console.error('Verify endpoint returned:', res.status);
|
||||
throw new Error(`HTTP ${res.status}`);
|
||||
}
|
||||
return res.json();
|
||||
})
|
||||
.then(data => {
|
||||
console.log('Token verification response:', data);
|
||||
if (!data.valid) {
|
||||
// Token is invalid/expired, delete cookies and redirect to login
|
||||
console.log('Token verification failed, redirecting to login');
|
||||
deleteCookie('dashboardToken');
|
||||
deleteCookie('gmLevel');
|
||||
localStorage.removeItem('dashboardToken');
|
||||
window.location.href = '/login';
|
||||
} else {
|
||||
// Update UI with username
|
||||
console.log('Token verified, user:', data.username);
|
||||
const usernameElement = document.querySelector('.username');
|
||||
if (usernameElement) {
|
||||
usernameElement.textContent = data.username || 'User';
|
||||
} else {
|
||||
console.warn('Username element not found in DOM');
|
||||
}
|
||||
// Now that verification is complete, connect to WebSocket
|
||||
setTimeout(() => {
|
||||
console.log('Starting WebSocket connection');
|
||||
connectWebSocket();
|
||||
}, 100);
|
||||
}
|
||||
})
|
||||
.catch(err => {
|
||||
console.error('Token verification error:', err);
|
||||
// Network error - log but don't redirect immediately
|
||||
// This prevents redirect loops on network issues
|
||||
});
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
// Get token from localStorage or cookie
|
||||
function getAuthToken() {
|
||||
let token = localStorage.getItem('dashboardToken');
|
||||
if (!token) {
|
||||
token = getCookie('dashboardToken');
|
||||
}
|
||||
console.log('getAuthToken called, token available:', !!token);
|
||||
return token;
|
||||
}
|
||||
|
||||
// Logout function
|
||||
function logout() {
|
||||
deleteCookie('dashboardToken');
|
||||
deleteCookie('gmLevel');
|
||||
localStorage.removeItem('dashboardToken');
|
||||
window.location.href = '/login';
|
||||
}
|
||||
|
||||
function connectWebSocket() {
|
||||
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||
const token = getAuthToken();
|
||||
if (!token) {
|
||||
console.error('No token available for WebSocket connection');
|
||||
window.location.href = '/login';
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(`WebSocket connection attempt ${reconnectAttempts + 1}/${maxReconnectAttempts}`);
|
||||
|
||||
// Connect to WebSocket without token in URL (token is in cookies)
|
||||
const wsUrl = `${protocol}//${window.location.host}/ws`;
|
||||
console.log(`Connecting to WebSocket: ${wsUrl}`);
|
||||
|
||||
try {
|
||||
ws = new WebSocket(wsUrl);
|
||||
|
||||
ws.onopen = () => {
|
||||
console.log('WebSocket connected');
|
||||
reconnectAttempts = 0;
|
||||
|
||||
// Subscribe to dashboard updates
|
||||
ws.send(JSON.stringify({
|
||||
event: 'subscribe',
|
||||
subscription: 'dashboard_update'
|
||||
}));
|
||||
|
||||
document.getElementById('connection-status')?.remove();
|
||||
};
|
||||
|
||||
ws.onmessage = (event) => {
|
||||
try {
|
||||
const data = JSON.parse(event.data);
|
||||
|
||||
// Handle subscription confirmation
|
||||
if (data.subscribed) {
|
||||
console.log('Subscribed to:', data.subscribed);
|
||||
return;
|
||||
}
|
||||
|
||||
// Handle dashboard updates
|
||||
if (data.event === 'dashboard_update') {
|
||||
updateDashboard(data);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error parsing WebSocket message:', error);
|
||||
}
|
||||
};
|
||||
|
||||
ws.onerror = (error) => {
|
||||
console.error('WebSocket error:', error);
|
||||
};
|
||||
|
||||
ws.onclose = () => {
|
||||
console.log('WebSocket disconnected');
|
||||
ws = null;
|
||||
|
||||
// Show connection status
|
||||
showConnectionStatus('Disconnected - Attempting to reconnect...');
|
||||
|
||||
// Attempt to reconnect with exponential backoff
|
||||
if (reconnectAttempts < maxReconnectAttempts) {
|
||||
reconnectAttempts++;
|
||||
const backoffDelay = reconnectDelay * Math.pow(2, reconnectAttempts - 1);
|
||||
console.log(`Reconnecting in ${backoffDelay}ms (attempt ${reconnectAttempts}/${maxReconnectAttempts})`);
|
||||
setTimeout(connectWebSocket, backoffDelay);
|
||||
} else {
|
||||
console.error('Max reconnection attempts reached');
|
||||
showConnectionStatus('Connection lost - Reload page to reconnect');
|
||||
}
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Failed to create WebSocket:', error);
|
||||
showConnectionStatus('Failed to connect - Reload page to retry');
|
||||
}
|
||||
}
|
||||
|
||||
function showConnectionStatus(message) {
|
||||
let statusEl = document.getElementById('connection-status');
|
||||
if (!statusEl) {
|
||||
statusEl = document.createElement('div');
|
||||
statusEl.id = 'connection-status';
|
||||
statusEl.style.cssText = 'position: fixed; top: 10px; right: 10px; background: #f44336; color: white; padding: 10px 20px; border-radius: 4px; z-index: 1000;';
|
||||
document.body.appendChild(statusEl);
|
||||
}
|
||||
statusEl.textContent = message;
|
||||
}
|
||||
|
||||
function updateDashboard(data) {
|
||||
// Update server status
|
||||
if (data.auth) {
|
||||
document.getElementById('auth-status').textContent = data.auth.online ? 'Online' : 'Offline';
|
||||
document.getElementById('auth-status').className = 'status ' + (data.auth.online ? 'online' : 'offline');
|
||||
}
|
||||
|
||||
if (data.chat) {
|
||||
document.getElementById('chat-status').textContent = data.chat.online ? 'Online' : 'Offline';
|
||||
document.getElementById('chat-status').className = 'status ' + (data.chat.online ? 'online' : 'offline');
|
||||
}
|
||||
|
||||
// Update world instances
|
||||
if (data.worlds) {
|
||||
document.getElementById('world-count').textContent = data.worlds.length;
|
||||
|
||||
const worldList = document.getElementById('world-list');
|
||||
if (data.worlds.length === 0) {
|
||||
worldList.innerHTML = '<div class="loading">No active world instances</div>';
|
||||
} else {
|
||||
worldList.innerHTML = data.worlds.map(world => `
|
||||
<div class="world-item">
|
||||
<h3>Zone ${world.mapID} - Instance ${world.instanceID}</h3>
|
||||
<div class="world-detail">Clone ID: ${world.cloneID}</div>
|
||||
<div class="world-detail">Players: ${world.players}</div>
|
||||
<div class="world-detail">Type: ${world.isPrivate ? 'Private' : 'Public'}</div>
|
||||
</div>
|
||||
`).join('');
|
||||
}
|
||||
}
|
||||
|
||||
// Update statistics
|
||||
if (data.stats) {
|
||||
if (data.stats.onlinePlayers !== undefined) {
|
||||
document.getElementById('online-players').textContent = data.stats.onlinePlayers;
|
||||
}
|
||||
if (data.stats.totalAccounts !== undefined) {
|
||||
document.getElementById('total-accounts').textContent = data.stats.totalAccounts;
|
||||
}
|
||||
if (data.stats.totalCharacters !== undefined) {
|
||||
document.getElementById('total-characters').textContent = data.stats.totalCharacters;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Connect on page load
|
||||
connectWebSocket();
|
||||
99
dDashboardServer/static/js/login.js
Normal file
99
dDashboardServer/static/js/login.js
Normal file
@@ -0,0 +1,99 @@
|
||||
// Check if user is already logged in
|
||||
function checkExistingToken() {
|
||||
const token = localStorage.getItem('dashboardToken');
|
||||
if (token) {
|
||||
verifyTokenAndRedirect(token);
|
||||
}
|
||||
}
|
||||
|
||||
function verifyTokenAndRedirect(token) {
|
||||
fetch('/api/auth/verify', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ token: token })
|
||||
})
|
||||
.then(res => res.json())
|
||||
.then(data => {
|
||||
if (data.valid) {
|
||||
window.location.href = '/';
|
||||
}
|
||||
})
|
||||
.catch(err => console.error('Token verification failed:', err));
|
||||
}
|
||||
|
||||
function showAlert(message, type) {
|
||||
const alert = document.getElementById('alert');
|
||||
alert.textContent = message;
|
||||
alert.className = 'alert';
|
||||
if (type === 'error') {
|
||||
alert.classList.add('alert-danger');
|
||||
} else if (type === 'success') {
|
||||
alert.classList.add('alert-success');
|
||||
}
|
||||
alert.style.display = 'block';
|
||||
}
|
||||
|
||||
// Wait for DOM to be ready
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
const loginForm = document.getElementById('loginForm');
|
||||
if (!loginForm) {
|
||||
console.error('Login form not found');
|
||||
return;
|
||||
}
|
||||
|
||||
loginForm.addEventListener('submit', async (e) => {
|
||||
e.preventDefault();
|
||||
|
||||
const username = document.getElementById('username').value;
|
||||
const password = document.getElementById('password').value;
|
||||
const rememberMe = document.getElementById('rememberMe').checked;
|
||||
|
||||
// Validate input
|
||||
if (!username || !password) {
|
||||
showAlert('Username and password are required', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
if (password.length > 40) {
|
||||
showAlert('Password exceeds maximum length (40 characters)', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
// Show loading state
|
||||
document.getElementById('loading').style.display = 'inline-block';
|
||||
document.getElementById('loginBtn').disabled = true;
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/auth/login', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ username, password, rememberMe })
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (data.success) {
|
||||
// Store token in localStorage (also set as cookie for API calls)
|
||||
localStorage.setItem('dashboardToken', data.token);
|
||||
document.cookie = `dashboardToken=${data.token}; path=/; SameSite=Strict`;
|
||||
showAlert('Login successful! Redirecting...', 'success');
|
||||
|
||||
// Redirect after a short delay (no token in URL)
|
||||
setTimeout(() => {
|
||||
window.location.href = '/';
|
||||
}, 1000);
|
||||
} else {
|
||||
showAlert(data.message || 'Login failed', 'error');
|
||||
document.getElementById('loading').style.display = 'none';
|
||||
document.getElementById('loginBtn').disabled = false;
|
||||
}
|
||||
} catch (error) {
|
||||
showAlert('Network error: ' + error.message, 'error');
|
||||
document.getElementById('loading').style.display = 'none';
|
||||
document.getElementById('loginBtn').disabled = false;
|
||||
}
|
||||
});
|
||||
|
||||
// Check existing token on page load
|
||||
checkExistingToken();
|
||||
});
|
||||
35
dDashboardServer/templates/base.jinja2
Normal file
35
dDashboardServer/templates/base.jinja2
Normal file
@@ -0,0 +1,35 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>{% block title %}DarkflameServer{% endblock %}</title>
|
||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
|
||||
<link rel="stylesheet" href="https://cdn.datatables.net/2.3.6/css/dataTables.dataTables.min.css">
|
||||
<link rel="stylesheet" href="/css/dashboard.css">
|
||||
{% block css %}{% endblock %}
|
||||
</head>
|
||||
<body class="d-flex bg-dark text-white">
|
||||
{% if username and username != "" %}
|
||||
{% include "header.jinja2" %}
|
||||
{% endif %}
|
||||
|
||||
<div class="container-fluid py-3">
|
||||
{% block content_before %}{% endblock %}
|
||||
{% block content %}{% endblock %}
|
||||
{% block content_after %}{% endblock %}
|
||||
</div>
|
||||
|
||||
<footer class="mt-5 pt-5 border-top border-secondary text-center pb-3">
|
||||
{% block footer %}
|
||||
<p class="text-muted small">DarkflameServer Dashboard © 2024</p>
|
||||
{% endblock %}
|
||||
</footer>
|
||||
|
||||
<script src="https://code.jquery.com/jquery-3.6.0.min.js"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script>
|
||||
<script src="https://cdn.datatables.net/2.3.6/js/jquery.dataTables.min.js"></script>
|
||||
|
||||
{% block scripts %}{% endblock %}
|
||||
</body>
|
||||
</html>
|
||||
30
dDashboardServer/templates/header.jinja2
Normal file
30
dDashboardServer/templates/header.jinja2
Normal file
@@ -0,0 +1,30 @@
|
||||
{# Navigation #}
|
||||
<nav class="navbar navbar-dark bg-dark flex-column" style="width: 280px; height: 100vh; position: fixed; left: 0; top: 0; overflow-y: auto;">
|
||||
<div class="p-3">
|
||||
<a class="navbar-brand fw-bold" href="#">🎮 DarkflameServer</a>
|
||||
</div>
|
||||
|
||||
<ul class="navbar-nav flex-column w-100 flex-grow-1 p-3">
|
||||
<li class="nav-item">
|
||||
<a class="nav-link active" href="#">Home</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" href="#accounts">Accounts</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" href="#characters">Characters</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" href="#properties">Properties</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" href="#players">Players</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" href="#servers">Servers</a>
|
||||
</li>
|
||||
<li class="nav-item mt-auto">
|
||||
<a class="nav-link" href="#" id="logoutBtn">Logout</a>
|
||||
</li>
|
||||
</ul>
|
||||
</nav>
|
||||
35
dDashboardServer/templates/index.jinja2
Normal file
35
dDashboardServer/templates/index.jinja2
Normal file
@@ -0,0 +1,35 @@
|
||||
{% extends "base.jinja2" %}
|
||||
|
||||
{% block title %}Dashboard - DarkflameServer{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<!-- Main Content -->
|
||||
<main style="margin-left: 280px;">
|
||||
<div class="container-fluid p-3 p-md-4">
|
||||
|
||||
<div class="row g-3">
|
||||
{% include "server_status.jinja2" %}
|
||||
{% include "statistics.jinja2" %}
|
||||
</div>
|
||||
|
||||
{% include "world_instances.jinja2" %}
|
||||
</div>
|
||||
</main>
|
||||
{% endblock %}
|
||||
|
||||
{% block scripts %}
|
||||
<script src="/js/dashboard.js"></script>
|
||||
<script>
|
||||
// Check authentication and initialize dashboard
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
// checkAuthentication is now async and calls connectWebSocket when ready
|
||||
checkAuthentication();
|
||||
|
||||
// Setup logout button
|
||||
document.getElementById('logoutBtn').addEventListener('click', (e) => {
|
||||
e.preventDefault();
|
||||
logout();
|
||||
});
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
53
dDashboardServer/templates/login.jinja2
Normal file
53
dDashboardServer/templates/login.jinja2
Normal file
@@ -0,0 +1,53 @@
|
||||
{% extends "base.jinja2" %}
|
||||
|
||||
{% block title %}Dashboard Login - DarkflameServer{% endblock %}
|
||||
|
||||
{% block extra_css %}
|
||||
<link rel="stylesheet" href="/css/login.css">
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="min-vh-100 d-flex align-items-center justify-content-center">
|
||||
<div class="container">
|
||||
<div class="row justify-content-center">
|
||||
<div class="col-md-6 col-lg-5">
|
||||
<div class="card shadow-lg border-0">
|
||||
<div class="card-body p-5">
|
||||
<h1 class="text-center mb-4">🎮 DarkflameServer</h1>
|
||||
|
||||
<div id="alert" class="alert" role="alert" style="display: none;"></div>
|
||||
|
||||
<form id="loginForm">
|
||||
<div class="mb-3">
|
||||
<label for="username" class="form-label">Username</label>
|
||||
<input type="text" class="form-control" id="username" name="username" required autofocus>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label for="password" class="form-label">Password</label>
|
||||
<input type="password" class="form-control" id="password" name="password" required maxlength="40">
|
||||
</div>
|
||||
|
||||
<div class="mb-3 form-check">
|
||||
<input type="checkbox" class="form-check-input" id="rememberMe" name="rememberMe">
|
||||
<label class="form-check-label" for="rememberMe">
|
||||
Remember me for 30 days
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<button type="submit" class="btn btn-primary w-100" id="loginBtn">
|
||||
<span id="loading" class="spinner-border spinner-border-sm me-2" role="status" aria-hidden="true" style="display: none;"></span>
|
||||
<span>Login</span>
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block scripts %}
|
||||
<script src="/js/login.js"></script>
|
||||
{% endblock %}
|
||||
29
dDashboardServer/templates/server_status.jinja2
Normal file
29
dDashboardServer/templates/server_status.jinja2
Normal file
@@ -0,0 +1,29 @@
|
||||
<div class="col-md-6 col-lg-4">
|
||||
<div class="card border-0 shadow-sm h-100">
|
||||
<div class="card-header bg-light">
|
||||
<h5 class="mb-0">Server Status</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||
<span>Auth Server</span>
|
||||
{% if auth.online %}
|
||||
<span class="badge bg-success" id="auth-status">Online</span>
|
||||
{% else %}
|
||||
<span class="badge bg-danger" id="auth-status">Offline</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||
<span>Chat Server</span>
|
||||
{% if chat.online %}
|
||||
<span class="badge bg-success" id="chat-status">Online</span>
|
||||
{% else %}
|
||||
<span class="badge bg-danger" id="chat-status">Offline</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="d-flex justify-content-between align-items-center">
|
||||
<span>Active Worlds</span>
|
||||
<span class="badge bg-primary" id="world-count">{{ length(worlds) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
21
dDashboardServer/templates/statistics.jinja2
Normal file
21
dDashboardServer/templates/statistics.jinja2
Normal file
@@ -0,0 +1,21 @@
|
||||
<div class="col-md-6 col-lg-4">
|
||||
<div class="card border-0 shadow-sm h-100">
|
||||
<div class="card-header bg-light">
|
||||
<h5 class="mb-0">Statistics</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||
<span>Online Players</span>
|
||||
<span class="badge bg-info" id="online-players">{{ stats.onlinePlayers }}</span>
|
||||
</div>
|
||||
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||
<span>Total Accounts</span>
|
||||
<span class="badge bg-info" id="total-accounts">{{ stats.totalAccounts }}</span>
|
||||
</div>
|
||||
<div class="d-flex justify-content-between align-items-center">
|
||||
<span>Total Characters</span>
|
||||
<span class="badge bg-info" id="total-characters">{{ stats.totalCharacters }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
37
dDashboardServer/templates/world_instances.jinja2
Normal file
37
dDashboardServer/templates/world_instances.jinja2
Normal file
@@ -0,0 +1,37 @@
|
||||
<div class="card border-0 shadow-sm mt-4">
|
||||
<div class="card-header bg-light">
|
||||
<h5 class="mb-0">Active World Instances</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div id="world-list">
|
||||
{% if length(worlds) == 0 %}
|
||||
<p class="text-muted text-center mb-0">No active world instances</p>
|
||||
{% else %}
|
||||
<div class="table-responsive">
|
||||
<table class="table table-sm mb-0">
|
||||
<thead class="table-light">
|
||||
<tr>
|
||||
<th>Zone</th>
|
||||
<th>Instance</th>
|
||||
<th>Clone</th>
|
||||
<th>Players</th>
|
||||
<th>Type</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for world in worlds %}
|
||||
<tr>
|
||||
<td>{{ world.mapID }}</td>
|
||||
<td>{{ world.instanceID }}</td>
|
||||
<td>{{ world.cloneID }}</td>
|
||||
<td><span class="badge bg-secondary">{{ world.players }}</span></td>
|
||||
<td>{% if world.isPrivate %}<span class="badge bg-warning">Private</span>{% else %}<span class="badge bg-primary">Public</span>{% endif %}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -39,6 +39,22 @@ public:
|
||||
virtual void UpdateAccountGmLevel(const uint32_t accountId, const eGameMasterLevel gmLevel) = 0;
|
||||
|
||||
virtual uint32_t GetAccountCount() = 0;
|
||||
|
||||
// Login attempt tracking methods
|
||||
// Record a failed login attempt
|
||||
virtual void RecordFailedAttempt(const uint32_t accountId) = 0;
|
||||
|
||||
// Clear failed login attempts and update last login time
|
||||
virtual void ClearFailedAttempts(const uint32_t accountId) = 0;
|
||||
|
||||
// Set account lockout
|
||||
virtual void SetLockout(const uint32_t accountId, const int64_t lockoutUntil) = 0;
|
||||
|
||||
// Check if account is locked out
|
||||
virtual bool IsLockedOut(const uint32_t accountId) = 0;
|
||||
|
||||
// Get failed attempt count
|
||||
virtual uint8_t GetFailedAttempts(const uint32_t accountId) = 0;
|
||||
};
|
||||
|
||||
#endif //!__IACCOUNTS__H__
|
||||
|
||||
@@ -126,6 +126,11 @@ public:
|
||||
void InsertUgcBuild(const std::string& modules, const LWOOBJID bigId, const std::optional<LWOOBJID> characterId) override;
|
||||
void DeleteUgcBuild(const LWOOBJID bigId) override;
|
||||
uint32_t GetAccountCount() override;
|
||||
void RecordFailedAttempt(const uint32_t accountId) override;
|
||||
void ClearFailedAttempts(const uint32_t accountId) override;
|
||||
void SetLockout(const uint32_t accountId, const int64_t lockoutUntil) override;
|
||||
bool IsLockedOut(const uint32_t accountId) override;
|
||||
uint8_t GetFailedAttempts(const uint32_t accountId) override;
|
||||
bool IsNameInUse(const std::string_view name) override;
|
||||
std::optional<IPropertyContents::Model> GetModel(const LWOOBJID modelID) override;
|
||||
std::optional<IUgc::Model> GetUgcModel(const LWOOBJID ugcId) override;
|
||||
|
||||
@@ -45,3 +45,40 @@ uint32_t MySQLDatabase::GetAccountCount() {
|
||||
auto res = ExecuteSelect("SELECT COUNT(*) as count FROM accounts;");
|
||||
return res->next() ? res->getUInt("count") : 0;
|
||||
}
|
||||
|
||||
void MySQLDatabase::RecordFailedAttempt(const uint32_t accountId) {
|
||||
ExecuteUpdate("UPDATE accounts SET failed_attempts = failed_attempts + 1 WHERE id = ?;", accountId);
|
||||
}
|
||||
|
||||
void MySQLDatabase::ClearFailedAttempts(const uint32_t accountId) {
|
||||
ExecuteUpdate("UPDATE accounts SET failed_attempts = 0, lockout_time = NULL, last_login = NOW() WHERE id = ?;", accountId);
|
||||
}
|
||||
|
||||
void MySQLDatabase::SetLockout(const uint32_t accountId, const int64_t lockoutUntil) {
|
||||
ExecuteUpdate("UPDATE accounts SET lockout_time = FROM_UNIXTIME(?) WHERE id = ?;", lockoutUntil, accountId);
|
||||
}
|
||||
|
||||
bool MySQLDatabase::IsLockedOut(const uint32_t accountId) {
|
||||
auto result = ExecuteSelect("SELECT lockout_time FROM accounts WHERE id = ?;", accountId);
|
||||
if (!result->next()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// If lockout_time is set and in the future, account is locked
|
||||
const char* lockoutTime = result->getString("lockout_time").c_str();
|
||||
if (lockoutTime == nullptr || strlen(lockoutTime) == 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Simplified check - if lockout_time exists and is not null, it's locked
|
||||
return true;
|
||||
}
|
||||
|
||||
uint8_t MySQLDatabase::GetFailedAttempts(const uint32_t accountId) {
|
||||
auto result = ExecuteSelect("SELECT failed_attempts FROM accounts WHERE id = ?;", accountId);
|
||||
if (!result->next()) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
return result->getUInt("failed_attempts");
|
||||
}
|
||||
|
||||
@@ -124,6 +124,11 @@ public:
|
||||
void InsertUgcBuild(const std::string& modules, const LWOOBJID bigId, const std::optional<LWOOBJID> characterId) override;
|
||||
void DeleteUgcBuild(const LWOOBJID bigId) override;
|
||||
uint32_t GetAccountCount() override;
|
||||
void RecordFailedAttempt(const uint32_t accountId) override;
|
||||
void ClearFailedAttempts(const uint32_t accountId) override;
|
||||
void SetLockout(const uint32_t accountId, const int64_t lockoutUntil) override;
|
||||
bool IsLockedOut(const uint32_t accountId) override;
|
||||
uint8_t GetFailedAttempts(const uint32_t accountId) override;
|
||||
bool IsNameInUse(const std::string_view name) override;
|
||||
std::optional<IPropertyContents::Model> GetModel(const LWOOBJID modelID) override;
|
||||
std::optional<IUgc::Model> GetUgcModel(const LWOOBJID ugcId) override;
|
||||
|
||||
@@ -48,3 +48,39 @@ uint32_t SQLiteDatabase::GetAccountCount() {
|
||||
|
||||
return res.getIntField("count");
|
||||
}
|
||||
void SQLiteDatabase::RecordFailedAttempt(const uint32_t accountId) {
|
||||
ExecuteUpdate("UPDATE accounts SET failed_attempts = failed_attempts + 1 WHERE id = ?;", accountId);
|
||||
}
|
||||
|
||||
void SQLiteDatabase::ClearFailedAttempts(const uint32_t accountId) {
|
||||
ExecuteUpdate("UPDATE accounts SET failed_attempts = 0, lockout_time = NULL, last_login = CURRENT_TIMESTAMP WHERE id = ?;", accountId);
|
||||
}
|
||||
|
||||
void SQLiteDatabase::SetLockout(const uint32_t accountId, const int64_t lockoutUntil) {
|
||||
ExecuteUpdate("UPDATE accounts SET lockout_time = datetime(?, 'unixepoch') WHERE id = ?;", lockoutUntil, accountId);
|
||||
}
|
||||
|
||||
bool SQLiteDatabase::IsLockedOut(const uint32_t accountId) {
|
||||
auto [_, result] = ExecuteSelect("SELECT lockout_time FROM accounts WHERE id = ?;", accountId);
|
||||
if (result.eof()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const char* lockoutTime = result.getStringField("lockout_time");
|
||||
if (lockoutTime == nullptr || strlen(lockoutTime) == 0 || strcmp(lockoutTime, "0") == 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// If lockout_time is set and in the future, account is locked
|
||||
// For now, simplified check - if lockout_time exists, it's locked
|
||||
return true;
|
||||
}
|
||||
|
||||
uint8_t SQLiteDatabase::GetFailedAttempts(const uint32_t accountId) {
|
||||
auto [_, result] = ExecuteSelect("SELECT failed_attempts FROM accounts WHERE id = ?;", accountId);
|
||||
if (result.eof()) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
return result.getIntField("failed_attempts");
|
||||
}
|
||||
@@ -103,6 +103,11 @@ class TestSQLDatabase : public GameDatabase {
|
||||
void InsertUgcBuild(const std::string& modules, const LWOOBJID bigId, const std::optional<LWOOBJID> characterId) override {};
|
||||
void DeleteUgcBuild(const LWOOBJID bigId) override {};
|
||||
uint32_t GetAccountCount() override { return 0; };
|
||||
void RecordFailedAttempt(const uint32_t accountId) override {};
|
||||
void ClearFailedAttempts(const uint32_t accountId) override {};
|
||||
void SetLockout(const uint32_t accountId, const int64_t lockoutUntil) override {};
|
||||
bool IsLockedOut(const uint32_t accountId) override { return false; };
|
||||
uint8_t GetFailedAttempts(const uint32_t accountId) override { return 0; };
|
||||
|
||||
bool IsNameInUse(const std::string_view name) override { return false; };
|
||||
std::optional<IPropertyContents::Model> GetModel(const LWOOBJID modelID) override { return {}; }
|
||||
|
||||
@@ -68,6 +68,7 @@ void HandlePacket(Packet* packet);
|
||||
std::map<uint32_t, std::string> activeSessions;
|
||||
SystemAddress authServerMasterPeerSysAddr;
|
||||
SystemAddress chatServerMasterPeerSysAddr;
|
||||
SystemAddress dashboardServerMasterPeerSysAddr;
|
||||
|
||||
int GenerateBCryptPassword(const std::string& password, const int workFactor, char salt[BCRYPT_HASHSIZE], char hash[BCRYPT_HASHSIZE]) {
|
||||
int32_t bcryptState = ::bcrypt_gensalt(workFactor, salt);
|
||||
@@ -381,6 +382,11 @@ int main(int argc, char** argv) {
|
||||
StartAuthServer();
|
||||
}
|
||||
|
||||
// Start web dashboard if enabled
|
||||
if (Game::config->GetValue("enable_dashboard") == "1") {
|
||||
StartDashboardServer();
|
||||
}
|
||||
|
||||
auto t = std::chrono::high_resolution_clock::now();
|
||||
Packet* packet = nullptr;
|
||||
constexpr uint32_t logFlushTime = 15 * masterFramerate;
|
||||
@@ -505,6 +511,11 @@ void HandlePacket(Packet* packet) {
|
||||
authServerMasterPeerSysAddr = UNASSIGNED_SYSTEM_ADDRESS;
|
||||
StartAuthServer();
|
||||
}
|
||||
|
||||
if (packet->systemAddress == dashboardServerMasterPeerSysAddr) {
|
||||
dashboardServerMasterPeerSysAddr = UNASSIGNED_SYSTEM_ADDRESS;
|
||||
StartDashboardServer();
|
||||
}
|
||||
}
|
||||
|
||||
if (packet->data[0] == ID_CONNECTION_LOST) {
|
||||
@@ -526,6 +537,11 @@ void HandlePacket(Packet* packet) {
|
||||
authServerMasterPeerSysAddr = UNASSIGNED_SYSTEM_ADDRESS;
|
||||
StartAuthServer();
|
||||
}
|
||||
|
||||
if (packet->systemAddress == dashboardServerMasterPeerSysAddr) {
|
||||
dashboardServerMasterPeerSysAddr = UNASSIGNED_SYSTEM_ADDRESS;
|
||||
StartDashboardServer();
|
||||
}
|
||||
}
|
||||
|
||||
if (packet->length < 4) return;
|
||||
@@ -609,6 +625,9 @@ void HandlePacket(Packet* packet) {
|
||||
case ServiceType::AUTH:
|
||||
authServerMasterPeerSysAddr = packet->systemAddress;
|
||||
break;
|
||||
case ServiceType::DASHBOARD:
|
||||
dashboardServerMasterPeerSysAddr = packet->systemAddress;
|
||||
break;
|
||||
default:
|
||||
// We just ignore any other server type
|
||||
break;
|
||||
@@ -907,7 +926,10 @@ int ShutdownSequence(int32_t signal) {
|
||||
}
|
||||
}
|
||||
|
||||
if (allInstancesShutdown && authServerMasterPeerSysAddr == UNASSIGNED_SYSTEM_ADDRESS && chatServerMasterPeerSysAddr == UNASSIGNED_SYSTEM_ADDRESS) {
|
||||
if (allInstancesShutdown && \
|
||||
authServerMasterPeerSysAddr == UNASSIGNED_SYSTEM_ADDRESS && \
|
||||
chatServerMasterPeerSysAddr == UNASSIGNED_SYSTEM_ADDRESS && \
|
||||
dashboardServerMasterPeerSysAddr == UNASSIGNED_SYSTEM_ADDRESS) {
|
||||
LOG("Finished shutting down MasterServer!");
|
||||
break;
|
||||
}
|
||||
@@ -919,6 +941,26 @@ int ShutdownSequence(int32_t signal) {
|
||||
|
||||
if (framesSinceShutdownStart == maxShutdownTime) {
|
||||
LOG("Finished shutting down by timeout!");
|
||||
// log what we were waiting on: worlds, chat, auth, dashboard, etc
|
||||
if (authServerMasterPeerSysAddr != UNASSIGNED_SYSTEM_ADDRESS) {
|
||||
LOG("Auth server did not shutdown in time");
|
||||
}
|
||||
if (chatServerMasterPeerSysAddr != UNASSIGNED_SYSTEM_ADDRESS) {
|
||||
LOG("Chat server did not shutdown in time");
|
||||
}
|
||||
if (dashboardServerMasterPeerSysAddr != UNASSIGNED_SYSTEM_ADDRESS) {
|
||||
LOG("Web server did not shutdown in time");
|
||||
}
|
||||
for (const auto& instance : Game::im->GetInstances()) {
|
||||
if (instance == nullptr) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!instance->GetShutdownComplete()) {
|
||||
LOG("Instance zone %i clone %i instance %i port %i did not shutdown in time", instance->GetMapID(), instance->GetCloneID(), instance->GetInstanceID(), instance->GetPort());
|
||||
}
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -107,6 +107,42 @@ uint32_t StartAuthServer() {
|
||||
return auth_pid;
|
||||
}
|
||||
|
||||
uint32_t StartDashboardServer() {
|
||||
if (Game::ShouldShutdown()) {
|
||||
LOG("Currently shutting down. DashboardServer will not be restarted.");
|
||||
return 0;
|
||||
}
|
||||
auto web_path = BinaryPathFinder::GetBinaryDir() / "DashboardServer";
|
||||
#ifdef _WIN32
|
||||
web_path.replace_extension(".exe");
|
||||
auto web_startup = startup;
|
||||
auto web_info = PROCESS_INFORMATION{};
|
||||
if (!CreateProcessW(web_path.wstring().data(), web_path.wstring().data(),
|
||||
nullptr, nullptr, false, 0, nullptr, nullptr,
|
||||
&web_startup, &web_info))
|
||||
{
|
||||
LOG("Failed to launch DashboardServer");
|
||||
return 0;
|
||||
}
|
||||
|
||||
// get pid and close unused handles
|
||||
auto web_pid = web_info.dwProcessId;
|
||||
CloseHandle(web_info.hProcess);
|
||||
CloseHandle(web_info.hThread);
|
||||
#else // *nix systems
|
||||
const auto web_pid = fork();
|
||||
if (web_pid < 0) {
|
||||
LOG("Failed to launch DashboardServer");
|
||||
return 0;
|
||||
} else if (web_pid == 0) {
|
||||
// We are the child process
|
||||
execl(web_path.string().c_str(), web_path.string().c_str(), nullptr);
|
||||
}
|
||||
#endif
|
||||
LOG("DashboardServer PID is %d", web_pid);
|
||||
return web_pid;
|
||||
}
|
||||
|
||||
uint32_t StartWorldServer(LWOMAPID mapID, uint16_t port, LWOINSTANCEID lastInstanceID, int maxPlayers, LWOCLONEID cloneID) {
|
||||
auto world_path = BinaryPathFinder::GetBinaryDir() / "WorldServer";
|
||||
#ifdef _WIN32
|
||||
|
||||
@@ -3,4 +3,5 @@
|
||||
|
||||
uint32_t StartAuthServer();
|
||||
uint32_t StartChatServer();
|
||||
uint32_t StartDashboardServer();
|
||||
uint32_t StartWorldServer(LWOMAPID mapID, uint16_t port, LWOINSTANCEID lastInstanceID, int maxPlayers, LWOCLONEID cloneID);
|
||||
|
||||
@@ -93,6 +93,7 @@ void MasterPackets::HandleServerInfo(Packet* packet) {
|
||||
}
|
||||
|
||||
void MasterPackets::SendServerInfo(dServer* server, Packet* packet) {
|
||||
LOG("SendServerInfo called for server type %i", static_cast<int>(server->GetServerType()));
|
||||
RakNet::BitStream bitStream;
|
||||
BitStreamUtils::WriteHeader(bitStream, ServiceType::MASTER, MessageType::Master::SERVER_INFO);
|
||||
|
||||
|
||||
@@ -144,7 +144,7 @@ Packet* dServer::ReceiveFromMaster() {
|
||||
break;
|
||||
}
|
||||
case ID_CONNECTION_REQUEST_ACCEPTED: {
|
||||
LOG("Established connection to master, zone (%i), instance (%i)", this->GetZoneID(), this->GetInstanceID());
|
||||
LOG("Established connection to master: ServiceType (%s), Zone (%i), Instance (%i)", StringifiedEnum::ToString(this->GetServerType()).data(), this->GetZoneID(), this->GetInstanceID());
|
||||
mMasterConnectionActive = true;
|
||||
mMasterSystemAddress = packet->systemAddress;
|
||||
MasterPackets::SendServerInfo(this, packet);
|
||||
|
||||
130
dWeb/AuthMiddleware.cpp
Normal file
130
dWeb/AuthMiddleware.cpp
Normal file
@@ -0,0 +1,130 @@
|
||||
#include "AuthMiddleware.h"
|
||||
#include "HTTPContext.h"
|
||||
#include "eHTTPStatusCode.h"
|
||||
#include <algorithm>
|
||||
#include "Logger.h"
|
||||
|
||||
// Forward declare DashboardAuthService::VerifyToken
|
||||
// This will be implemented in the dashboard server
|
||||
namespace DashboardAuthService {
|
||||
bool VerifyToken(const std::string& token, std::string& username, uint8_t& gmLevel);
|
||||
}
|
||||
|
||||
bool AuthMiddleware::Process(HTTPContext& context, HTTPReply& reply) {
|
||||
// Try to extract token from query string first
|
||||
std::string token = ExtractTokenFromQueryString(context.queryString);
|
||||
|
||||
// If not found in query string, try cookies
|
||||
if (token.empty()) {
|
||||
const std::string& cookieHeader = context.GetHeader("Cookie");
|
||||
if (!cookieHeader.empty()) {
|
||||
token = ExtractTokenFromCookies(cookieHeader);
|
||||
}
|
||||
}
|
||||
|
||||
// If not found in query or cookies, try Authorization header (API token)
|
||||
if (token.empty()) {
|
||||
const std::string& authHeader = context.GetHeader("Authorization");
|
||||
if (!authHeader.empty()) {
|
||||
token = ExtractTokenFromAuthHeader(authHeader);
|
||||
}
|
||||
}
|
||||
|
||||
// If token found, verify it
|
||||
if (!token.empty()) {
|
||||
std::string username{};
|
||||
uint8_t gmLevel = 0;
|
||||
|
||||
if (DashboardAuthService::VerifyToken(token, username, gmLevel)) {
|
||||
context.isAuthenticated = true;
|
||||
context.authenticatedUser = username;
|
||||
context.gmLevel = gmLevel;
|
||||
LOG_DEBUG("Authenticated user %s (GM level %d)", username.c_str(), gmLevel);
|
||||
return true; // Continue to next middleware
|
||||
} else {
|
||||
LOG_DEBUG("Failed to verify token from %s", context.clientIP.c_str());
|
||||
}
|
||||
}
|
||||
|
||||
// No valid token found, but we don't fail here
|
||||
// Routes can check context.isAuthenticated if they require auth
|
||||
return true;
|
||||
}
|
||||
|
||||
std::string AuthMiddleware::ExtractTokenFromQueryString(const std::string& queryString) {
|
||||
if (queryString.empty()) return "";
|
||||
|
||||
const std::string tokenPrefix = "token=";
|
||||
const size_t tokenPos = queryString.find(tokenPrefix);
|
||||
|
||||
if (tokenPos == std::string::npos) {
|
||||
return "";
|
||||
}
|
||||
|
||||
const size_t valueStart = tokenPos + tokenPrefix.length();
|
||||
const size_t valueEnd = queryString.find("&", valueStart);
|
||||
|
||||
if (valueEnd == std::string::npos) {
|
||||
return queryString.substr(valueStart);
|
||||
}
|
||||
|
||||
return queryString.substr(valueStart, valueEnd - valueStart);
|
||||
}
|
||||
|
||||
std::string AuthMiddleware::ExtractTokenFromCookies(const std::string& cookieHeader) {
|
||||
if (cookieHeader.empty()) return "";
|
||||
|
||||
const std::string searchStr = "dashboardToken=";
|
||||
const size_t pos = cookieHeader.find(searchStr);
|
||||
|
||||
if (pos == std::string::npos) {
|
||||
return "";
|
||||
}
|
||||
|
||||
const size_t valueStart = pos + searchStr.length();
|
||||
const size_t valueEnd = cookieHeader.find(";", valueStart);
|
||||
|
||||
std::string value;
|
||||
if (valueEnd == std::string::npos) {
|
||||
value = cookieHeader.substr(valueStart);
|
||||
} else {
|
||||
value = cookieHeader.substr(valueStart, valueEnd - valueStart);
|
||||
}
|
||||
|
||||
// URL decode the value
|
||||
std::string decoded{};
|
||||
for (size_t i = 0; i < value.length(); ++i) {
|
||||
if (value[i] == '%' && i + 2 < value.length()) {
|
||||
const std::string hex = value.substr(i + 1, 2);
|
||||
char* endptr = nullptr;
|
||||
const int charCode = static_cast<int>(std::strtol(hex.c_str(), &endptr, 16));
|
||||
if (endptr - hex.c_str() == 2) {
|
||||
decoded += static_cast<char>(charCode);
|
||||
i += 2;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
decoded += value[i];
|
||||
}
|
||||
|
||||
return decoded;
|
||||
}
|
||||
|
||||
std::string AuthMiddleware::ExtractTokenFromAuthHeader(const std::string& authHeader) {
|
||||
if (authHeader.empty()) return "";
|
||||
|
||||
// Check for "Bearer <token>" format
|
||||
const std::string bearerPrefix = "Bearer ";
|
||||
if (authHeader.find(bearerPrefix) == 0) {
|
||||
return authHeader.substr(bearerPrefix.length());
|
||||
}
|
||||
|
||||
// Also check for "Token <token>" format (API tokens)
|
||||
const std::string tokenPrefix = "Token ";
|
||||
if (authHeader.find(tokenPrefix) == 0) {
|
||||
return authHeader.substr(tokenPrefix.length());
|
||||
}
|
||||
|
||||
// Return as-is if no prefix (raw token)
|
||||
return authHeader;
|
||||
}
|
||||
43
dWeb/AuthMiddleware.h
Normal file
43
dWeb/AuthMiddleware.h
Normal file
@@ -0,0 +1,43 @@
|
||||
#pragma once
|
||||
|
||||
#include "IHTTPMiddleware.h"
|
||||
#include <vector>
|
||||
|
||||
/**
|
||||
* Authentication Middleware
|
||||
*
|
||||
* Verifies JWT tokens from:
|
||||
* - Query parameter: ?token=...
|
||||
* - Cookie: dashboardToken=...
|
||||
* - Authorization header: Bearer <token> or Token <token>
|
||||
*
|
||||
* Populates HTTPContext with authentication information if valid.
|
||||
* Does NOT fail on missing auth - that's left to specific routes.
|
||||
*/
|
||||
class AuthMiddleware : public IHTTPMiddleware {
|
||||
public:
|
||||
AuthMiddleware() = default;
|
||||
|
||||
bool Process(HTTPContext& context, HTTPReply& reply) override;
|
||||
|
||||
std::string GetName() const override { return "AuthMiddleware"; }
|
||||
|
||||
private:
|
||||
/**
|
||||
* Extract token from query string
|
||||
* Expected format: "?token=eyJhbGc..." or "&token=eyJhbGc..."
|
||||
*/
|
||||
static std::string ExtractTokenFromQueryString(const std::string& queryString);
|
||||
|
||||
/**
|
||||
* Extract token from Cookie header
|
||||
* Looks for "dashboardToken=..." cookie
|
||||
*/
|
||||
static std::string ExtractTokenFromCookies(const std::string& cookieHeader);
|
||||
|
||||
/**
|
||||
* Extract token from Authorization header
|
||||
* Supports: "Bearer <token>", "Token <token>", or raw token
|
||||
*/
|
||||
static std::string ExtractTokenFromAuthHeader(const std::string& authHeader);
|
||||
};
|
||||
59
dWeb/HTTPContext.h
Normal file
59
dWeb/HTTPContext.h
Normal file
@@ -0,0 +1,59 @@
|
||||
#pragma once
|
||||
|
||||
#include <string>
|
||||
#include <map>
|
||||
#include <memory>
|
||||
#include <algorithm>
|
||||
#include "eHTTPStatusCode.h"
|
||||
|
||||
/**
|
||||
* HTTP Request Context
|
||||
*
|
||||
* Carries all request metadata through the middleware chain.
|
||||
* Populated by the Web framework before middleware/handlers are called.
|
||||
*/
|
||||
struct HTTPContext {
|
||||
// Request metadata
|
||||
std::string method{};
|
||||
std::string path{};
|
||||
std::string queryString{};
|
||||
std::string body{};
|
||||
|
||||
// Request headers (header name -> value)
|
||||
// Header names are lowercase for case-insensitive lookup
|
||||
std::map<std::string, std::string> headers{};
|
||||
|
||||
// Client information
|
||||
std::string clientIP{};
|
||||
|
||||
// Authentication information (populated by auth middleware)
|
||||
bool isAuthenticated = false;
|
||||
std::string authenticatedUser{};
|
||||
uint8_t gmLevel = 0;
|
||||
|
||||
// Custom data for middleware to communicate
|
||||
std::map<std::string, std::string> userData{};
|
||||
|
||||
/**
|
||||
* Get header value (case-insensitive)
|
||||
*/
|
||||
const std::string& GetHeader(const std::string& headerName) const {
|
||||
static const std::string empty{};
|
||||
|
||||
// Convert to lowercase for comparison
|
||||
std::string lowerName = headerName;
|
||||
std::transform(lowerName.begin(), lowerName.end(), lowerName.begin(), ::tolower);
|
||||
|
||||
const auto it = headers.find(lowerName);
|
||||
return it != headers.end() ? it->second : empty;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set header value (automatically lowercased)
|
||||
*/
|
||||
void SetHeader(const std::string& headerName, const std::string& value) {
|
||||
std::string lowerName = headerName;
|
||||
std::transform(lowerName.begin(), lowerName.end(), lowerName.begin(), ::tolower);
|
||||
headers[lowerName] = value;
|
||||
}
|
||||
};
|
||||
38
dWeb/IHTTPMiddleware.h
Normal file
38
dWeb/IHTTPMiddleware.h
Normal file
@@ -0,0 +1,38 @@
|
||||
#pragma once
|
||||
|
||||
#include <memory>
|
||||
#include "HTTPContext.h"
|
||||
|
||||
// Forward declaration
|
||||
struct HTTPReply;
|
||||
|
||||
/**
|
||||
* Middleware Interface
|
||||
*
|
||||
* All middleware implements this interface and is called in order during request processing.
|
||||
* Middleware can:
|
||||
* - Inspect and modify the request (HTTPContext)
|
||||
* - Populate authentication/authorization info
|
||||
* - Short-circuit the chain by setting a reply and returning false
|
||||
* - Pass to the next middleware by returning true
|
||||
*/
|
||||
class IHTTPMiddleware {
|
||||
public:
|
||||
virtual ~IHTTPMiddleware() = default;
|
||||
|
||||
/**
|
||||
* Process the request through this middleware
|
||||
*
|
||||
* @param context The HTTP request context
|
||||
* @param reply The HTTP reply (can be populated to short-circuit)
|
||||
* @return true to continue to next middleware, false to stop processing
|
||||
*/
|
||||
virtual bool Process(HTTPContext& context, HTTPReply& reply) = 0;
|
||||
|
||||
/**
|
||||
* Get a friendly name for this middleware
|
||||
*/
|
||||
virtual std::string GetName() const = 0;
|
||||
};
|
||||
|
||||
using MiddlewarePtr = std::shared_ptr<IHTTPMiddleware>;
|
||||
25
dWeb/RequireAuthMiddleware.cpp
Normal file
25
dWeb/RequireAuthMiddleware.cpp
Normal file
@@ -0,0 +1,25 @@
|
||||
#include "RequireAuthMiddleware.h"
|
||||
#include "HTTPContext.h"
|
||||
#include "Game.h"
|
||||
#include "Logger.h"
|
||||
|
||||
bool RequireAuthMiddleware::Process(HTTPContext& context, HTTPReply& reply) {
|
||||
if (!context.isAuthenticated) {
|
||||
LOG_DEBUG("Rejected request to %s: not authenticated", context.path.c_str());
|
||||
reply.status = eHTTPStatusCode::UNAUTHORIZED;
|
||||
reply.message = R"({"error":"Unauthorized","message":"Authentication required"})";
|
||||
reply.contentType = eContentType::APPLICATION_JSON;
|
||||
return false; // Stop processing chain
|
||||
}
|
||||
|
||||
if (context.gmLevel < minGmLevel) {
|
||||
LOG("Rejected request to %s: insufficient permissions (gmLevel=%d, required=%d)",
|
||||
context.path.c_str(), context.gmLevel, minGmLevel);
|
||||
reply.status = eHTTPStatusCode::FORBIDDEN;
|
||||
reply.message = R"({"error":"Forbidden","message":"Insufficient permissions"})";
|
||||
reply.contentType = eContentType::APPLICATION_JSON;
|
||||
return false; // Stop processing chain
|
||||
}
|
||||
|
||||
return true; // Continue to next middleware
|
||||
}
|
||||
33
dWeb/RequireAuthMiddleware.h
Normal file
33
dWeb/RequireAuthMiddleware.h
Normal file
@@ -0,0 +1,33 @@
|
||||
#pragma once
|
||||
|
||||
#include "IHTTPMiddleware.h"
|
||||
#include "eHTTPStatusCode.h"
|
||||
|
||||
/**
|
||||
* Require Authentication Middleware
|
||||
*
|
||||
* Verifies that the request has been authenticated.
|
||||
* Must be placed AFTER AuthMiddleware in the chain.
|
||||
*
|
||||
* Fails with 401 Unauthorized if not authenticated.
|
||||
* Optionally checks for minimum GM level.
|
||||
*/
|
||||
class RequireAuthMiddleware : public IHTTPMiddleware {
|
||||
public:
|
||||
/**
|
||||
* Create a require auth middleware
|
||||
*
|
||||
* @param minGmLevel Minimum GM level required (0 = any authenticated user)
|
||||
*/
|
||||
explicit RequireAuthMiddleware(uint8_t minGmLevel = 0)
|
||||
: minGmLevel(minGmLevel) {}
|
||||
|
||||
bool Process(HTTPContext& context, HTTPReply& reply) override;
|
||||
|
||||
std::string GetName() const override {
|
||||
return "RequireAuthMiddleware(minGM=" + std::to_string(minGmLevel) + ")";
|
||||
}
|
||||
|
||||
private:
|
||||
uint8_t minGmLevel{};
|
||||
};
|
||||
265
dWeb/Web.cpp
265
dWeb/Web.cpp
@@ -6,30 +6,134 @@
|
||||
#include "eHTTPMethod.h"
|
||||
#include "GeneralUtils.h"
|
||||
#include "JSONUtils.h"
|
||||
#include "HTTPContext.h"
|
||||
#include "IHTTPMiddleware.h"
|
||||
#include <ranges>
|
||||
#include <set>
|
||||
#include <cctype>
|
||||
|
||||
namespace Game {
|
||||
Web web;
|
||||
}
|
||||
|
||||
namespace {
|
||||
const char* jsonContentType = "Content-Type: application/json\r\n";
|
||||
const std::string wsSubscribed = "{\"status\":\"subscribed\"}";
|
||||
const std::string wsUnsubscribed = "{\"status\":\"unsubscribed\"}";
|
||||
std::map<std::pair<eHTTPMethod, std::string>, HTTPRoute> g_HTTPRoutes;
|
||||
std::map<std::string, WSEvent> g_WSEvents;
|
||||
std::vector<std::string> g_WSSubscriptions;
|
||||
// Keep track of authenticated WebSocket connections
|
||||
std::set<mg_connection*> g_AuthenticatedWSConnections;
|
||||
|
||||
// Global middleware applied to all routes
|
||||
std::vector<MiddlewarePtr> g_GlobalMiddleware;
|
||||
|
||||
// Helper to extract client IP from mongoose connection
|
||||
static std::string GetClientIP(mg_connection* connection) {
|
||||
if (!connection) return "unknown";
|
||||
|
||||
const uint8_t* ip = connection->rem.ip;
|
||||
|
||||
// Check for IPv4-mapped IPv6 addresses (::ffff:x.x.x.x)
|
||||
if (ip[0] == 0 && ip[1] == 0 && ip[2] == 0 && ip[3] == 0 &&
|
||||
ip[4] == 0 && ip[5] == 0 && ip[6] == 0 && ip[7] == 0 &&
|
||||
ip[8] == 0 && ip[9] == 0 && ip[10] == 0xff && ip[11] == 0xff) {
|
||||
// IPv4 address is in bytes 12-15
|
||||
char buffer[32]{};
|
||||
snprintf(buffer, sizeof(buffer), "%d.%d.%d.%d",
|
||||
ip[12], ip[13], ip[14], ip[15]);
|
||||
return buffer;
|
||||
}
|
||||
|
||||
// Direct IPv4
|
||||
char buffer[32]{};
|
||||
snprintf(buffer, sizeof(buffer), "%d.%d.%d.%d",
|
||||
ip[0], ip[1], ip[2], ip[3]);
|
||||
return buffer;
|
||||
}
|
||||
|
||||
// Helper to populate HTTPContext from mg_http_message
|
||||
static void PopulateHTTPContext(HTTPContext& context,
|
||||
const mg_http_message* http_msg,
|
||||
mg_connection* connection) {
|
||||
// Parse method
|
||||
context.method = std::string(http_msg->method.buf, http_msg->method.len);
|
||||
|
||||
// Parse URI/path
|
||||
std::string uri(http_msg->uri.buf, http_msg->uri.len);
|
||||
std::transform(uri.begin(), uri.end(), uri.begin(), ::tolower);
|
||||
|
||||
// Split path and query string
|
||||
const size_t queryPos = uri.find('?');
|
||||
if (queryPos != std::string::npos) {
|
||||
context.path = uri.substr(0, queryPos);
|
||||
context.queryString = uri.substr(queryPos + 1);
|
||||
} else {
|
||||
context.path = uri;
|
||||
context.queryString = "";
|
||||
}
|
||||
|
||||
// Parse body
|
||||
context.body = std::string(http_msg->body.buf, http_msg->body.len);
|
||||
|
||||
// Parse common headers (case-insensitive)
|
||||
const struct mg_str* hdr_ptr;
|
||||
|
||||
// Get Content-Type
|
||||
if ((hdr_ptr = mg_http_get_header(const_cast<mg_http_message*>(http_msg), "Content-Type")) != NULL) {
|
||||
context.SetHeader("Content-Type", std::string(hdr_ptr->buf, hdr_ptr->len));
|
||||
}
|
||||
|
||||
// Get Cookie
|
||||
if ((hdr_ptr = mg_http_get_header(const_cast<mg_http_message*>(http_msg), "Cookie")) != NULL) {
|
||||
context.SetHeader("Cookie", std::string(hdr_ptr->buf, hdr_ptr->len));
|
||||
}
|
||||
|
||||
// Get Authorization
|
||||
if ((hdr_ptr = mg_http_get_header(const_cast<mg_http_message*>(http_msg), "Authorization")) != NULL) {
|
||||
context.SetHeader("Authorization", std::string(hdr_ptr->buf, hdr_ptr->len));
|
||||
}
|
||||
|
||||
// Get User-Agent
|
||||
if ((hdr_ptr = mg_http_get_header(const_cast<mg_http_message*>(http_msg), "User-Agent")) != NULL) {
|
||||
context.SetHeader("User-Agent", std::string(hdr_ptr->buf, hdr_ptr->len));
|
||||
}
|
||||
|
||||
// Get Host
|
||||
if ((hdr_ptr = mg_http_get_header(const_cast<mg_http_message*>(http_msg), "Host")) != NULL) {
|
||||
context.SetHeader("Host", std::string(hdr_ptr->buf, hdr_ptr->len));
|
||||
}
|
||||
|
||||
// Get client IP
|
||||
context.clientIP = GetClientIP(connection);
|
||||
}
|
||||
|
||||
const char* ContentTypeToString(eContentType contentType) {
|
||||
switch (contentType) {
|
||||
case eContentType::APPLICATION_JSON:
|
||||
return "application/json";
|
||||
case eContentType::TEXT_HTML:
|
||||
return "text/html; charset=utf-8";
|
||||
case eContentType::TEXT_CSS:
|
||||
return "text/css; charset=utf-8";
|
||||
case eContentType::TEXT_JAVASCRIPT:
|
||||
return "application/javascript; charset=utf-8";
|
||||
case eContentType::TEXT_PLAIN:
|
||||
return "text/plain; charset=utf-8";
|
||||
case eContentType::IMAGE_PNG:
|
||||
return "image/png";
|
||||
case eContentType::IMAGE_JPEG:
|
||||
return "image/jpeg";
|
||||
case eContentType::APPLICATION_OCTET_STREAM:
|
||||
return "application/octet-stream";
|
||||
default:
|
||||
return "application/json";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
using json = nlohmann::json;
|
||||
|
||||
bool ValidateAuthentication(const mg_http_message* http_msg) {
|
||||
// TO DO: This is just a placeholder for now
|
||||
// use tokens or something at a later point if we want to implement authentication
|
||||
// bit using the listen bind address to limit external access is good enough to start with
|
||||
return true;
|
||||
}
|
||||
|
||||
void HandleHTTPMessage(mg_connection* connection, const mg_http_message* http_msg) {
|
||||
if (g_HTTPRoutes.empty()) return;
|
||||
|
||||
@@ -38,46 +142,136 @@ void HandleHTTPMessage(mg_connection* connection, const mg_http_message* http_ms
|
||||
if (!http_msg) {
|
||||
reply.status = eHTTPStatusCode::BAD_REQUEST;
|
||||
reply.message = "{\"error\":\"Invalid Request\"}";
|
||||
} else if (ValidateAuthentication(http_msg)) {
|
||||
|
||||
// convert method from cstring to std string
|
||||
} else {
|
||||
// All authentication is now handled by middleware chain
|
||||
// Convert method from cstring to enum
|
||||
std::string method_string(http_msg->method.buf, http_msg->method.len);
|
||||
// get method from mg to enum
|
||||
const eHTTPMethod method = magic_enum::enum_cast<eHTTPMethod>(method_string).value_or(eHTTPMethod::INVALID);
|
||||
|
||||
// convert uri from cstring to std string
|
||||
// Extract URI and convert to lowercase
|
||||
std::string uri(http_msg->uri.buf, http_msg->uri.len);
|
||||
std::transform(uri.begin(), uri.end(), uri.begin(), ::tolower);
|
||||
|
||||
// convert body from cstring to std string
|
||||
std::string body(http_msg->body.buf, http_msg->body.len);
|
||||
|
||||
// Special case for websocket
|
||||
if (uri == "/ws" && method == eHTTPMethod::GET) {
|
||||
mg_ws_upgrade(connection, const_cast<mg_http_message*>(http_msg), NULL);
|
||||
LOG_DEBUG("Upgraded connection to websocket: %d.%d.%d.%d:%i", MG_IPADDR_PARTS(&connection->rem.ip), connection->rem.port);
|
||||
// return cause they are now a websocket
|
||||
// Check if connection is from localhost/internal network
|
||||
bool isInternal = false;
|
||||
const uint8_t* ip = connection->rem.ip;
|
||||
|
||||
// Check for IPv4-mapped IPv6 addresses (::ffff:x.x.x.x)
|
||||
if (ip[0] == 0 && ip[1] == 0 && ip[2] == 0 && ip[3] == 0 &&
|
||||
ip[4] == 0 && ip[5] == 0 && ip[6] == 0 && ip[7] == 0 &&
|
||||
ip[8] == 0 && ip[9] == 0 && ip[10] == 0xff && ip[11] == 0xff) {
|
||||
// IPv4 address is in bytes 12-15
|
||||
uint8_t b1 = ip[12];
|
||||
uint8_t b2 = ip[13];
|
||||
|
||||
// Check for 127.x.x.x (localhost)
|
||||
if (b1 == 127) {
|
||||
isInternal = true;
|
||||
}
|
||||
// Check for 192.168.x.x
|
||||
else if (b1 == 192 && b2 == 168) {
|
||||
isInternal = true;
|
||||
}
|
||||
// Check for 10.x.x.x
|
||||
else if (b1 == 10) {
|
||||
isInternal = true;
|
||||
}
|
||||
// Check for 172.16.x.x to 172.31.x.x
|
||||
else if (b1 == 172 && b2 >= 16 && b2 <= 31) {
|
||||
isInternal = true;
|
||||
}
|
||||
}
|
||||
|
||||
bool authenticated = isInternal; // Internal connections are automatically trusted
|
||||
|
||||
// For external connections, require authentication cookie
|
||||
if (!isInternal) {
|
||||
const auto* cookieHeader = mg_http_get_header(const_cast<mg_http_message*>(http_msg), "Cookie");
|
||||
if (cookieHeader) {
|
||||
std::string cookieStr = std::string(cookieHeader->buf, cookieHeader->len);
|
||||
if (!cookieStr.empty() && cookieStr.find("dashboardToken=") != std::string::npos) {
|
||||
authenticated = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (authenticated) {
|
||||
mg_ws_upgrade(connection, const_cast<mg_http_message*>(http_msg), NULL);
|
||||
g_AuthenticatedWSConnections.insert(connection);
|
||||
const char* connType = isInternal ? "internal" : "external";
|
||||
LOG_DEBUG("Upgraded %s connection to websocket: %d.%d.%d.%d:%i", connType, MG_IPADDR_PARTS(&connection->rem.ip), connection->rem.port);
|
||||
} else {
|
||||
LOG_DEBUG("Rejected WebSocket connection - no valid authentication from %d.%d.%d.%d:%i", MG_IPADDR_PARTS(&connection->rem.ip), connection->rem.port);
|
||||
reply.status = eHTTPStatusCode::UNAUTHORIZED;
|
||||
reply.message = "{\"error\":\"Unauthorized\"}";
|
||||
std::string headers = std::string("Content-Type: ") + ContentTypeToString(reply.contentType) + "\r\n";
|
||||
if (!reply.location.empty()) {
|
||||
headers += "Location: " + reply.location + "\r\n";
|
||||
}
|
||||
mg_http_reply(connection, static_cast<int>(reply.status), headers.c_str(), reply.message.c_str());
|
||||
}
|
||||
// return cause they are now a websocket or connection closed
|
||||
return;
|
||||
}
|
||||
|
||||
// Handle HTTP request
|
||||
const auto routeItr = g_HTTPRoutes.find({method, uri});
|
||||
if (routeItr != g_HTTPRoutes.end()) {
|
||||
const auto& [_, route] = *routeItr;
|
||||
route.handle(reply, body);
|
||||
const auto& route = routeItr->second;
|
||||
|
||||
// Create HTTP context from request
|
||||
HTTPContext context;
|
||||
PopulateHTTPContext(context, http_msg, connection);
|
||||
|
||||
// Build complete middleware chain
|
||||
std::vector<MiddlewarePtr> middlewareChain = g_GlobalMiddleware;
|
||||
middlewareChain.insert(middlewareChain.end(),
|
||||
route.middleware.begin(),
|
||||
route.middleware.end());
|
||||
|
||||
// Execute middleware chain
|
||||
bool chainPassed = true;
|
||||
for (const auto& middleware : middlewareChain) {
|
||||
if (!middleware->Process(context, reply)) {
|
||||
chainPassed = false;
|
||||
LOG_DEBUG("Middleware %s rejected request to %s %s",
|
||||
middleware->GetName().c_str(),
|
||||
context.method.c_str(),
|
||||
context.path.c_str());
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Call handler only if all middleware passed
|
||||
if (chainPassed) {
|
||||
route.handle(reply, context);
|
||||
}
|
||||
} else {
|
||||
reply.status = eHTTPStatusCode::NOT_FOUND;
|
||||
reply.message = "{\"error\":\"Not Found\"}";
|
||||
}
|
||||
} else {
|
||||
reply.status = eHTTPStatusCode::UNAUTHORIZED;
|
||||
reply.message = "{\"error\":\"Unauthorized\"}";
|
||||
}
|
||||
mg_http_reply(connection, static_cast<int>(reply.status), jsonContentType, reply.message.c_str());
|
||||
|
||||
// Build headers
|
||||
std::string headers = std::string("Content-Type: ") + ContentTypeToString(reply.contentType) + "\r\n";
|
||||
if (!reply.location.empty()) {
|
||||
headers += "Location: " + reply.location + "\r\n";
|
||||
}
|
||||
mg_http_reply(connection, static_cast<int>(reply.status), headers.c_str(), reply.message.c_str());
|
||||
}
|
||||
|
||||
|
||||
|
||||
void HandleWSMessage(mg_connection* connection, const mg_ws_message* ws_msg) {
|
||||
// Check if connection is authenticated
|
||||
if (g_AuthenticatedWSConnections.find(connection) == g_AuthenticatedWSConnections.end()) {
|
||||
LOG_DEBUG("Received websocket message from unauthenticated connection");
|
||||
mg_ws_send(connection, "{\"error\":\"Unauthorized\"}", 23, WEBSOCKET_OP_TEXT);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!ws_msg) {
|
||||
LOG_DEBUG("Received invalid websocket message");
|
||||
return;
|
||||
@@ -233,6 +427,15 @@ void Web::RegisterWSSubscription(const std::string& subscription) {
|
||||
}
|
||||
}
|
||||
|
||||
void Web::AddGlobalMiddleware(MiddlewarePtr middleware) {
|
||||
if (!middleware) {
|
||||
LOG_DEBUG("Attempted to add null middleware");
|
||||
return;
|
||||
}
|
||||
g_GlobalMiddleware.push_back(middleware);
|
||||
LOG_DEBUG("Registered global middleware: %s", middleware->GetName().c_str());
|
||||
}
|
||||
|
||||
Web::Web() {
|
||||
mg_log_set_fn(DLOG, NULL); // Redirect logs to our logger
|
||||
mg_log_set(MG_LL_DEBUG);
|
||||
@@ -293,6 +496,18 @@ void Web::SendWSMessage(const std::string subscription, json& data) {
|
||||
// tell it the event type
|
||||
data["event"] = subscription;
|
||||
auto index = std::distance(g_WSSubscriptions.begin(), subItr);
|
||||
|
||||
// Clean up closed connections from authenticated set
|
||||
std::vector<mg_connection*> closedConnections;
|
||||
for (auto* conn : g_AuthenticatedWSConnections) {
|
||||
if (conn->is_closing) {
|
||||
closedConnections.push_back(conn);
|
||||
}
|
||||
}
|
||||
for (auto* conn : closedConnections) {
|
||||
g_AuthenticatedWSConnections.erase(conn);
|
||||
}
|
||||
|
||||
for (auto *wc = Game::web.mgr.conns; wc != NULL; wc = wc->next) {
|
||||
if (wc->is_websocket && wc->data[index] == SubscriptionStatus::SUBSCRIBED) {
|
||||
mg_ws_send(wc, data.dump().c_str(), data.dump().size(), WEBSOCKET_OP_TEXT);
|
||||
|
||||
27
dWeb/Web.h
27
dWeb/Web.h
@@ -4,9 +4,13 @@
|
||||
#include <functional>
|
||||
#include <string>
|
||||
#include <optional>
|
||||
#include <vector>
|
||||
#include <memory>
|
||||
#include "mongoose.h"
|
||||
#include "json_fwd.hpp"
|
||||
#include "eHTTPStatusCode.h"
|
||||
#include "HTTPContext.h"
|
||||
#include "IHTTPMiddleware.h"
|
||||
|
||||
// Forward declarations for game namespace
|
||||
// so that we can access the data anywhere
|
||||
@@ -20,20 +24,35 @@ enum class eHTTPMethod;
|
||||
// Forward declaration for mongoose manager
|
||||
typedef struct mg_mgr mg_mgr;
|
||||
|
||||
// Content type enum for HTTP responses
|
||||
enum class eContentType {
|
||||
APPLICATION_JSON,
|
||||
TEXT_HTML,
|
||||
TEXT_CSS,
|
||||
TEXT_JAVASCRIPT,
|
||||
TEXT_PLAIN,
|
||||
IMAGE_PNG,
|
||||
IMAGE_JPEG,
|
||||
APPLICATION_OCTET_STREAM
|
||||
};
|
||||
|
||||
// For passing HTTP messages between functions
|
||||
struct HTTPReply {
|
||||
eHTTPStatusCode status = eHTTPStatusCode::NOT_FOUND;
|
||||
std::string message = "{\"error\":\"Not Found\"}";
|
||||
eContentType contentType = eContentType::APPLICATION_JSON;
|
||||
std::string location = ""; // For redirect responses (Location header)
|
||||
};
|
||||
|
||||
// HTTP route structure
|
||||
// This structure is used to register HTTP routes
|
||||
// with the server. Each route has a path, method, and a handler function
|
||||
// that will be called when the route is matched.
|
||||
// with the server. Each route has a path, method, optional middleware,
|
||||
// and a handler function that will be called when the route is matched.
|
||||
struct HTTPRoute {
|
||||
std::string path;
|
||||
eHTTPMethod method;
|
||||
std::function<void(HTTPReply&, const std::string&)> handle;
|
||||
std::vector<MiddlewarePtr> middleware;
|
||||
std::function<void(HTTPReply&, const HTTPContext&)> handle;
|
||||
};
|
||||
|
||||
// WebSocket event structure
|
||||
@@ -68,6 +87,8 @@ public:
|
||||
void RegisterWSEvent(WSEvent event);
|
||||
// Register WebSocket subscription to be handled by the server
|
||||
void RegisterWSSubscription(const std::string& subscription);
|
||||
// Add global middleware that applies to all routes
|
||||
void AddGlobalMiddleware(MiddlewarePtr middleware);
|
||||
// Returns if the web server is enabled
|
||||
bool IsEnabled() const { return enabled; };
|
||||
// Send a message to all connected WebSocket clients that are subscribed to the given topic
|
||||
|
||||
585
docs/DasshboardWebAPI.yaml
Normal file
585
docs/DasshboardWebAPI.yaml
Normal file
@@ -0,0 +1,585 @@
|
||||
openapi: 3.0.0
|
||||
info:
|
||||
title: DarkflameServer Dashboard API
|
||||
description: |
|
||||
Game server management and monitoring API for DarkflameServer Dashboard.
|
||||
All protected endpoints require JWT authentication.
|
||||
version: 1.0.0
|
||||
contact:
|
||||
name: DarkflameServer Team
|
||||
url: https://github.com/DarkflameUniverse/DarkflameServer
|
||||
license:
|
||||
name: MIT
|
||||
|
||||
servers:
|
||||
- url: http://localhost:3000
|
||||
description: Local development server
|
||||
- url: https://api.example.com
|
||||
description: Production server
|
||||
|
||||
tags:
|
||||
- name: Authentication
|
||||
description: User login and token verification
|
||||
- name: Server
|
||||
description: Server status and information
|
||||
- name: Players
|
||||
description: Player and character management
|
||||
- name: Statistics
|
||||
description: Server statistics and counts
|
||||
|
||||
components:
|
||||
securitySchemes:
|
||||
bearerAuth:
|
||||
type: http
|
||||
scheme: bearer
|
||||
bearerFormat: JWT
|
||||
description: JWT token obtained from login endpoint
|
||||
queryToken:
|
||||
type: apiKey
|
||||
in: query
|
||||
name: token
|
||||
description: JWT token as query parameter
|
||||
cookieToken:
|
||||
type: apiKey
|
||||
in: cookie
|
||||
name: dashboardToken
|
||||
description: JWT token as HTTP-only cookie
|
||||
|
||||
schemas:
|
||||
LoginRequest:
|
||||
type: object
|
||||
required:
|
||||
- username
|
||||
- password
|
||||
properties:
|
||||
username:
|
||||
type: string
|
||||
minLength: 1
|
||||
example: admin
|
||||
password:
|
||||
type: string
|
||||
minLength: 1
|
||||
example: password123
|
||||
rememberMe:
|
||||
type: boolean
|
||||
default: false
|
||||
description: Extends token expiration to 30 days
|
||||
|
||||
LoginResponse:
|
||||
type: object
|
||||
required:
|
||||
- success
|
||||
- token
|
||||
- gmLevel
|
||||
- expiresIn
|
||||
properties:
|
||||
success:
|
||||
type: boolean
|
||||
example: true
|
||||
token:
|
||||
type: string
|
||||
description: JWT token for authenticated requests
|
||||
example: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...
|
||||
gmLevel:
|
||||
type: integer
|
||||
minimum: 0
|
||||
maximum: 9
|
||||
example: 9
|
||||
description: User's GM level (0-9)
|
||||
expiresIn:
|
||||
type: integer
|
||||
description: Token expiration time in seconds
|
||||
example: 86400
|
||||
|
||||
LoginError:
|
||||
type: object
|
||||
required:
|
||||
- success
|
||||
- error
|
||||
properties:
|
||||
success:
|
||||
type: boolean
|
||||
example: false
|
||||
error:
|
||||
type: string
|
||||
example: Invalid username or password
|
||||
|
||||
VerifyTokenRequest:
|
||||
type: object
|
||||
required:
|
||||
- token
|
||||
properties:
|
||||
token:
|
||||
type: string
|
||||
description: JWT token to verify
|
||||
example: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...
|
||||
|
||||
VerifyTokenResponse:
|
||||
type: object
|
||||
required:
|
||||
- valid
|
||||
- username
|
||||
- gmLevel
|
||||
- expiresAt
|
||||
properties:
|
||||
valid:
|
||||
type: boolean
|
||||
example: true
|
||||
username:
|
||||
type: string
|
||||
example: admin
|
||||
gmLevel:
|
||||
type: integer
|
||||
minimum: 0
|
||||
maximum: 9
|
||||
example: 9
|
||||
expiresAt:
|
||||
type: integer
|
||||
description: Unix timestamp when token expires
|
||||
example: 1705960800
|
||||
|
||||
VerifyTokenError:
|
||||
type: object
|
||||
required:
|
||||
- valid
|
||||
- error
|
||||
properties:
|
||||
valid:
|
||||
type: boolean
|
||||
example: false
|
||||
error:
|
||||
type: string
|
||||
example: Invalid or expired token
|
||||
|
||||
ServerStatus:
|
||||
type: object
|
||||
required:
|
||||
- status
|
||||
- version
|
||||
- uptime
|
||||
- timestamp
|
||||
properties:
|
||||
status:
|
||||
type: string
|
||||
enum:
|
||||
- running
|
||||
- stopping
|
||||
- restarting
|
||||
example: running
|
||||
version:
|
||||
type: string
|
||||
example: 1.0.0
|
||||
uptime:
|
||||
type: integer
|
||||
description: Server uptime in seconds
|
||||
example: 3600
|
||||
timestamp:
|
||||
type: integer
|
||||
description: Current Unix timestamp
|
||||
example: 1705960800
|
||||
|
||||
Player:
|
||||
type: object
|
||||
required:
|
||||
- id
|
||||
- name
|
||||
- level
|
||||
- character
|
||||
- zone
|
||||
- lastSeen
|
||||
properties:
|
||||
id:
|
||||
type: integer
|
||||
example: 1
|
||||
name:
|
||||
type: string
|
||||
example: PlayerOne
|
||||
level:
|
||||
type: integer
|
||||
minimum: 1
|
||||
maximum: 999
|
||||
example: 20
|
||||
character:
|
||||
type: string
|
||||
example: Knight
|
||||
zone:
|
||||
type: string
|
||||
example: Nimbus Station
|
||||
lastSeen:
|
||||
type: integer
|
||||
description: Unix timestamp of last activity
|
||||
example: 1705960750
|
||||
|
||||
PlayersResponse:
|
||||
type: object
|
||||
required:
|
||||
- players
|
||||
- total
|
||||
- limit
|
||||
- offset
|
||||
properties:
|
||||
players:
|
||||
type: array
|
||||
items:
|
||||
$ref: '#/components/schemas/Player'
|
||||
total:
|
||||
type: integer
|
||||
description: Total number of players
|
||||
example: 42
|
||||
limit:
|
||||
type: integer
|
||||
description: Requested limit
|
||||
example: 50
|
||||
offset:
|
||||
type: integer
|
||||
description: Requested offset
|
||||
example: 0
|
||||
|
||||
AccountsCountRequest:
|
||||
type: object
|
||||
properties:
|
||||
includeInactive:
|
||||
type: boolean
|
||||
default: false
|
||||
description: Include inactive accounts in count
|
||||
|
||||
AccountsCountResponse:
|
||||
type: object
|
||||
required:
|
||||
- count
|
||||
- active
|
||||
- inactive
|
||||
- timestamp
|
||||
properties:
|
||||
count:
|
||||
type: integer
|
||||
description: Total account count
|
||||
example: 42
|
||||
active:
|
||||
type: integer
|
||||
description: Number of active accounts
|
||||
example: 35
|
||||
inactive:
|
||||
type: integer
|
||||
description: Number of inactive accounts
|
||||
example: 7
|
||||
timestamp:
|
||||
type: integer
|
||||
description: Unix timestamp when data was collected
|
||||
example: 1705960800
|
||||
|
||||
CharactersCountRequest:
|
||||
type: object
|
||||
properties:
|
||||
includeDeleted:
|
||||
type: boolean
|
||||
default: false
|
||||
description: Include deleted characters in count
|
||||
|
||||
CharactersCountResponse:
|
||||
type: object
|
||||
required:
|
||||
- count
|
||||
- active
|
||||
- deleted
|
||||
- timestamp
|
||||
properties:
|
||||
count:
|
||||
type: integer
|
||||
description: Total character count
|
||||
example: 128
|
||||
active:
|
||||
type: integer
|
||||
description: Number of active characters
|
||||
example: 125
|
||||
deleted:
|
||||
type: integer
|
||||
description: Number of deleted characters
|
||||
example: 3
|
||||
timestamp:
|
||||
type: integer
|
||||
description: Unix timestamp when data was collected
|
||||
example: 1705960800
|
||||
|
||||
Error:
|
||||
type: object
|
||||
required:
|
||||
- error
|
||||
- code
|
||||
- timestamp
|
||||
properties:
|
||||
error:
|
||||
type: string
|
||||
example: Authentication required
|
||||
code:
|
||||
type: integer
|
||||
enum:
|
||||
- 400
|
||||
- 401
|
||||
- 403
|
||||
- 404
|
||||
- 500
|
||||
example: 401
|
||||
timestamp:
|
||||
type: integer
|
||||
description: Unix timestamp
|
||||
example: 1705960800
|
||||
|
||||
responses:
|
||||
Unauthorized:
|
||||
description: Missing or invalid authentication token
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/Error'
|
||||
example:
|
||||
error: Authentication required
|
||||
code: 401
|
||||
timestamp: 1705960800
|
||||
|
||||
Forbidden:
|
||||
description: Authenticated but insufficient permissions
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/Error'
|
||||
example:
|
||||
error: Insufficient permissions
|
||||
code: 403
|
||||
timestamp: 1705960800
|
||||
|
||||
BadRequest:
|
||||
description: Invalid request parameters or body
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/Error'
|
||||
example:
|
||||
error: Invalid request body
|
||||
code: 400
|
||||
timestamp: 1705960800
|
||||
|
||||
NotFound:
|
||||
description: Endpoint not found
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/Error'
|
||||
example:
|
||||
error: Not Found
|
||||
code: 404
|
||||
timestamp: 1705960800
|
||||
|
||||
ServerError:
|
||||
description: Internal server error
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/Error'
|
||||
example:
|
||||
error: Internal server error
|
||||
code: 500
|
||||
timestamp: 1705960800
|
||||
|
||||
parameters:
|
||||
tokenQuery:
|
||||
name: token
|
||||
in: query
|
||||
description: JWT token (alternative to header/cookie)
|
||||
schema:
|
||||
type: string
|
||||
required: false
|
||||
|
||||
limitParam:
|
||||
name: limit
|
||||
in: query
|
||||
description: Maximum number of results to return
|
||||
schema:
|
||||
type: integer
|
||||
default: 100
|
||||
maximum: 100
|
||||
minimum: 1
|
||||
required: false
|
||||
|
||||
offsetParam:
|
||||
name: offset
|
||||
in: query
|
||||
description: Pagination offset
|
||||
schema:
|
||||
type: integer
|
||||
default: 0
|
||||
minimum: 0
|
||||
required: false
|
||||
|
||||
paths:
|
||||
/api/auth/login:
|
||||
post:
|
||||
tags:
|
||||
- Authentication
|
||||
summary: User login
|
||||
description: Authenticate user and receive JWT token for API access
|
||||
operationId: loginUser
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/LoginRequest'
|
||||
responses:
|
||||
'200':
|
||||
description: Login successful
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/LoginResponse'
|
||||
'401':
|
||||
$ref: '#/components/responses/Unauthorized'
|
||||
'400':
|
||||
$ref: '#/components/responses/BadRequest'
|
||||
|
||||
/api/auth/verify:
|
||||
post:
|
||||
tags:
|
||||
- Authentication
|
||||
summary: Verify token
|
||||
description: Check if a JWT token is valid and retrieve user information
|
||||
operationId: verifyToken
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/VerifyTokenRequest'
|
||||
responses:
|
||||
'200':
|
||||
description: Token is valid
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/VerifyTokenResponse'
|
||||
'401':
|
||||
description: Token is invalid or expired
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/VerifyTokenError'
|
||||
'400':
|
||||
$ref: '#/components/responses/BadRequest'
|
||||
|
||||
/api/status:
|
||||
get:
|
||||
tags:
|
||||
- Server
|
||||
summary: Get server status
|
||||
description: Get current server status and version information
|
||||
operationId: getServerStatus
|
||||
security:
|
||||
- bearerAuth: []
|
||||
- queryToken: []
|
||||
- cookieToken: []
|
||||
parameters:
|
||||
- $ref: '#/components/parameters/tokenQuery'
|
||||
responses:
|
||||
'200':
|
||||
description: Server status retrieved successfully
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ServerStatus'
|
||||
'401':
|
||||
$ref: '#/components/responses/Unauthorized'
|
||||
'403':
|
||||
$ref: '#/components/responses/Forbidden'
|
||||
|
||||
/api/players:
|
||||
get:
|
||||
tags:
|
||||
- Players
|
||||
summary: List online players
|
||||
description: Get list of currently online players on the server
|
||||
operationId: listPlayers
|
||||
security:
|
||||
- bearerAuth: []
|
||||
- queryToken: []
|
||||
- cookieToken: []
|
||||
parameters:
|
||||
- $ref: '#/components/parameters/tokenQuery'
|
||||
- $ref: '#/components/parameters/limitParam'
|
||||
- $ref: '#/components/parameters/offsetParam'
|
||||
responses:
|
||||
'200':
|
||||
description: Players list retrieved successfully
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/PlayersResponse'
|
||||
'401':
|
||||
$ref: '#/components/responses/Unauthorized'
|
||||
'403':
|
||||
$ref: '#/components/responses/Forbidden'
|
||||
|
||||
/api/accounts/count:
|
||||
post:
|
||||
tags:
|
||||
- Statistics
|
||||
summary: Get total accounts count
|
||||
description: Get total number of registered accounts on the server
|
||||
operationId: getAccountsCount
|
||||
security:
|
||||
- bearerAuth: []
|
||||
- queryToken: []
|
||||
- cookieToken: []
|
||||
parameters:
|
||||
- $ref: '#/components/parameters/tokenQuery'
|
||||
requestBody:
|
||||
required: false
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/AccountsCountRequest'
|
||||
responses:
|
||||
'200':
|
||||
description: Accounts count retrieved successfully
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/AccountsCountResponse'
|
||||
'401':
|
||||
$ref: '#/components/responses/Unauthorized'
|
||||
'403':
|
||||
$ref: '#/components/responses/Forbidden'
|
||||
'400':
|
||||
$ref: '#/components/responses/BadRequest'
|
||||
|
||||
/api/characters/count:
|
||||
post:
|
||||
tags:
|
||||
- Statistics
|
||||
summary: Get total characters count
|
||||
description: Get total number of characters on the server
|
||||
operationId: getCharactersCount
|
||||
security:
|
||||
- bearerAuth: []
|
||||
- queryToken: []
|
||||
- cookieToken: []
|
||||
parameters:
|
||||
- $ref: '#/components/parameters/tokenQuery'
|
||||
requestBody:
|
||||
required: false
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/CharactersCountRequest'
|
||||
responses:
|
||||
'200':
|
||||
description: Characters count retrieved successfully
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/CharactersCountResponse'
|
||||
'401':
|
||||
$ref: '#/components/responses/Unauthorized'
|
||||
'403':
|
||||
$ref: '#/components/responses/Forbidden'
|
||||
'400':
|
||||
$ref: '#/components/responses/BadRequest'
|
||||
6
migrations/dlu/mysql/27_login_tracking.sql
Normal file
6
migrations/dlu/mysql/27_login_tracking.sql
Normal file
@@ -0,0 +1,6 @@
|
||||
-- Migration: Add login tracking columns to accounts table
|
||||
-- Adds fields for tracking login attempts, lockouts, and last login
|
||||
|
||||
ALTER TABLE accounts ADD COLUMN IF NOT EXISTS failed_attempts INT NOT NULL DEFAULT 0;
|
||||
ALTER TABLE accounts ADD COLUMN IF NOT EXISTS lockout_time DATETIME NULL DEFAULT NULL;
|
||||
ALTER TABLE accounts ADD COLUMN IF NOT EXISTS last_login DATETIME NULL DEFAULT NULL;
|
||||
6
migrations/dlu/sqlite/10_login_tracking.sql
Normal file
6
migrations/dlu/sqlite/10_login_tracking.sql
Normal file
@@ -0,0 +1,6 @@
|
||||
/* Migration: Add login tracking columns to accounts table */
|
||||
/* Adds fields for tracking login attempts, lockouts, and last login */
|
||||
|
||||
ALTER TABLE accounts ADD COLUMN failed_attempts INTEGER NOT NULL DEFAULT 0;
|
||||
ALTER TABLE accounts ADD COLUMN lockout_time DATETIME DEFAULT NULL;
|
||||
ALTER TABLE accounts ADD COLUMN last_login DATETIME DEFAULT NULL;
|
||||
15
resources/dashboardconfig.ini
Normal file
15
resources/dashboardconfig.ini
Normal file
@@ -0,0 +1,15 @@
|
||||
# Web Dashboard Configuration
|
||||
|
||||
# The port to listen on for HTTP/WebSocket connections
|
||||
port=2006
|
||||
|
||||
# The IP address to bind to
|
||||
# Use 127.0.0.1 for localhost only (recommended for security)
|
||||
# Use 0.0.0.0 to allow external access (not recommended without authentication)
|
||||
listen_ip=127.0.0.1
|
||||
|
||||
# How often to broadcast updates to connected clients (in milliseconds)
|
||||
broadcast_interval=2000
|
||||
|
||||
# Minimum GM level required to access the dashboard (default: 0 = any user)
|
||||
min_dashboard_gm_level=0
|
||||
@@ -11,3 +11,7 @@ world_port_start=3000
|
||||
prestart_servers=1
|
||||
|
||||
master_password=3.25DARKFLAME1
|
||||
|
||||
# Enable the web dashboard (0 = disabled, 1 = enabled)
|
||||
# Dashboard settings are configured in dashboardconfig.ini
|
||||
enable_dashboard=0
|
||||
|
||||
@@ -7,3 +7,4 @@ include(GoogleTest)
|
||||
# Add the subdirectories
|
||||
add_subdirectory(dCommonTests)
|
||||
add_subdirectory(dGameTests)
|
||||
add_subdirectory(dWebTests)
|
||||
|
||||
19
tests/dWebTests/CMakeLists.txt
Normal file
19
tests/dWebTests/CMakeLists.txt
Normal file
@@ -0,0 +1,19 @@
|
||||
set(DWEBTESTS_SOURCES
|
||||
"MiddlewareTests.cpp"
|
||||
"RouteIntegrationTests.cpp"
|
||||
)
|
||||
|
||||
add_executable(dWebTests ${DWEBTESTS_SOURCES})
|
||||
|
||||
target_include_directories(dWebTests PRIVATE
|
||||
"${PROJECT_SOURCE_DIR}/dCommon"
|
||||
"${PROJECT_SOURCE_DIR}/dCommon/dClient"
|
||||
"${PROJECT_SOURCE_DIR}/dWeb"
|
||||
"${PROJECT_SOURCE_DIR}/dDashboardServer"
|
||||
"${PROJECT_SOURCE_DIR}/dDashboardServer/auth"
|
||||
"${PROJECT_SOURCE_DIR}/thirdparty/nlohmann"
|
||||
)
|
||||
|
||||
target_link_libraries(dWebTests ${COMMON_LIBRARIES} dWeb GTest::gtest_main)
|
||||
|
||||
gtest_discover_tests(dWebTests)
|
||||
334
tests/dWebTests/MiddlewareTests.cpp
Normal file
334
tests/dWebTests/MiddlewareTests.cpp
Normal file
@@ -0,0 +1,334 @@
|
||||
#include <gtest/gtest.h>
|
||||
#include <memory>
|
||||
#include "HTTPContext.h"
|
||||
#include "Web.h"
|
||||
|
||||
// Note: These tests use mock implementations to avoid circular dependencies.
|
||||
// In a real deployment, DashboardAuthService would be used instead.
|
||||
|
||||
// Mock implementation of token verification for testing
|
||||
namespace {
|
||||
bool VerifyTokenMock(const std::string& token, std::string& outUsername, uint8_t& outGmLevel) {
|
||||
// For testing: valid tokens are prefixed with "valid_"
|
||||
if (token.substr(0, 6) == "valid_") {
|
||||
outUsername = "testuser";
|
||||
outGmLevel = 1; // GM level 1
|
||||
return true;
|
||||
}
|
||||
if (token == "admin_token") {
|
||||
outUsername = "admin";
|
||||
outGmLevel = 9; // GM level 9 (admin)
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Test HTTPContext functionality
|
||||
class HTTPContextTest : public ::testing::Test {
|
||||
protected:
|
||||
HTTPContext context;
|
||||
};
|
||||
|
||||
TEST_F(HTTPContextTest, DefaultConstructorInitializesFields) {
|
||||
EXPECT_FALSE(context.isAuthenticated);
|
||||
EXPECT_EQ(context.authenticatedUser, "");
|
||||
EXPECT_EQ(context.gmLevel, 0);
|
||||
EXPECT_EQ(context.method, "");
|
||||
EXPECT_EQ(context.path, "");
|
||||
EXPECT_EQ(context.queryString, "");
|
||||
EXPECT_EQ(context.body, "");
|
||||
EXPECT_EQ(context.clientIP, "");
|
||||
}
|
||||
|
||||
TEST_F(HTTPContextTest, SetHeaderAndGetHeaderCaseInsensitive) {
|
||||
context.SetHeader("Content-Type", "application/json");
|
||||
EXPECT_EQ(context.GetHeader("Content-Type"), "application/json");
|
||||
EXPECT_EQ(context.GetHeader("content-type"), "application/json");
|
||||
EXPECT_EQ(context.GetHeader("CONTENT-TYPE"), "application/json");
|
||||
}
|
||||
|
||||
TEST_F(HTTPContextTest, GetHeaderReturnsEmptyStringForMissingHeader) {
|
||||
EXPECT_EQ(context.GetHeader("NonExistent"), "");
|
||||
}
|
||||
|
||||
TEST_F(HTTPContextTest, SetHeaderMultipleHeaders) {
|
||||
context.SetHeader("Authorization", "Bearer token123");
|
||||
context.SetHeader("Cookie", "session=xyz");
|
||||
context.SetHeader("User-Agent", "TestClient/1.0");
|
||||
|
||||
EXPECT_EQ(context.GetHeader("authorization"), "Bearer token123");
|
||||
EXPECT_EQ(context.GetHeader("cookie"), "session=xyz");
|
||||
EXPECT_EQ(context.GetHeader("user-agent"), "TestClient/1.0");
|
||||
}
|
||||
|
||||
TEST_F(HTTPContextTest, AuthenticationFields) {
|
||||
context.isAuthenticated = true;
|
||||
context.authenticatedUser = "testuser";
|
||||
context.gmLevel = 5;
|
||||
|
||||
EXPECT_TRUE(context.isAuthenticated);
|
||||
EXPECT_EQ(context.authenticatedUser, "testuser");
|
||||
EXPECT_EQ(context.gmLevel, 5);
|
||||
}
|
||||
|
||||
TEST_F(HTTPContextTest, UserDataMap) {
|
||||
context.userData["key1"] = "value1";
|
||||
context.userData["key2"] = "value2";
|
||||
|
||||
EXPECT_EQ(context.userData["key1"], "value1");
|
||||
EXPECT_EQ(context.userData["key2"], "value2");
|
||||
EXPECT_TRUE(context.userData.count("key1") > 0);
|
||||
}
|
||||
|
||||
// Test token extraction utilities
|
||||
namespace TokenExtraction {
|
||||
static std::string ExtractTokenFromQueryString(const std::string& queryString) {
|
||||
if (queryString.empty()) {
|
||||
return "";
|
||||
}
|
||||
std::string tokenPrefix = "token=";
|
||||
size_t tokenPos = queryString.find(tokenPrefix);
|
||||
|
||||
if (tokenPos == std::string::npos) {
|
||||
return "";
|
||||
}
|
||||
|
||||
size_t valueStart = tokenPos + tokenPrefix.length();
|
||||
size_t valueEnd = queryString.find("&", valueStart);
|
||||
|
||||
if (valueEnd == std::string::npos) {
|
||||
valueEnd = queryString.length();
|
||||
}
|
||||
|
||||
return queryString.substr(valueStart, valueEnd - valueStart);
|
||||
}
|
||||
|
||||
static std::string ExtractTokenFromAuthHeader(const std::string& authHeader) {
|
||||
if (authHeader.empty()) {
|
||||
return "";
|
||||
}
|
||||
|
||||
if (authHeader.substr(0, 7) == "Bearer ") {
|
||||
return authHeader.substr(7);
|
||||
}
|
||||
|
||||
if (authHeader.substr(0, 6) == "Token ") {
|
||||
return authHeader.substr(6);
|
||||
}
|
||||
|
||||
return authHeader;
|
||||
}
|
||||
}
|
||||
|
||||
// Test token extraction utilities
|
||||
class TokenExtractionTest : public ::testing::Test {
|
||||
};
|
||||
|
||||
TEST_F(TokenExtractionTest, ExtractFromQueryString) {
|
||||
std::string query = "token=mytoken123&other=value";
|
||||
std::string token = TokenExtraction::ExtractTokenFromQueryString(query);
|
||||
EXPECT_EQ(token, "mytoken123");
|
||||
}
|
||||
|
||||
TEST_F(TokenExtractionTest, ExtractFromQueryStringWithNoOtherParams) {
|
||||
std::string query = "token=simpletoken";
|
||||
std::string token = TokenExtraction::ExtractTokenFromQueryString(query);
|
||||
EXPECT_EQ(token, "simpletoken");
|
||||
}
|
||||
|
||||
TEST_F(TokenExtractionTest, NoTokenInQueryString) {
|
||||
std::string query = "other=value¶m=test";
|
||||
std::string token = TokenExtraction::ExtractTokenFromQueryString(query);
|
||||
EXPECT_EQ(token, "");
|
||||
}
|
||||
|
||||
TEST_F(TokenExtractionTest, ExtractFromBearerHeader) {
|
||||
std::string header = "Bearer eyJhbGciOiJIUzI1NiJ9";
|
||||
std::string token = TokenExtraction::ExtractTokenFromAuthHeader(header);
|
||||
EXPECT_EQ(token, "eyJhbGciOiJIUzI1NiJ9");
|
||||
}
|
||||
|
||||
TEST_F(TokenExtractionTest, ExtractFromTokenHeader) {
|
||||
std::string header = "Token abc123xyz";
|
||||
std::string token = TokenExtraction::ExtractTokenFromAuthHeader(header);
|
||||
EXPECT_EQ(token, "abc123xyz");
|
||||
}
|
||||
|
||||
TEST_F(TokenExtractionTest, ExtractRawTokenFromHeader) {
|
||||
std::string header = "rawtoken123";
|
||||
std::string token = TokenExtraction::ExtractTokenFromAuthHeader(header);
|
||||
EXPECT_EQ(token, "rawtoken123");
|
||||
}
|
||||
|
||||
// Test HTTPContext population scenarios
|
||||
class HTTPContextPopulationTest : public ::testing::Test {
|
||||
protected:
|
||||
HTTPContext context;
|
||||
};
|
||||
|
||||
TEST_F(HTTPContextPopulationTest, PopulateFromRequest) {
|
||||
context.method = "POST";
|
||||
context.path = "/api/auth/login";
|
||||
context.queryString = "token=abc123";
|
||||
context.body = "{\"username\":\"test\"}";
|
||||
context.clientIP = "192.168.1.100";
|
||||
|
||||
EXPECT_EQ(context.method, "POST");
|
||||
EXPECT_EQ(context.path, "/api/auth/login");
|
||||
EXPECT_EQ(context.queryString, "token=abc123");
|
||||
EXPECT_EQ(context.body, "{\"username\":\"test\"}");
|
||||
EXPECT_EQ(context.clientIP, "192.168.1.100");
|
||||
}
|
||||
|
||||
TEST_F(HTTPContextPopulationTest, MultipleHeadersWithMixedCase) {
|
||||
context.SetHeader("Content-Type", "application/json");
|
||||
context.SetHeader("Authorization", "Bearer token");
|
||||
context.SetHeader("Accept", "application/json");
|
||||
context.SetHeader("User-Agent", "TestClient");
|
||||
|
||||
// Verify all headers are accessible case-insensitively
|
||||
EXPECT_EQ(context.GetHeader("content-type"), "application/json");
|
||||
EXPECT_EQ(context.GetHeader("AUTHORIZATION"), "Bearer token");
|
||||
EXPECT_EQ(context.GetHeader("accept"), "application/json");
|
||||
EXPECT_EQ(context.GetHeader("USER-AGENT"), "TestClient");
|
||||
}
|
||||
|
||||
// Integration tests for middleware chains
|
||||
class MiddlewareAuthenticationFlow : public ::testing::Test {
|
||||
protected:
|
||||
HTTPContext context;
|
||||
HTTPReply reply;
|
||||
|
||||
void SetUp() override {
|
||||
reply.status = eHTTPStatusCode::OK;
|
||||
reply.contentType = eContentType::APPLICATION_JSON;
|
||||
context.path = "/api/test";
|
||||
context.clientIP = "127.0.0.1";
|
||||
context.method = "GET";
|
||||
}
|
||||
|
||||
void SimulateTokenVerification(const std::string& token) {
|
||||
std::string username;
|
||||
uint8_t gmLevel;
|
||||
if (VerifyTokenMock(token, username, gmLevel)) {
|
||||
context.isAuthenticated = true;
|
||||
context.authenticatedUser = username;
|
||||
context.gmLevel = gmLevel;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
TEST_F(MiddlewareAuthenticationFlow, SuccessfulAuthenticationWithQueryToken) {
|
||||
context.queryString = "token=valid_token123";
|
||||
|
||||
// Extract token
|
||||
std::string token = TokenExtraction::ExtractTokenFromQueryString(context.queryString);
|
||||
EXPECT_EQ(token, "valid_token123");
|
||||
|
||||
// Verify token
|
||||
SimulateTokenVerification(token);
|
||||
|
||||
EXPECT_TRUE(context.isAuthenticated);
|
||||
EXPECT_EQ(context.authenticatedUser, "testuser");
|
||||
EXPECT_EQ(context.gmLevel, 1);
|
||||
}
|
||||
|
||||
TEST_F(MiddlewareAuthenticationFlow, SuccessfulAuthenticationWithBearerToken) {
|
||||
context.SetHeader("Authorization", "Bearer admin_token");
|
||||
|
||||
// Extract token
|
||||
std::string authHeader = context.GetHeader("Authorization");
|
||||
std::string token = TokenExtraction::ExtractTokenFromAuthHeader(authHeader);
|
||||
EXPECT_EQ(token, "admin_token");
|
||||
|
||||
// Verify token
|
||||
SimulateTokenVerification(token);
|
||||
|
||||
EXPECT_TRUE(context.isAuthenticated);
|
||||
EXPECT_EQ(context.authenticatedUser, "admin");
|
||||
EXPECT_EQ(context.gmLevel, 9);
|
||||
}
|
||||
|
||||
TEST_F(MiddlewareAuthenticationFlow, FailedAuthenticationInvalidToken) {
|
||||
context.queryString = "token=invalid_token";
|
||||
|
||||
// Extract token
|
||||
std::string token = TokenExtraction::ExtractTokenFromQueryString(context.queryString);
|
||||
EXPECT_EQ(token, "invalid_token");
|
||||
|
||||
// Verify token
|
||||
SimulateTokenVerification(token);
|
||||
|
||||
EXPECT_FALSE(context.isAuthenticated);
|
||||
EXPECT_EQ(context.authenticatedUser, "");
|
||||
EXPECT_EQ(context.gmLevel, 0);
|
||||
}
|
||||
|
||||
TEST_F(MiddlewareAuthenticationFlow, NoTokenProvided) {
|
||||
context.queryString = "";
|
||||
|
||||
// Extract token (none provided)
|
||||
std::string token = TokenExtraction::ExtractTokenFromQueryString(context.queryString);
|
||||
EXPECT_EQ(token, "");
|
||||
|
||||
// Should remain unauthenticated
|
||||
EXPECT_FALSE(context.isAuthenticated);
|
||||
EXPECT_EQ(context.authenticatedUser, "");
|
||||
EXPECT_EQ(context.gmLevel, 0);
|
||||
}
|
||||
|
||||
// Test authorization level checking
|
||||
class AuthorizationLevelTest : public ::testing::Test {
|
||||
protected:
|
||||
uint8_t CheckMinimumGMLevel(uint8_t userLevel, uint8_t requiredLevel) {
|
||||
return userLevel >= requiredLevel ? 1 : 0; // 1 = allowed, 0 = forbidden
|
||||
}
|
||||
};
|
||||
|
||||
TEST_F(AuthorizationLevelTest, UserCanAccessWithSufficientLevel) {
|
||||
EXPECT_EQ(CheckMinimumGMLevel(9, 5), 1); // Admin (9) can access level 5
|
||||
EXPECT_EQ(CheckMinimumGMLevel(5, 5), 1); // Level 5 can access level 5
|
||||
EXPECT_EQ(CheckMinimumGMLevel(0, 0), 1); // Level 0 can access level 0
|
||||
}
|
||||
|
||||
TEST_F(AuthorizationLevelTest, UserCannotAccessWithInsufficientLevel) {
|
||||
EXPECT_EQ(CheckMinimumGMLevel(2, 5), 0); // Level 2 cannot access level 5
|
||||
EXPECT_EQ(CheckMinimumGMLevel(0, 1), 0); // Level 0 cannot access level 1
|
||||
EXPECT_EQ(CheckMinimumGMLevel(3, 9), 0); // Level 3 cannot access admin (9)
|
||||
}
|
||||
|
||||
// Test error response formatting
|
||||
class ErrorResponseTest : public ::testing::Test {
|
||||
protected:
|
||||
HTTPReply reply;
|
||||
};
|
||||
|
||||
TEST_F(ErrorResponseTest, UnauthorizedResponse) {
|
||||
reply.status = eHTTPStatusCode::UNAUTHORIZED;
|
||||
reply.message = "{\"error\":\"Unauthorized - Authentication required\"}";
|
||||
reply.contentType = eContentType::APPLICATION_JSON;
|
||||
|
||||
EXPECT_EQ(reply.status, eHTTPStatusCode::UNAUTHORIZED);
|
||||
EXPECT_NE(reply.message.find("Unauthorized"), std::string::npos);
|
||||
EXPECT_EQ(reply.contentType, eContentType::APPLICATION_JSON);
|
||||
}
|
||||
|
||||
TEST_F(ErrorResponseTest, ForbiddenResponse) {
|
||||
reply.status = eHTTPStatusCode::FORBIDDEN;
|
||||
reply.message = "{\"error\":\"Forbidden - Insufficient permissions\"}";
|
||||
reply.contentType = eContentType::APPLICATION_JSON;
|
||||
|
||||
EXPECT_EQ(reply.status, eHTTPStatusCode::FORBIDDEN);
|
||||
EXPECT_NE(reply.message.find("Forbidden"), std::string::npos);
|
||||
EXPECT_EQ(reply.contentType, eContentType::APPLICATION_JSON);
|
||||
}
|
||||
|
||||
TEST_F(ErrorResponseTest, OkResponse) {
|
||||
reply.status = eHTTPStatusCode::OK;
|
||||
reply.message = "{\"status\":\"success\",\"data\":{}}";
|
||||
reply.contentType = eContentType::APPLICATION_JSON;
|
||||
|
||||
EXPECT_EQ(reply.status, eHTTPStatusCode::OK);
|
||||
EXPECT_EQ(reply.contentType, eContentType::APPLICATION_JSON);
|
||||
}
|
||||
475
tests/dWebTests/RouteIntegrationTests.cpp
Normal file
475
tests/dWebTests/RouteIntegrationTests.cpp
Normal file
@@ -0,0 +1,475 @@
|
||||
#include <gtest/gtest.h>
|
||||
#include <memory>
|
||||
#include <sstream>
|
||||
#include "HTTPContext.h"
|
||||
#include "Web.h"
|
||||
#include "AuthMiddleware.h"
|
||||
#include "RequireAuthMiddleware.h"
|
||||
|
||||
/**
|
||||
* Route Integration Tests
|
||||
*
|
||||
* These tests verify the actual route handlers work correctly with middleware chains.
|
||||
* Unlike MiddlewareTests.cpp which uses mocks, these tests use real middleware
|
||||
* to verify the complete authentication and authorization flow.
|
||||
*/
|
||||
|
||||
// Mock DashboardAuthService for testing
|
||||
namespace {
|
||||
class MockDashboardAuthService {
|
||||
public:
|
||||
static bool VerifyToken(const std::string& token, std::string& outUsername, uint8_t& outGmLevel) {
|
||||
// Test tokens with predictable results
|
||||
if (token == "valid_user_token") {
|
||||
outUsername = "testuser";
|
||||
outGmLevel = 0; // Regular user
|
||||
return true;
|
||||
}
|
||||
if (token == "admin_token") {
|
||||
outUsername = "admin";
|
||||
outGmLevel = 9; // Admin
|
||||
return true;
|
||||
}
|
||||
if (token == "moderator_token") {
|
||||
outUsername = "moderator";
|
||||
outGmLevel = 5; // Moderator
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
static bool HasDashboardAccess(uint8_t gmLevel) {
|
||||
return gmLevel > 0;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// Test fixture for route handlers
|
||||
class RouteHandlerTest : public ::testing::Test {
|
||||
protected:
|
||||
HTTPContext context;
|
||||
HTTPReply reply;
|
||||
|
||||
void SetUp() override {
|
||||
reply.status = eHTTPStatusCode::OK;
|
||||
reply.contentType = eContentType::APPLICATION_JSON;
|
||||
reply.message = "";
|
||||
}
|
||||
|
||||
// Simulate a route handler for /api/status
|
||||
void HandleStatusRoute(HTTPReply& out, const HTTPContext& in) {
|
||||
out.status = eHTTPStatusCode::OK;
|
||||
out.contentType = eContentType::APPLICATION_JSON;
|
||||
out.message = R"({"status":"running","version":"1.0.0"})";
|
||||
}
|
||||
|
||||
// Simulate a route handler for /api/players
|
||||
void HandlePlayersRoute(HTTPReply& out, const HTTPContext& in) {
|
||||
out.status = eHTTPStatusCode::OK;
|
||||
out.contentType = eContentType::APPLICATION_JSON;
|
||||
out.message = R"({"players":[{"id":1,"name":"Player1"},{"id":2,"name":"Player2"}]})";
|
||||
}
|
||||
|
||||
// Simulate a route handler for /api/accounts/count
|
||||
void HandleAccountsCountRoute(HTTPReply& out, const HTTPContext& in) {
|
||||
out.status = eHTTPStatusCode::OK;
|
||||
out.contentType = eContentType::APPLICATION_JSON;
|
||||
out.message = R"({"count":42})";
|
||||
}
|
||||
|
||||
// Simulate a route handler for /api/characters/count
|
||||
void HandleCharactersCountRoute(HTTPReply& out, const HTTPContext& in) {
|
||||
out.status = eHTTPStatusCode::OK;
|
||||
out.contentType = eContentType::APPLICATION_JSON;
|
||||
out.message = R"({"count":128})";
|
||||
}
|
||||
};
|
||||
|
||||
// Test protected API routes with authentication
|
||||
class ProtectedAPIRouteTest : public RouteHandlerTest {
|
||||
protected:
|
||||
void ProcessMiddlewareChain(std::vector<std::shared_ptr<IHTTPMiddleware>>& middlewares, HTTPContext& ctx) {
|
||||
for (const auto& middleware : middlewares) {
|
||||
if (!middleware->Process(ctx, reply)) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
TEST_F(ProtectedAPIRouteTest, StatusRouteRequiresAuthentication) {
|
||||
// Create middleware chain for protected route
|
||||
std::vector<std::shared_ptr<IHTTPMiddleware>> middlewares;
|
||||
|
||||
// Simulate AuthMiddleware (always passes, extracts token if available)
|
||||
context.path = "/api/status";
|
||||
context.queryString = ""; // No token
|
||||
context.method = "GET";
|
||||
|
||||
// Without authentication
|
||||
std::string username;
|
||||
uint8_t gmLevel{};
|
||||
|
||||
EXPECT_FALSE(context.isAuthenticated);
|
||||
EXPECT_EQ(context.gmLevel, 0);
|
||||
|
||||
// Now test with token
|
||||
context.queryString = "token=valid_user_token";
|
||||
|
||||
// Extract and verify token (simulating AuthMiddleware)
|
||||
std::string token = "valid_user_token";
|
||||
if (MockDashboardAuthService::VerifyToken(token, username, gmLevel)) {
|
||||
context.isAuthenticated = true;
|
||||
context.authenticatedUser = username;
|
||||
context.gmLevel = gmLevel;
|
||||
}
|
||||
|
||||
EXPECT_TRUE(context.isAuthenticated);
|
||||
EXPECT_EQ(context.authenticatedUser, "testuser");
|
||||
EXPECT_EQ(context.gmLevel, 0);
|
||||
}
|
||||
|
||||
TEST_F(ProtectedAPIRouteTest, PlayersRouteWithValidAuth) {
|
||||
context.path = "/api/players";
|
||||
context.method = "GET";
|
||||
|
||||
// Simulate token verification
|
||||
std::string username;
|
||||
uint8_t gmLevel{};
|
||||
std::string token = "admin_token";
|
||||
|
||||
if (MockDashboardAuthService::VerifyToken(token, username, gmLevel)) {
|
||||
context.isAuthenticated = true;
|
||||
context.authenticatedUser = username;
|
||||
context.gmLevel = gmLevel;
|
||||
}
|
||||
|
||||
// Check authentication
|
||||
EXPECT_TRUE(context.isAuthenticated);
|
||||
EXPECT_EQ(context.gmLevel, 9);
|
||||
|
||||
// Call handler
|
||||
HandlePlayersRoute(reply, context);
|
||||
|
||||
// Verify response
|
||||
EXPECT_EQ(reply.status, eHTTPStatusCode::OK);
|
||||
EXPECT_NE(reply.message.find("players"), std::string::npos);
|
||||
}
|
||||
|
||||
TEST_F(ProtectedAPIRouteTest, AccountsCountRouteRequiresLevel0) {
|
||||
context.path = "/api/accounts/count";
|
||||
context.method = "POST";
|
||||
|
||||
// Test with level 0 user (should pass)
|
||||
std::string username;
|
||||
uint8_t gmLevel{};
|
||||
std::string token = "valid_user_token";
|
||||
|
||||
if (MockDashboardAuthService::VerifyToken(token, username, gmLevel)) {
|
||||
context.isAuthenticated = true;
|
||||
context.authenticatedUser = username;
|
||||
context.gmLevel = gmLevel;
|
||||
}
|
||||
|
||||
EXPECT_TRUE(context.isAuthenticated);
|
||||
EXPECT_GE(context.gmLevel, 0); // Meets requirement
|
||||
|
||||
HandleAccountsCountRoute(reply, context);
|
||||
EXPECT_EQ(reply.status, eHTTPStatusCode::OK);
|
||||
}
|
||||
|
||||
TEST_F(ProtectedAPIRouteTest, CharactersCountRouteWithModerator) {
|
||||
context.path = "/api/characters/count";
|
||||
context.method = "POST";
|
||||
|
||||
// Test with moderator token
|
||||
std::string username;
|
||||
uint8_t gmLevel{};
|
||||
std::string token = "moderator_token";
|
||||
|
||||
if (MockDashboardAuthService::VerifyToken(token, username, gmLevel)) {
|
||||
context.isAuthenticated = true;
|
||||
context.authenticatedUser = username;
|
||||
context.gmLevel = gmLevel;
|
||||
}
|
||||
|
||||
EXPECT_TRUE(context.isAuthenticated);
|
||||
EXPECT_EQ(context.gmLevel, 5);
|
||||
|
||||
HandleCharactersCountRoute(reply, context);
|
||||
EXPECT_EQ(reply.status, eHTTPStatusCode::OK);
|
||||
EXPECT_NE(reply.message.find("count"), std::string::npos);
|
||||
}
|
||||
|
||||
// Test authentication failures
|
||||
class AuthenticationFailureTest : public RouteHandlerTest {
|
||||
};
|
||||
|
||||
TEST_F(AuthenticationFailureTest, InvalidTokenRejected) {
|
||||
context.path = "/api/status";
|
||||
context.method = "GET";
|
||||
|
||||
std::string username;
|
||||
uint8_t gmLevel{};
|
||||
std::string token = "invalid_token";
|
||||
|
||||
// Should fail
|
||||
EXPECT_FALSE(MockDashboardAuthService::VerifyToken(token, username, gmLevel));
|
||||
EXPECT_FALSE(context.isAuthenticated);
|
||||
EXPECT_EQ(context.gmLevel, 0);
|
||||
}
|
||||
|
||||
TEST_F(AuthenticationFailureTest, ExpiredTokenRejected) {
|
||||
context.path = "/api/players";
|
||||
context.method = "GET";
|
||||
|
||||
std::string username;
|
||||
uint8_t gmLevel{};
|
||||
std::string token = "expired_token";
|
||||
|
||||
// Should fail
|
||||
EXPECT_FALSE(MockDashboardAuthService::VerifyToken(token, username, gmLevel));
|
||||
EXPECT_EQ(gmLevel, 0);
|
||||
}
|
||||
|
||||
TEST_F(AuthenticationFailureTest, MissingTokenRejectsProtectedRoute) {
|
||||
context.path = "/api/status";
|
||||
context.method = "GET";
|
||||
context.queryString = ""; // No token
|
||||
context.isAuthenticated = false;
|
||||
context.gmLevel = 0;
|
||||
|
||||
EXPECT_FALSE(context.isAuthenticated);
|
||||
EXPECT_EQ(context.gmLevel, 0);
|
||||
|
||||
// Route should return 401
|
||||
reply.status = eHTTPStatusCode::UNAUTHORIZED;
|
||||
reply.message = "{\"error\":\"Authentication required\"}";
|
||||
|
||||
EXPECT_EQ(reply.status, eHTTPStatusCode::UNAUTHORIZED);
|
||||
EXPECT_NE(reply.message.find("Authentication required"), std::string::npos);
|
||||
}
|
||||
|
||||
// Test authorization level checking
|
||||
class AuthorizationLevelTest : public RouteHandlerTest {
|
||||
protected:
|
||||
bool CheckAuthorizationLevel(uint8_t userLevel, uint8_t requiredLevel) {
|
||||
return userLevel >= requiredLevel;
|
||||
}
|
||||
};
|
||||
|
||||
TEST_F(AuthorizationLevelTest, Level0UserAccessLevel0Route) {
|
||||
context.gmLevel = 0;
|
||||
EXPECT_TRUE(CheckAuthorizationLevel(context.gmLevel, 0));
|
||||
}
|
||||
|
||||
TEST_F(AuthorizationLevelTest, Level9AdminAccessAnyRoute) {
|
||||
context.gmLevel = 9;
|
||||
EXPECT_TRUE(CheckAuthorizationLevel(context.gmLevel, 0));
|
||||
EXPECT_TRUE(CheckAuthorizationLevel(context.gmLevel, 5));
|
||||
EXPECT_TRUE(CheckAuthorizationLevel(context.gmLevel, 9));
|
||||
}
|
||||
|
||||
TEST_F(AuthorizationLevelTest, Level1CannotAccessLevel5Route) {
|
||||
context.gmLevel = 1;
|
||||
EXPECT_FALSE(CheckAuthorizationLevel(context.gmLevel, 5));
|
||||
}
|
||||
|
||||
TEST_F(AuthorizationLevelTest, InsufficientLevelReturns403) {
|
||||
context.gmLevel = 0;
|
||||
|
||||
if (!CheckAuthorizationLevel(context.gmLevel, 5)) {
|
||||
reply.status = eHTTPStatusCode::FORBIDDEN;
|
||||
reply.message = "{\"error\":\"Insufficient permissions\"}";
|
||||
}
|
||||
|
||||
EXPECT_EQ(reply.status, eHTTPStatusCode::FORBIDDEN);
|
||||
EXPECT_NE(reply.message.find("Insufficient permissions"), std::string::npos);
|
||||
}
|
||||
|
||||
// Test token extraction from different sources
|
||||
class TokenSourceTest : public RouteHandlerTest {
|
||||
protected:
|
||||
std::string ExtractTokenFromQuery(const std::string& queryString) {
|
||||
if (queryString.empty()) return "";
|
||||
size_t pos = queryString.find("token=");
|
||||
if (pos == std::string::npos) return "";
|
||||
size_t start = pos + 6;
|
||||
size_t end = queryString.find("&", start);
|
||||
if (end == std::string::npos) end = queryString.length();
|
||||
return queryString.substr(start, end - start);
|
||||
}
|
||||
|
||||
std::string ExtractTokenFromHeader(const std::string& authHeader) {
|
||||
if (authHeader.empty()) return "";
|
||||
if (authHeader.substr(0, 7) == "Bearer ") return authHeader.substr(7);
|
||||
if (authHeader.substr(0, 6) == "Token ") return authHeader.substr(6);
|
||||
return authHeader;
|
||||
}
|
||||
|
||||
std::string ExtractTokenFromCookie(const std::string& cookieHeader) {
|
||||
if (cookieHeader.empty()) return "";
|
||||
size_t pos = cookieHeader.find("dashboardToken=");
|
||||
if (pos == std::string::npos) return "";
|
||||
size_t start = pos + 15;
|
||||
size_t end = cookieHeader.find(";", start);
|
||||
if (end == std::string::npos) end = cookieHeader.length();
|
||||
return cookieHeader.substr(start, end - start);
|
||||
}
|
||||
};
|
||||
|
||||
TEST_F(TokenSourceTest, ExtractFromQueryString) {
|
||||
context.queryString = "token=valid_user_token&other=param";
|
||||
std::string token = ExtractTokenFromQuery(context.queryString);
|
||||
EXPECT_EQ(token, "valid_user_token");
|
||||
}
|
||||
|
||||
TEST_F(TokenSourceTest, ExtractFromAuthorizationHeader) {
|
||||
context.SetHeader("Authorization", "Bearer admin_token");
|
||||
std::string authHeader = context.GetHeader("Authorization");
|
||||
std::string token = ExtractTokenFromHeader(authHeader);
|
||||
EXPECT_EQ(token, "admin_token");
|
||||
}
|
||||
|
||||
TEST_F(TokenSourceTest, ExtractFromCookie) {
|
||||
context.SetHeader("Cookie", "dashboardToken=moderator_token; Path=/");
|
||||
std::string cookieHeader = context.GetHeader("Cookie");
|
||||
std::string token = ExtractTokenFromCookie(cookieHeader);
|
||||
EXPECT_EQ(token, "moderator_token");
|
||||
}
|
||||
|
||||
// Test response formatting
|
||||
class ResponseFormattingTest : public RouteHandlerTest {
|
||||
};
|
||||
|
||||
TEST_F(ResponseFormattingTest, SuccessResponseFormat) {
|
||||
reply.status = eHTTPStatusCode::OK;
|
||||
reply.contentType = eContentType::APPLICATION_JSON;
|
||||
reply.message = R"({"status":"success","data":{}})";
|
||||
|
||||
EXPECT_EQ(reply.status, eHTTPStatusCode::OK);
|
||||
EXPECT_EQ(reply.contentType, eContentType::APPLICATION_JSON);
|
||||
EXPECT_NE(reply.message.find("success"), std::string::npos);
|
||||
}
|
||||
|
||||
TEST_F(ResponseFormattingTest, UnauthorizedResponseFormat) {
|
||||
reply.status = eHTTPStatusCode::UNAUTHORIZED;
|
||||
reply.contentType = eContentType::APPLICATION_JSON;
|
||||
reply.message = R"({"error":"Unauthorized","code":401})";
|
||||
|
||||
EXPECT_EQ(reply.status, eHTTPStatusCode::UNAUTHORIZED);
|
||||
EXPECT_NE(reply.message.find("error"), std::string::npos);
|
||||
EXPECT_NE(reply.message.find("401"), std::string::npos);
|
||||
}
|
||||
|
||||
TEST_F(ResponseFormattingTest, ForbiddenResponseFormat) {
|
||||
reply.status = eHTTPStatusCode::FORBIDDEN;
|
||||
reply.contentType = eContentType::APPLICATION_JSON;
|
||||
reply.message = R"({"error":"Forbidden","code":403})";
|
||||
|
||||
EXPECT_EQ(reply.status, eHTTPStatusCode::FORBIDDEN);
|
||||
EXPECT_NE(reply.message.find("error"), std::string::npos);
|
||||
EXPECT_NE(reply.message.find("403"), std::string::npos);
|
||||
}
|
||||
|
||||
// Integration test: Full request flow
|
||||
class FullRequestFlowTest : public RouteHandlerTest {
|
||||
protected:
|
||||
struct RequestFlow {
|
||||
std::string method;
|
||||
std::string path;
|
||||
std::string token;
|
||||
uint8_t requiredLevel;
|
||||
bool shouldSucceed;
|
||||
};
|
||||
|
||||
eHTTPStatusCode ProcessRequest(const RequestFlow& flow) {
|
||||
// Step 1: Set request context
|
||||
context.method = flow.method;
|
||||
context.path = flow.path;
|
||||
context.queryString = flow.token.empty() ? "" : ("token=" + flow.token);
|
||||
|
||||
// Step 2: Try to verify token
|
||||
if (!flow.token.empty()) {
|
||||
std::string username;
|
||||
uint8_t gmLevel{};
|
||||
if (MockDashboardAuthService::VerifyToken(flow.token, username, gmLevel)) {
|
||||
context.isAuthenticated = true;
|
||||
context.authenticatedUser = username;
|
||||
context.gmLevel = gmLevel;
|
||||
}
|
||||
}
|
||||
|
||||
// Step 3: Check authorization
|
||||
if (context.isAuthenticated && context.gmLevel >= flow.requiredLevel) {
|
||||
// Call handler
|
||||
if (flow.path == "/api/status") {
|
||||
HandleStatusRoute(reply, context);
|
||||
} else if (flow.path == "/api/players") {
|
||||
HandlePlayersRoute(reply, context);
|
||||
}
|
||||
return reply.status;
|
||||
} else if (!context.isAuthenticated) {
|
||||
return eHTTPStatusCode::UNAUTHORIZED;
|
||||
} else {
|
||||
return eHTTPStatusCode::FORBIDDEN;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
TEST_F(FullRequestFlowTest, ValidUserAccessesPublicAPI) {
|
||||
RequestFlow flow{
|
||||
.method = "GET",
|
||||
.path = "/api/status",
|
||||
.token = "valid_user_token",
|
||||
.requiredLevel = 0,
|
||||
.shouldSucceed = true
|
||||
};
|
||||
|
||||
eHTTPStatusCode result = ProcessRequest(flow);
|
||||
EXPECT_EQ(result, eHTTPStatusCode::OK);
|
||||
EXPECT_TRUE(context.isAuthenticated);
|
||||
}
|
||||
|
||||
TEST_F(FullRequestFlowTest, AdminAccessesProtectedAPI) {
|
||||
RequestFlow flow{
|
||||
.method = "GET",
|
||||
.path = "/api/players",
|
||||
.token = "admin_token",
|
||||
.requiredLevel = 0,
|
||||
.shouldSucceed = true
|
||||
};
|
||||
|
||||
eHTTPStatusCode result = ProcessRequest(flow);
|
||||
EXPECT_EQ(result, eHTTPStatusCode::OK);
|
||||
EXPECT_TRUE(context.isAuthenticated);
|
||||
EXPECT_EQ(context.gmLevel, 9);
|
||||
}
|
||||
|
||||
TEST_F(FullRequestFlowTest, NoTokenReturnsUnauthorized) {
|
||||
RequestFlow flow{
|
||||
.method = "GET",
|
||||
.path = "/api/status",
|
||||
.token = "",
|
||||
.requiredLevel = 0,
|
||||
.shouldSucceed = false
|
||||
};
|
||||
|
||||
eHTTPStatusCode result = ProcessRequest(flow);
|
||||
EXPECT_EQ(result, eHTTPStatusCode::UNAUTHORIZED);
|
||||
EXPECT_FALSE(context.isAuthenticated);
|
||||
}
|
||||
|
||||
TEST_F(FullRequestFlowTest, InvalidTokenReturnsUnauthorized) {
|
||||
RequestFlow flow{
|
||||
.method = "GET",
|
||||
.path = "/api/players",
|
||||
.token = "invalid_token",
|
||||
.requiredLevel = 0,
|
||||
.shouldSucceed = false
|
||||
};
|
||||
|
||||
eHTTPStatusCode result = ProcessRequest(flow);
|
||||
EXPECT_EQ(result, eHTTPStatusCode::UNAUTHORIZED);
|
||||
EXPECT_FALSE(context.isAuthenticated);
|
||||
}
|
||||
2937
thirdparty/inja.hpp
vendored
Normal file
2937
thirdparty/inja.hpp
vendored
Normal file
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user