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

@@ -0,0 +1,463 @@
#include "ClientAssets.h"
#include <algorithm>
#include <filesystem>
#include <fstream>
#include <map>
#include <set>
#include <unordered_map>
#include "RouteUtils.h"
#include "CDClientDatabase.h"
#include "Game.h"
#include "Logger.h"
#include "Locale.h"
#include "dConfig.h"
#include "eHTTPMethod.h"
#include "GeneralUtils.h"
#include "Process.h"
using namespace RouteUtils;
namespace {
constexpr uint32_t MAX_ITEMS_PER_REQUEST = 500;
constexpr size_t MAX_LISTING_ENTRIES = 2000;
std::map<LOT, nlohmann::json> g_ItemCache;
std::map<LOT, std::vector<int>> g_ItemSetsByLot; // lot -> set ids containing it
bool g_ItemSetsLoaded = false;
std::set<std::string> g_FailedConversions;
std::filesystem::path ClientRes() {
const auto client = Game::config->GetValue("client_location");
return client.empty() ? std::filesystem::path{} : std::filesystem::path(client) / "res";
}
// Lowercase, forward slashes, no leading "../" or "res/"
std::string NormalizeAssetPath(std::string path) {
std::replace(path.begin(), path.end(), '\\', '/');
std::transform(path.begin(), path.end(), path.begin(), ::tolower);
while (path.starts_with("../")) path = path.substr(3);
if (path.starts_with("res/")) path = path.substr(4);
while (path.starts_with("/")) path = path.substr(1);
return path;
}
/**
* Where a normalized (lowercase) res/ path is on disk. Unpacked clients keep their original mixed case, so on
* case-sensitive file systems each part is matched ignoring case.
*/
std::filesystem::path ResolveRes(const std::string& normalized) {
auto current = ClientRes();
std::error_code ec;
for (const auto& part : GeneralUtils::SplitString(normalized, '/')) {
if (part.empty()) continue;
if (std::filesystem::exists(current / part, ec)) {
current /= part;
continue;
}
std::filesystem::path match;
if (std::filesystem::is_directory(current, ec)) {
for (const auto& entry : std::filesystem::directory_iterator(current, ec)) {
auto name = entry.path().filename().string();
std::transform(name.begin(), name.end(), name.begin(), ::tolower);
if (name == part) {
match = entry.path();
break;
}
}
}
current = match.empty() ? current / part : match;
}
return current;
}
std::optional<std::string> ReadFile(const std::filesystem::path& path) {
std::ifstream file(path, std::ios::binary | std::ios::ate);
if (!file) return std::nullopt;
// One read of the whole file: copying through stream iterators is slow (very slow in debug builds)
const auto size = file.tellg();
if (size < 0) return std::nullopt;
std::string data(static_cast<size_t>(size), '\0');
file.seekg(0);
if (!file.read(data.data(), size)) return std::nullopt;
return data;
}
std::string Phrase(const std::string& key) {
const auto& phrase = Locale::GetPhrase(key);
return phrase;
}
struct Stats {
int imagination{ 0 };
int life{ 0 };
int armor{ 0 };
nlohmann::json skills = nlohmann::json::array();
};
std::string SkillDescription(int skillId) {
auto text = Phrase("SkillBehavior_" + std::to_string(skillId) + "_descriptionUI");
const std::pair<const char*, const char*> replacements[] = {
{"%(DamageCombo)", "Damage Combo: "}, {"%(AltCombo)", "\nSkeleton Combo: "},
{"%(Description)", "\n"}, {"%(ChargeUp)", "\nCharge-up: "}
};
for (const auto& [from, to] : replacements) {
for (auto pos = text.find(from); pos != std::string::npos; pos = text.find(from, pos)) text.replace(pos, std::strlen(from), to);
}
return text;
}
void AddSkill(Stats& stats, CppSQLite3Query& row) {
const int imagination = row.getIntField("imBonusUI", 0);
const int life = row.getIntField("lifeBonusUI", 0);
const int armor = row.getIntField("armorBonusUI", 0);
stats.imagination += imagination;
stats.life += life;
stats.armor += armor;
// Stat bonus skills only restate the numbers above ("+2 AP"), so list just the other skills
if (imagination || life || armor) return;
const int skillId = row.getIntField("skillID", 0);
const auto description = SkillDescription(skillId);
if (!description.empty()) stats.skills.push_back({ {"id", skillId}, {"icon", row.getIntField("skillIcon", 0)}, {"description", description} });
}
nlohmann::json StatsJson(const Stats& stats) {
return { {"imagination", stats.imagination}, {"life", stats.life}, {"armor", stats.armor}, {"skills", stats.skills} };
}
nlohmann::json SetBonus(int skillSetId) {
Stats stats;
auto stmt = CDClientDatabase::CreatePreppedStmt(
"SELECT sb.skillID, sb.skillIcon, sb.imBonusUI, sb.lifeBonusUI, sb.armorBonusUI FROM ItemSetSkills iss "
"JOIN SkillBehavior sb ON sb.skillID = iss.SkillID WHERE iss.SkillSetID = ?;");
stmt.bind(1, skillSetId);
auto row = stmt.execQuery();
while (!row.eof()) {
AddSkill(stats, row);
row.nextRow();
}
return StatsJson(stats);
}
void LoadItemSets() {
if (g_ItemSetsLoaded) return;
g_ItemSetsLoaded = true;
auto rows = CDClientDatabase::ExecuteQuery("SELECT setID, itemIDs FROM ItemSets;");
while (!rows.eof()) {
const int setId = rows.getIntField("setID");
for (const auto& part : GeneralUtils::SplitString(rows.getStringField("itemIDs", ""), ',')) {
std::string trimmed = part;
trimmed.erase(0, trimmed.find_first_not_of(' '));
if (const auto lot = GeneralUtils::TryParse<LOT>(trimmed)) g_ItemSetsByLot[*lot].push_back(setId);
}
rows.nextRow();
}
}
nlohmann::json ItemSet(LOT lot) {
LoadItemSets();
const auto it = g_ItemSetsByLot.find(lot);
if (it == g_ItemSetsByLot.end()) return nullptr;
auto stmt = CDClientDatabase::CreatePreppedStmt(
"SELECT setID, kitRank, kitImage, skillSetWith2, skillSetWith3, skillSetWith4, skillSetWith5, skillSetWith6 FROM ItemSets WHERE setID = ?;");
stmt.bind(1, it->second.front());
auto row = stmt.execQuery();
if (row.eof()) return nullptr;
const int setId = row.getIntField("setID");
nlohmann::json set{
{"id", setId},
{"name", Phrase("ItemSets_" + std::to_string(setId) + "_kitName")},
{"rank", row.getIntField("kitRank", 0)},
{"icon", row.getIntField("kitImage", 0)},
{"bonuses", nlohmann::json::array()}
};
const char* columns[] = { "skillSetWith2", "skillSetWith3", "skillSetWith4", "skillSetWith5", "skillSetWith6" };
for (int i = 0; i < 5; i++) {
const int skillSet = row.getIntField(columns[i], 0);
if (skillSet > 0) set["bonuses"].push_back({ {"pieces", i + 2}, {"stats", SetBonus(skillSet)} });
}
return set;
}
}
namespace {
struct ObjectNames {
std::string name;
std::string displayName;
};
// Every object's name and displayName, read once: the CDClient has no index on Objects.id, so looking a LOT up
// scans the whole table, and pages name thousands of items at once
const std::unordered_map<LOT, ObjectNames>& ObjectNameTable() {
static std::unordered_map<LOT, ObjectNames> table;
static bool loaded = false;
if (loaded) return table;
loaded = true;
try {
auto row = CDClientDatabase::ExecuteQuery("SELECT id, name, displayName FROM Objects;");
for (; !row.eof(); row.nextRow()) {
// The first row wins, as the LIMIT 1 lookups this replaces did
table.try_emplace(static_cast<LOT>(row.getIntField("id")), ObjectNames{ row.getStringField("name", ""), row.getStringField("displayName", "") });
}
} catch (const std::exception& ex) {
LOG("Failed to read object names: %s", ex.what());
}
return table;
}
}
namespace ClientAssets {
std::optional<std::string> ObjectName(LOT lot) {
const auto& table = ObjectNameTable();
const auto it = table.find(lot);
if (it == table.end()) return std::nullopt;
return it->second.name;
}
std::string ItemName(LOT lot) {
const auto& localized = Locale::GetPhrase("Objects_" + std::to_string(lot) + "_name");
if (!localized.empty()) return localized;
const auto& table = ObjectNameTable();
const auto it = table.find(lot);
if (it == table.end()) return "LOT " + std::to_string(lot);
return it->second.displayName.empty() ? it->second.name : it->second.displayName;
}
nlohmann::json ItemInfo(LOT lot) {
if (const auto cached = g_ItemCache.find(lot); cached != g_ItemCache.end()) return cached->second;
nlohmann::json info{ {"lot", lot}, {"name", ItemName(lot)} };
auto description = Phrase("Objects_" + std::to_string(lot) + "_description");
if (description.empty()) {
auto stmt = CDClientDatabase::CreatePreppedStmt("SELECT description FROM Objects WHERE id = ? LIMIT 1;");
stmt.bind(1, static_cast<int>(lot));
auto row = stmt.execQuery();
if (!row.eof()) description = row.getStringField("description", "");
}
info["description"] = description;
auto rarityStmt = CDClientDatabase::CreatePreppedStmt(
"SELECT ic.rarity FROM ComponentsRegistry cr JOIN ItemComponent ic ON ic.id = cr.component_id WHERE cr.component_type = 11 AND cr.id = ? LIMIT 1;");
rarityStmt.bind(1, static_cast<int>(lot));
auto rarity = rarityStmt.execQuery();
info["rarity"] = rarity.eof() ? 0 : rarity.getIntField("rarity", 0);
Stats stats;
auto skillStmt = CDClientDatabase::CreatePreppedStmt(
"SELECT sb.skillID, sb.skillIcon, sb.imBonusUI, sb.lifeBonusUI, sb.armorBonusUI FROM ObjectSkills os "
"JOIN SkillBehavior sb ON sb.skillID = os.skillID WHERE os.objectTemplate = ?;");
skillStmt.bind(1, static_cast<int>(lot));
auto skills = skillStmt.execQuery();
while (!skills.eof()) {
AddSkill(stats, skills);
skills.nextRow();
}
info["stats"] = StatsJson(stats);
info["set"] = ItemSet(lot);
g_ItemCache[lot] = info;
return info;
}
std::string IconPathForLot(LOT lot) {
auto stmt = CDClientDatabase::CreatePreppedStmt(
"SELECT rc.icon_asset, rc.IconID FROM ComponentsRegistry cr JOIN RenderComponent rc ON cr.component_id = rc.id "
"WHERE cr.component_type = 2 AND cr.id = ? LIMIT 1;");
stmt.bind(1, static_cast<int>(lot));
auto result = stmt.execQuery();
if (result.eof()) return "";
std::string iconPath = result.getStringField("icon_asset", "");
const int iconId = result.getIntField("IconID", 0);
if (iconPath.empty() && iconId > 0) iconPath = IconPathForId(iconId);
return iconPath;
}
std::string IconPathForId(int iconId) {
auto stmt = CDClientDatabase::CreatePreppedStmt("SELECT IconPath FROM Icons WHERE IconID = ? LIMIT 1;");
stmt.bind(1, iconId);
auto result = stmt.execQuery();
return result.eof() ? "" : result.getStringField("IconPath", "");
}
bool IsSafeAssetPath(const std::string& path) {
if (path.empty() || path.find("..") != std::string::npos || path.starts_with('/')) return false;
return std::ranges::all_of(path, [](char c) {
return std::isalnum(static_cast<unsigned char>(c)) || c == '/' || c == '_' || c == '-' || c == '.' || c == ' ';
});
}
std::optional<std::string> TextureAsPng(const std::string& assetPath, uint32_t maxSize, bool opaque) {
const auto res = ClientRes();
const auto normalized = NormalizeAssetPath(assetPath);
if (res.empty() || !IsSafeAssetPath(normalized)) return std::nullopt;
const auto source = ResolveRes(normalized);
std::error_code ec;
if (!std::filesystem::is_regular_file(source, ec)) return std::nullopt;
if (normalized.ends_with(".png")) return ReadFile(source);
if (!normalized.ends_with(".dds")) return std::nullopt;
std::string cacheName = normalized + "_" + std::to_string(maxSize) + (opaque ? "_opaque" : "") + ".png";
std::replace(cacheName.begin(), cacheName.end(), '/', '_');
std::replace(cacheName.begin(), cacheName.end(), ' ', '_');
const auto cacheDir = std::filesystem::path("dDashboardServer") / "icon_cache";
const auto target = cacheDir / cacheName;
if (!std::filesystem::exists(target)) {
// ImageMagick runs without a shell (an argument vector); odd characters are still refused as a precaution
if (g_FailedConversions.contains(cacheName) || Process::HasShellMetacharacters(source.string())) return std::nullopt;
std::filesystem::create_directories(cacheDir, ec);
const std::string size = std::to_string(maxSize) + "x" + std::to_string(maxSize) + ">";
std::vector<std::string> arguments{ "magick", source.string() };
if (opaque) { arguments.push_back("-alpha"); arguments.push_back("off"); }
arguments.insert(arguments.end(), { "-resize", size, target.string() });
if (Process::Run(arguments) != 0) {
LOG_DEBUG("Texture conversion failed for %s (is ImageMagick installed?)", normalized.c_str());
g_FailedConversions.insert(cacheName);
return std::nullopt;
}
}
return ReadFile(target);
}
std::optional<std::string> ReadResFile(const std::string& relativePath) {
const auto res = ClientRes();
if (res.empty() || !IsSafeAssetPath(NormalizeAssetPath(relativePath))) return std::nullopt;
return ReadFile(ResolveRes(NormalizeAssetPath(relativePath)));
}
std::optional<std::filesystem::path> ResolveResFile(const std::string& relativePath) {
const auto normalized = NormalizeAssetPath(relativePath);
if (ClientRes().empty() || !IsSafeAssetPath(normalized)) return std::nullopt;
auto path = ResolveRes(normalized);
std::error_code ec;
if (!std::filesystem::is_regular_file(path, ec)) return std::nullopt;
return path;
}
std::optional<std::string> FindResFile(const std::string& folder, const std::string& fileName) {
const auto normalized = NormalizeAssetPath(folder);
if (ClientRes().empty() || !IsSafeAssetPath(normalized)) return std::nullopt;
const auto root = ResolveRes(normalized);
auto wanted = fileName;
std::transform(wanted.begin(), wanted.end(), wanted.begin(), ::tolower);
std::error_code ec;
for (auto it = std::filesystem::recursive_directory_iterator(root, ec); !ec && it != std::filesystem::recursive_directory_iterator(); it.increment(ec)) {
auto name = it->path().filename().string();
std::transform(name.begin(), name.end(), name.begin(), ::tolower);
if (name != wanted || !it->is_regular_file(ec)) continue;
auto relative = std::filesystem::relative(it->path(), ClientRes(), ec).generic_string();
std::transform(relative.begin(), relative.end(), relative.begin(), ::tolower);
return relative;
}
return std::nullopt;
}
std::optional<nlohmann::json> ListDirectory(const std::string& relativePath) {
const auto res = ClientRes();
const auto normalized = NormalizeAssetPath(relativePath);
if (res.empty() || (!normalized.empty() && !IsSafeAssetPath(normalized))) return std::nullopt;
std::error_code ec;
const auto dir = normalized.empty() ? res : ResolveRes(normalized);
if (!std::filesystem::is_directory(dir, ec)) return std::nullopt;
nlohmann::json entries = nlohmann::json::array();
for (const auto& entry : std::filesystem::directory_iterator(dir, ec)) {
if (entries.size() >= MAX_LISTING_ENTRIES) break;
const auto name = entry.path().filename().string();
const bool isDir = entry.is_directory(ec);
entries.push_back({
{"name", name},
{"path", normalized.empty() ? name : normalized + "/" + name},
{"dir", isDir},
{"size", isDir ? 0 : static_cast<int64_t>(entry.file_size(ec))}
});
}
std::sort(entries.begin(), entries.end(), [](const nlohmann::json& a, const nlohmann::json& b) {
if (a["dir"] != b["dir"]) return a["dir"].get<bool>();
return a["name"].get<std::string>() < b["name"].get<std::string>();
});
return nlohmann::json{ {"path", normalized}, {"entries", entries}, {"truncated", entries.size() >= MAX_LISTING_ENTRIES} };
}
}
namespace {
void ReplyPng(HTTPReply& reply, const std::optional<std::string>& png) {
if (!png) {
reply.status = eHTTPStatusCode::NOT_FOUND;
reply.message = "";
return;
}
reply.status = eHTTPStatusCode::OK;
reply.message = *png;
reply.contentType = eContentType::IMAGE_PNG;
reply.headers.push_back("Cache-Control: private, max-age=86400");
}
}
void RegisterClientAssetRoutes() {
Route(eHTTPMethod::GET, "/api/icon/:lot", 0, "PNG icon for an item LOT (requires client_location and ImageMagick)",
[](HTTPReply& reply, const HTTPContext& context) {
const auto lot = PathId<LOT>(context.path, 2);
std::string path = lot ? ClientAssets::IconPathForLot(*lot) : "";
auto png = path.empty() ? std::nullopt : ClientAssets::TextureAsPng(path, 64);
// Fall back to the game's "unknown item" icon
if (lot && !png) png = ClientAssets::TextureAsPng("textures/ui/inventory/unknown.dds", 64);
ReplyPng(reply, png);
});
Route(eHTTPMethod::GET, "/api/icon_id/:id", 0, "PNG icon by icon ID (skills, item sets)",
[](HTTPReply& reply, const HTTPContext& context) {
const auto iconId = PathId<int>(context.path, 2);
const std::string path = iconId ? ClientAssets::IconPathForId(*iconId) : "";
ReplyPng(reply, path.empty() ? std::nullopt : ClientAssets::TextureAsPng(path, 64));
});
Route(eHTTPMethod::POST, "/api/items/info", 0, "Item details for tooltips. Body: {lots: [..]} (max 500)",
[](HTTPReply& reply, const HTTPContext& context) {
const auto body = ParseBody(context);
if (!body || !body->contains("lots") || !(*body)["lots"].is_array()) return JsonError(reply, eHTTPStatusCode::BAD_REQUEST, "lots must be an array");
nlohmann::json items = nlohmann::json::object();
uint32_t count = 0;
for (const auto& lot : (*body)["lots"]) {
if (!lot.is_number_integer() || ++count > MAX_ITEMS_PER_REQUEST) continue;
const auto value = lot.get<LOT>();
items[std::to_string(value)] = ClientAssets::ItemInfo(value);
}
JsonReply(reply, eHTTPStatusCode::OK, items);
});
Route(eHTTPMethod::GET, "/api/client/list", Perm("client_files"), "List a folder of the game client's res directory. Query: ?path=",
[](HTTPReply& reply, const HTTPContext& context) {
const auto listing = ClientAssets::ListDirectory(QueryValue(context.queryString, "path"));
if (!listing) return JsonError(reply, eHTTPStatusCode::NOT_FOUND, "Folder not found (is client_location set?)");
JsonReply(reply, eHTTPStatusCode::OK, *listing);
});
Route(eHTTPMethod::GET, "/api/client/texture", Perm("client_files"), "A client DDS/PNG texture as PNG. Query: ?path=&size= (max 1024)",
[](HTTPReply& reply, const HTTPContext& context) {
const uint32_t size = std::clamp<uint32_t>(GeneralUtils::TryParse<uint32_t>(QueryValue(context.queryString, "size")).value_or(512), 16, 1024);
ReplyPng(reply, ClientAssets::TextureAsPng(QueryValue(context.queryString, "path"), size));
});
Route(eHTTPMethod::GET, "/api/client/file", Perm("client_files"), "Download a raw client file. Query: ?path=",
[](HTTPReply& reply, const HTTPContext& context) {
const auto normalized = NormalizeAssetPath(QueryValue(context.queryString, "path"));
const auto res = ClientRes();
std::error_code ec;
if (res.empty() || !ClientAssets::IsSafeAssetPath(normalized) || !std::filesystem::is_regular_file(ResolveRes(normalized), ec)) {
return JsonError(reply, eHTTPStatusCode::NOT_FOUND, "File not found");
}
const auto data = ReadFile(ResolveRes(normalized));
if (!data) return JsonError(reply, eHTTPStatusCode::NOT_FOUND, "File not found");
reply.status = eHTTPStatusCode::OK;
reply.message = *data;
reply.contentType = eContentType::APPLICATION_OCTET_STREAM;
const auto name = std::filesystem::path(normalized).filename().string();
reply.headers.push_back("Content-Disposition: attachment; filename=\"" + name + "\"");
});
}