feat: web dashboard and playground work

The NexusDashboard-parity dashboard (dDashboardServer) and everything built on it on the experimental branch:
accounts, characters, properties and moderation tools, permissions shared with in-game slash commands, economy
reports, World 3D and property 3D views with client scenery, scheduled events (features, vanity changes, live
events, announcements, restarts), vanity files and events, the CDClient browser, the message inspector with saved
captures, chat filter tools, community challenges, live ops, the AI moderator helper, and the server-side changes
they need.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Aaron Kimbrell
2026-09-26 17:52:10 -05:00
parent 854d02f509
commit e213a7aeff
681 changed files with 88310 additions and 1321 deletions

View File

@@ -1,9 +1,11 @@
#include "MySQLDatabase.h"
#include "GeneralUtils.h"
#include "eGameMasterLevel.h"
#include "json.hpp"
std::optional<IAccounts::Info> MySQLDatabase::GetAccountInfo(const std::string_view username) {
auto result = ExecuteSelect("SELECT id, password, banned, locked, play_key_id, gm_level, mute_expire FROM accounts WHERE name = ? LIMIT 1;", username);
auto result = ExecuteSelect("SELECT id, password, banned, locked, play_key_id, gm_level, mute_expire, ban_expires, ban_reason FROM accounts WHERE name = ? LIMIT 1;", username);
if (!result->next()) {
return std::nullopt;
@@ -17,6 +19,8 @@ std::optional<IAccounts::Info> MySQLDatabase::GetAccountInfo(const std::string_v
toReturn.locked = result->getBoolean("locked");
toReturn.playKeyId = result->getUInt("play_key_id");
toReturn.muteExpire = result->getUInt64("mute_expire");
toReturn.banExpires = result->getInt64("ban_expires");
toReturn.banReason = result->isNull("ban_reason") ? "" : result->getString("ban_reason").c_str();
return toReturn;
}
@@ -33,8 +37,8 @@ void MySQLDatabase::UpdateAccountPassword(const uint32_t accountId, const std::s
ExecuteUpdate("UPDATE accounts SET password = ? WHERE id = ?;", bcryptpassword, accountId);
}
void MySQLDatabase::InsertNewAccount(const std::string_view username, const std::string_view bcryptpassword) {
ExecuteInsert("INSERT INTO accounts (name, password, gm_level) VALUES (?, ?, ?);", username, bcryptpassword, static_cast<int32_t>(eGameMasterLevel::OPERATOR));
void MySQLDatabase::InsertNewAccount(const std::string_view username, const std::string_view bcryptpassword, const eGameMasterLevel gmLevel) {
ExecuteInsert("INSERT INTO accounts (name, password, gm_level) VALUES (?, ?, ?);", username, bcryptpassword, static_cast<int32_t>(gmLevel));
}
void MySQLDatabase::UpdateAccountGmLevel(const uint32_t accountId, const eGameMasterLevel gmLevel) {
@@ -45,3 +49,175 @@ 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(NULLIF(?, 0)), failed_attempts = 0 WHERE id = ?;", lockoutUntil, accountId);
}
bool MySQLDatabase::IsLockedOut(const uint32_t accountId) {
auto result = ExecuteSelect("SELECT 1 AS locked FROM accounts WHERE id = ? AND lockout_time IS NOT NULL AND lockout_time > NOW();", accountId);
return result->next();
}
uint32_t MySQLDatabase::CountActiveAccountsAtGmLevel(const uint8_t gmLevel, const uint32_t excludeAccountId) {
auto result = ExecuteSelect("SELECT COUNT(*) AS count FROM accounts WHERE gm_level = ? AND id != ? AND banned = 0 AND locked = 0;", static_cast<int32_t>(gmLevel), excludeAccountId);
return result->next() ? result->getUInt("count") : 0;
}
void MySQLDatabase::SetAccountLocked(const uint32_t accountId, const bool locked) {
ExecuteUpdate("UPDATE accounts SET locked = ? WHERE id = ?;", locked, accountId);
}
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");
}
nlohmann::json MySQLDatabase::GetAccountsTable(uint32_t start, uint32_t length, const std::string_view search, uint32_t orderColumn, bool orderAsc) {
// A number in the search box also matches IDs exactly (-1 never matches)
const int64_t searchId = GeneralUtils::TryParse<int64_t>(std::string(search)).value_or(-1);
// Build base query
std::string baseQuery = "SELECT id, name, banned, locked, gm_level, mute_expire, created_at FROM accounts";
std::string whereClause;
std::string orderClause;
// Add search filter if provided
if (!search.empty()) {
whereClause = " WHERE (name LIKE CONCAT('%', ?, '%') OR id = ? OR id IN (SELECT account_id FROM charinfo WHERE name LIKE CONCAT('%', ?, '%')))";
}
// Map column indices to database columns
std::string orderColumnName = "id";
switch (orderColumn) {
case 0: orderColumnName = "id"; break;
case 1: orderColumnName = "name"; break;
case 2: orderColumnName = "banned"; break;
case 3: orderColumnName = "locked"; break;
case 4: orderColumnName = "gm_level"; break;
case 5: orderColumnName = "mute_expire"; break;
case 6: orderColumnName = "created_at"; break;
default: orderColumnName = "id";
}
orderClause = " ORDER BY " + orderColumnName + (orderAsc ? " ASC" : " DESC");
// Build the main query
std::string mainQuery = baseQuery + whereClause + orderClause + " LIMIT ?, ?;";
// Get total count
std::string totalCountQuery = "SELECT COUNT(*) as count FROM accounts;";
auto totalCountResult = ExecuteSelect(totalCountQuery);
uint32_t totalRecords = totalCountResult->next() ? totalCountResult->getUInt("count") : 0;
// Get filtered count
uint32_t filteredRecords = totalRecords;
if (!search.empty()) {
std::string filteredCountQuery = "SELECT COUNT(*) as count FROM accounts WHERE (name LIKE CONCAT('%', ?, '%') OR id = ? OR id IN (SELECT account_id FROM charinfo WHERE name LIKE CONCAT('%', ?, '%')));";
auto filteredCountResult = ExecuteSelect(filteredCountQuery, search, searchId, search);
filteredRecords = filteredCountResult->next() ? filteredCountResult->getUInt("count") : 0;
}
// Execute main query
auto result = !search.empty()
? ExecuteSelect(mainQuery, search, searchId, search, start, length)
: ExecuteSelect(mainQuery, start, length);
// Build response JSON
nlohmann::json accountsArray = nlohmann::json::array();
while (result->next()) {
nlohmann::json account = {
{"id", result->getUInt("id")},
{"name", result->getString("name")},
{"banned", result->getBoolean("banned")},
{"locked", result->getBoolean("locked")},
{"gm_level", result->getInt("gm_level")},
{"mute_expire", result->getUInt64("mute_expire")},
{"created_at", result->getString("created_at")}
};
accountsArray.push_back(account);
}
nlohmann::json response = {
{"draw", 1},
{"recordsTotal", totalRecords},
{"recordsFiltered", filteredRecords},
{"data", accountsArray}
};
return response;
}
nlohmann::json MySQLDatabase::GetAccountById(uint32_t accountId) {
try {
const std::string query = "SELECT id, name, banned, locked, gm_level, mute_expire, created_at, ban_expires, ban_reason FROM accounts WHERE id = ?;";
auto result = ExecuteSelect(query, accountId);
if (!result->next()) {
return nlohmann::json{{"error", "Account not found"}};
}
nlohmann::json account = {
{"id", result->getUInt("id")},
{"name", result->getString("name")},
{"banned", result->getBoolean("banned")},
{"locked", result->getBoolean("locked")},
{"ban_expires", result->getInt64("ban_expires")},
{"ban_reason", result->isNull("ban_reason") ? std::string() : std::string(result->getString("ban_reason").c_str())},
{"gm_level", result->getInt("gm_level")},
{"mute_expire", result->getUInt64("mute_expire")},
{"created_at", result->getString("created_at")}
};
return account;
} catch (const sql::SQLException& e) {
LOG_DEBUG("SQL Error: %s", e.what());
return nlohmann::json{{"error", "Database error"}};
}
}
void MySQLDatabase::DeleteAccount(const uint32_t accountId) {
// All or nothing: a failure part way leaves the account as it was
DatabaseTransaction transaction(*this);
std::vector<LWOOBJID> characters;
{
auto result = ExecuteSelect("SELECT id FROM charinfo WHERE account_id = ?;", accountId);
while (result->next()) characters.push_back(result->getInt64("id"));
}
for (const auto characterId : characters) {
DeleteCharacter(characterId);
ExecuteDelete("DELETE FROM character_snapshots WHERE character_id = ?;", characterId);
}
// Rows about the account itself. Audit log, chat log and reports stay as the record of what happened.
for (const auto* table : { "account_tokens", "account_recovery_codes", "account_notes", "account_strikes", "account_login_addresses",
"dashboard_preferences", "accounts_rewardcodes", "player_cheat_detections" }) {
ExecuteDelete(std::string("DELETE FROM ") + table + " WHERE account_id = ?;", accountId);
}
ExecuteDelete("DELETE FROM accounts WHERE id = ?;", accountId);
transaction.Commit();
}
nlohmann::json MySQLDatabase::GetAccountCharacters(uint32_t accountId) {
auto result = ExecuteSelect("SELECT id, name, last_login FROM charinfo WHERE account_id = ? ORDER BY last_login DESC;", accountId);
nlohmann::json chars = nlohmann::json::array();
while (result->next()) {
chars.push_back({
{"id", std::to_string(result->getInt64("id"))},
{"name", result->getString("name")},
{"last_login", result->getUInt64("last_login")}
});
}
return chars;
}