mirror of
https://github.com/DarkflameUniverse/DarkflameServer.git
synced 2026-03-23 08:56:59 +00:00
WIP: basic server, no features
This commit is contained in:
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>
|
||||
Reference in New Issue
Block a user