Files
DarkflameServer/dDatabase/GameDatabase/SQLite/Tables/Dashboard.cpp
Aaron Kimbrell 33402425e4 feat(dashboard): pending guild names count on the Review Queue and Guilds badges
The dashboard snapshot counts guilds whose name waits for moderation; the moderation counts (WebSocket and
/api/moderation/counts) include it, the Review Queue badge adds it for staff with guilds_manage, and the Guilds menu
entry has its own badge. Check: create a guild with a name off the allow list: both badges go up; approve it: they
go down.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 08:46:34 -05:00

364 lines
18 KiB
C++

#include "SQLiteDatabase.h"
#include "GeneralUtils.h"
#include "json.hpp"
// Queries backing the web dashboard's moderation features.
namespace {
constexpr const char* SNAPSHOT_QUERY =
"SELECT "
"(SELECT COUNT(*) FROM accounts) AS accounts, "
"(SELECT COALESCE(MAX(id), 0) FROM accounts) AS accounts_max_id, "
"(SELECT COUNT(*) FROM charinfo) AS characters, "
"(SELECT COUNT(*) FROM charinfo WHERE pending_name != '' AND needs_rename = 0) AS pending_names, "
"(SELECT COUNT(*) FROM properties) AS properties, "
"(SELECT COUNT(*) FROM properties WHERE mod_approved = 0 AND privacy_option = 2 AND rejection_reason = '') AS pending_properties, "
"(SELECT COUNT(*) FROM play_keys) AS play_keys, "
"(SELECT COUNT(*) FROM bug_reports) AS bug_reports, "
"(SELECT COUNT(*) FROM bug_reports WHERE resolved_time IS NULL) AS unresolved_bug_reports, "
"(SELECT COUNT(*) FROM pet_names) AS pet_names, "
"(SELECT COUNT(*) FROM pet_names WHERE approved = 1) AS pending_pet_names, "
"(SELECT COUNT(*) FROM guilds WHERE name_status = 1) AS pending_guild_names, "
"(SELECT COALESCE(MAX(id), 0) FROM activity_log) AS activity_log_max_id, "
"(SELECT COALESCE(MAX(id), 0) FROM chat_log) AS chat_log_max_id, "
"(SELECT COALESCE(MAX(id), 0) FROM command_log) AS command_log_max_id, "
"(SELECT COALESCE(MAX(id), 0) FROM audit_log) AS audit_log_max_id, "
"(SELECT COALESCE(MAX(id), 0) FROM mail) AS mail_max_id, "
"(SELECT COUNT(*) FROM economy_flags WHERE status = 0) AS open_economy_flags, "
"(SELECT COALESCE(MAX(id), 0) FROM economy_flags) AS economy_flags_max_id;";
}
namespace {
std::string NullableString(CppSQLite3Query& result, const char* field) {
return result.fieldIsNull(field) ? "" : result.getStringField(field);
}
}
std::string SQLiteDatabase::GetBugReportsTable(uint32_t start, uint32_t length, const std::string_view search, uint32_t orderColumn, bool orderAsc, int8_t resolvedFilter) {
// 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);
const std::string from = " FROM bug_reports b LEFT JOIN charinfo c ON c.id = b.reporter_id";
std::string resolvedCondition;
if (resolvedFilter == 0) resolvedCondition = "b.resolved_time IS NULL";
else if (resolvedFilter == 1) resolvedCondition = "b.resolved_time IS NOT NULL";
std::string where = resolvedCondition.empty() ? "" : " WHERE " + resolvedCondition;
const std::string totalWhere = where;
if (!search.empty()) {
where += (where.empty() ? " WHERE " : " AND ");
where += "(b.body LIKE '%' || ? || '%' OR b.other_player_id LIKE '%' || ? || '%' OR c.name LIKE '%' || ? || '%' OR b.id = ? OR b.reporter_id = ?)";
}
std::string orderColumnName = "b.id";
switch (orderColumn) {
case 1: orderColumnName = "reporter_name"; break;
case 2: orderColumnName = "b.client_version"; break;
case 3: orderColumnName = "b.submitted"; break;
case 4: orderColumnName = "b.resolved_time"; break;
default: orderColumnName = "b.id"; break;
}
auto [_, totalResult] = ExecuteSelect("SELECT COUNT(*) AS count" + from + totalWhere + ";");
const uint32_t totalRecords = totalResult.eof() ? 0 : totalResult.getIntField("count");
uint32_t filteredRecords = totalRecords;
if (!search.empty()) {
auto [__, filteredResult] = ExecuteSelect("SELECT COUNT(*) AS count" + from + where + ";", search, search, search, searchId, searchId);
filteredRecords = filteredResult.eof() ? 0 : filteredResult.getIntField("count");
}
const std::string query =
"SELECT b.id, b.body, b.client_version, b.other_player_id, b.selection, b.submitted, b.reporter_id, c.name AS reporter_name, b.resolved_time"
+ from + where + " ORDER BY " + orderColumnName + (orderAsc ? " ASC" : " DESC") + " LIMIT ? OFFSET ?;";
auto [stmt, result] = !search.empty()
? ExecuteSelect(query, search, search, search, searchId, searchId, length, start)
: ExecuteSelect(query, length, start);
nlohmann::json data = nlohmann::json::array();
while (!result.eof()) {
data.push_back({
{"id", result.getIntField("id")},
{"body", result.getStringField("body")},
{"client_version", result.getStringField("client_version")},
{"other_player_id", result.getStringField("other_player_id")},
{"selection", result.getStringField("selection")},
{"submitted", result.getStringField("submitted")},
{"reporter_id", std::to_string(result.getInt64Field("reporter_id"))},
{"reporter_name", NullableString(result, "reporter_name")},
{"resolved", !result.fieldIsNull("resolved_time")}
});
result.nextRow();
}
return nlohmann::json({ {"draw", 0}, {"recordsTotal", totalRecords}, {"recordsFiltered", filteredRecords}, {"data", data} }).dump();
}
nlohmann::json SQLiteDatabase::GetBugReport(const uint32_t id) {
auto [_, result] = ExecuteSelect(
"SELECT b.id, b.body, b.client_version, b.other_player_id, b.selection, b.submitted, b.reporter_id, c.name AS reporter_name, "
"b.resolved_time, b.resolution, b.resoleved_by_id, a.name AS resolver_name "
"FROM bug_reports b LEFT JOIN charinfo c ON c.id = b.reporter_id LEFT JOIN accounts a ON a.id = b.resoleved_by_id "
"WHERE b.id = ? LIMIT 1;", id);
if (result.eof()) return nlohmann::json{ {"error", "Bug report not found"} };
return nlohmann::json{
{"id", result.getIntField("id")},
{"body", result.getStringField("body")},
{"client_version", result.getStringField("client_version")},
{"other_player_id", result.getStringField("other_player_id")},
{"selection", result.getStringField("selection")},
{"submitted", result.getStringField("submitted")},
{"reporter_id", std::to_string(result.getInt64Field("reporter_id"))},
{"reporter_name", NullableString(result, "reporter_name")},
{"resolved", !result.fieldIsNull("resolved_time")},
{"resolved_time", NullableString(result, "resolved_time")},
{"resolution", NullableString(result, "resolution")},
{"resolver_name", NullableString(result, "resolver_name")}
};
}
void SQLiteDatabase::ResolveBugReport(const uint32_t id, const uint32_t resolverAccountId, const std::string_view resolution) {
ExecuteUpdate("UPDATE bug_reports SET resolved_time = datetime('now'), resoleved_by_id = ?, resolution = ? WHERE id = ?;", resolverAccountId, resolution, id);
}
std::string SQLiteDatabase::GetPlayKeysTable(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);
const std::string where = search.empty() ? "" : " WHERE (p.key_string LIKE '%' || ? || '%' OR p.notes LIKE '%' || ? || '%' OR p.id = ? OR p.id IN (SELECT play_key_id FROM accounts WHERE name LIKE '%' || ? || '%'))";
std::string orderColumnName = "p.id";
switch (orderColumn) {
case 1: orderColumnName = "p.key_string"; break;
case 2: orderColumnName = "p.key_uses"; break;
case 3: orderColumnName = "times_used"; break;
case 4: orderColumnName = "p.created_at"; break;
case 5: orderColumnName = "p.active"; break;
default: orderColumnName = "p.id"; break;
}
auto [_, totalResult] = ExecuteSelect("SELECT COUNT(*) AS count FROM play_keys;");
const uint32_t totalRecords = totalResult.eof() ? 0 : totalResult.getIntField("count");
uint32_t filteredRecords = totalRecords;
if (!search.empty()) {
auto [__, filteredResult] = ExecuteSelect("SELECT COUNT(*) AS count FROM play_keys p" + where + ";", search, search, searchId, search);
filteredRecords = filteredResult.eof() ? 0 : filteredResult.getIntField("count");
}
const std::string query =
"SELECT p.id, p.key_string, p.key_uses, p.created_at, p.active, p.notes, "
"(SELECT COUNT(*) FROM accounts a WHERE a.play_key_id = p.id) AS times_used FROM play_keys p"
+ where + " ORDER BY " + orderColumnName + (orderAsc ? " ASC" : " DESC") + " LIMIT ? OFFSET ?;";
auto [stmt, result] = !search.empty()
? ExecuteSelect(query, search, search, searchId, search, length, start)
: ExecuteSelect(query, length, start);
nlohmann::json data = nlohmann::json::array();
while (!result.eof()) {
data.push_back({
{"id", result.getIntField("id")},
{"key_string", result.getStringField("key_string")},
{"key_uses", result.getIntField("key_uses")},
{"times_used", result.getIntField("times_used")},
{"created_at", result.getStringField("created_at")},
{"active", result.getIntField("active") != 0},
{"notes", NullableString(result, "notes")}
});
result.nextRow();
}
return nlohmann::json({ {"draw", 0}, {"recordsTotal", totalRecords}, {"recordsFiltered", filteredRecords}, {"data", data} }).dump();
}
nlohmann::json SQLiteDatabase::GetPlayKey(const int32_t playkeyId) {
auto [_, result] = ExecuteSelect("SELECT id, key_string, key_uses, created_at, active, notes FROM play_keys WHERE id = ? LIMIT 1;", playkeyId);
if (result.eof()) return nlohmann::json{ {"error", "Play key not found"} };
nlohmann::json key{
{"id", result.getIntField("id")},
{"key_string", result.getStringField("key_string")},
{"key_uses", result.getIntField("key_uses")},
{"created_at", result.getStringField("created_at")},
{"active", result.getIntField("active") != 0},
{"notes", NullableString(result, "notes")}
};
nlohmann::json accounts = nlohmann::json::array();
auto [__, accountResult] = ExecuteSelect("SELECT id, name, created_at FROM accounts WHERE play_key_id = ? ORDER BY id;", playkeyId);
while (!accountResult.eof()) {
accounts.push_back({
{"id", accountResult.getIntField("id")},
{"name", accountResult.getStringField("name")},
{"created_at", accountResult.getStringField("created_at")}
});
accountResult.nextRow();
}
key["times_used"] = accounts.size();
key["accounts"] = accounts;
return key;
}
void SQLiteDatabase::UpdatePlayKey(const int32_t playkeyId, const uint32_t uses, const std::string_view notes, const bool active) {
ExecuteUpdate("UPDATE play_keys SET key_uses = ?, notes = ?, active = ? WHERE id = ?;", uses, notes, active ? 1 : 0, playkeyId);
}
void SQLiteDatabase::DeletePlayKey(const int32_t playkeyId) {
ExecuteUpdate("UPDATE accounts SET play_key_id = NULL WHERE play_key_id = ?;", playkeyId);
ExecuteDelete("DELETE FROM play_keys WHERE id = ?;", playkeyId);
}
std::vector<std::pair<LWOOBJID, std::string>> SQLiteDatabase::GetCharacterIdsAndNames() {
auto [_, result] = ExecuteSelect("SELECT id, name FROM charinfo ORDER BY name;");
std::vector<std::pair<LWOOBJID, std::string>> characters;
while (!result.eof()) {
characters.emplace_back(result.getInt64Field("id"), result.getStringField("name"));
result.nextRow();
}
return characters;
}
nlohmann::json SQLiteDatabase::GetPendingNamesTable(const uint32_t start, const uint32_t length) {
auto [_, countResult] = ExecuteSelect("SELECT COUNT(*) AS count FROM charinfo WHERE pending_name != '' AND needs_rename = 0;");
const uint32_t total = countResult.eof() ? 0 : countResult.getIntField("count");
auto [__, result] = ExecuteSelect(
"SELECT c.id, c.name, c.pending_name, c.account_id, a.name AS account_name "
"FROM charinfo c LEFT JOIN accounts a ON a.id = c.account_id "
"WHERE c.pending_name != '' AND c.needs_rename = 0 ORDER BY c.id LIMIT ? OFFSET ?;", length, start);
nlohmann::json data = nlohmann::json::array();
while (!result.eof()) {
data.push_back({
{"id", std::to_string(result.getInt64Field("id"))},
{"name", result.getStringField("name")},
{"pending_name", result.getStringField("pending_name")},
{"account_id", result.getIntField("account_id")},
{"account_name", NullableString(result, "account_name")}
});
result.nextRow();
}
return nlohmann::json{ {"draw", 0}, {"recordsTotal", total}, {"recordsFiltered", total}, {"data", data} };
}
void SQLiteDatabase::SetCharacterPermissionMap(const LWOOBJID characterId, const uint64_t permissionMap) {
ExecuteUpdate("UPDATE charinfo SET permission_map = ? WHERE id = ?;", permissionMap, characterId);
}
IDashboardStats::Snapshot SQLiteDatabase::GetDashboardSnapshot() {
IDashboardStats::Snapshot snapshot;
auto [_, result] = ExecuteSelect(SNAPSHOT_QUERY);
if (result.eof()) return snapshot;
snapshot.accounts = result.getInt64Field("accounts");
snapshot.accountsMaxId = result.getInt64Field("accounts_max_id");
snapshot.characters = result.getInt64Field("characters");
snapshot.pendingNames = result.getInt64Field("pending_names");
snapshot.properties = result.getInt64Field("properties");
snapshot.pendingProperties = result.getInt64Field("pending_properties");
snapshot.playKeys = result.getInt64Field("play_keys");
snapshot.bugReports = result.getInt64Field("bug_reports");
snapshot.unresolvedBugReports = result.getInt64Field("unresolved_bug_reports");
snapshot.petNames = result.getInt64Field("pet_names");
snapshot.pendingPetNames = result.getInt64Field("pending_pet_names");
snapshot.pendingGuildNames = result.getInt64Field("pending_guild_names");
snapshot.activityLogMaxId = result.getInt64Field("activity_log_max_id");
snapshot.chatLogMaxId = result.getInt64Field("chat_log_max_id");
snapshot.commandLogMaxId = result.getInt64Field("command_log_max_id");
snapshot.auditLogMaxId = result.getInt64Field("audit_log_max_id");
snapshot.mailMaxId = result.getInt64Field("mail_max_id");
snapshot.openEconomyFlags = result.getInt64Field("open_economy_flags");
snapshot.economyFlagsMaxId = result.getInt64Field("economy_flags_max_id");
return snapshot;
}
std::optional<LWOOBJID> SQLiteDatabase::GetModelPropertyId(const LWOOBJID modelID) {
auto [_, result] = ExecuteSelect("SELECT property_id FROM properties_contents WHERE id = ? LIMIT 1;", modelID);
if (result.eof()) return std::nullopt;
return result.getInt64Field("property_id");
}
std::optional<int32_t> SQLiteDatabase::GetRedeemablePlayKeyId(const std::string_view keyString) {
auto [_, result] = ExecuteSelect(
"SELECT p.id FROM play_keys p WHERE p.key_string = ? AND p.active = 1 "
"AND (SELECT COUNT(*) FROM accounts a WHERE a.play_key_id = p.id) < p.key_uses LIMIT 1;", keyString);
if (result.eof()) return std::nullopt;
return result.getIntField("id");
}
void SQLiteDatabase::SetAccountPlayKey(const uint32_t accountId, const int32_t playkeyId) {
ExecuteUpdate("UPDATE accounts SET play_key_id = ? WHERE id = ?;", playkeyId, accountId);
}
std::optional<IAccountEmails::EmailInfo> SQLiteDatabase::GetAccountEmail(const uint32_t accountId) {
auto [_, result] = ExecuteSelect("SELECT email, email_confirmed_at FROM accounts WHERE id = ? LIMIT 1;", accountId);
if (result.eof()) return std::nullopt;
return IAccountEmails::EmailInfo{ result.fieldIsNull("email") ? "" : result.getStringField("email"), !result.fieldIsNull("email_confirmed_at") };
}
void SQLiteDatabase::SetAccountEmail(const uint32_t accountId, const std::string_view email, const bool confirmed) {
if (confirmed) ExecuteUpdate("UPDATE accounts SET email = ?, email_confirmed_at = CURRENT_TIMESTAMP WHERE id = ?;", email, accountId);
else ExecuteUpdate("UPDATE accounts SET email = ?, email_confirmed_at = NULL WHERE id = ?;", email, accountId);
}
std::optional<uint32_t> SQLiteDatabase::GetAccountIdByConfirmedEmail(const std::string_view email) {
auto [_, result] = ExecuteSelect("SELECT id FROM accounts WHERE LOWER(email) = LOWER(?) AND email_confirmed_at IS NOT NULL LIMIT 1;", email);
if (result.eof()) return std::nullopt;
return static_cast<uint32_t>(result.getIntField("id"));
}
void SQLiteDatabase::InsertAccountToken(const std::string_view tokenHash, const uint32_t accountId, const std::string_view purpose, const std::string_view data, const int64_t expiresAt) {
ExecuteInsert("INSERT INTO account_tokens (token_hash, account_id, purpose, data, expires_at) VALUES (?, ?, ?, ?, ?);", tokenHash, accountId, purpose, data, expiresAt);
}
std::optional<IAccountEmails::AccountToken> SQLiteDatabase::ConsumeAccountToken(const std::string_view tokenHash, const std::string_view purpose) {
std::optional<IAccountEmails::AccountToken> token;
{
auto [_, result] = ExecuteSelect("SELECT account_id, data FROM account_tokens WHERE token_hash = ? AND purpose = ? AND expires_at > ? LIMIT 1;",
tokenHash, purpose, static_cast<int64_t>(std::time(nullptr)));
if (!result.eof()) token = IAccountEmails::AccountToken{ static_cast<uint32_t>(result.getIntField("account_id")), result.getStringField("data") };
}
ExecuteDelete("DELETE FROM account_tokens WHERE token_hash = ?;", tokenHash);
return token;
}
void SQLiteDatabase::DeleteAccountTokens(const uint32_t accountId, const std::string_view purpose) {
ExecuteDelete("DELETE FROM account_tokens WHERE account_id = ? AND purpose = ?;", accountId, purpose);
}
void SQLiteDatabase::DeleteExpiredAccountTokens() {
ExecuteDelete("DELETE FROM account_tokens WHERE expires_at <= ?;", static_cast<int64_t>(std::time(nullptr)));
}
int64_t SQLiteDatabase::GetSessionsValidAfter(const uint32_t accountId) {
auto [_, result] = ExecuteSelect("SELECT sessions_valid_after FROM accounts WHERE id = ? LIMIT 1;", accountId);
return result.eof() ? 0 : result.getInt64Field("sessions_valid_after");
}
void SQLiteDatabase::SetSessionsValidAfter(const uint32_t accountId, const int64_t time) {
ExecuteUpdate("UPDATE accounts SET sessions_valid_after = ? WHERE id = ?;", time, accountId);
}
uint32_t SQLiteDatabase::ApprovePreviouslyApprovedPetNames() {
// The derived table keeps this valid on MySQL, which can't select from the table being updated
return static_cast<uint32_t>(ExecuteUpdate(
"UPDATE pet_names SET approved = 2 WHERE approved = 1 AND pet_name IN "
"(SELECT name FROM (SELECT pet_name AS name FROM pet_names WHERE approved = 2) AS approved_names);"));
}
void SQLiteDatabase::ForEachCharacterXml(const std::function<void(LWOOBJID, const std::string&)>& visit) {
auto [_, result] = ExecuteSelect("SELECT id, xml_data FROM charxml;");
while (!result.eof()) {
visit(result.getInt64Field("id"), result.getStringField("xml_data"));
result.nextRow();
}
}
void SQLiteDatabase::ForEachCharacterXmlContaining(const std::string& needle, const std::function<void(LWOOBJID, const std::string&)>& visit) {
auto [_, result] = ExecuteSelect("SELECT id, xml_data FROM charxml WHERE instr(xml_data, ?) > 0;", needle);
while (!result.eof()) {
visit(result.getInt64Field("id"), result.getStringField("xml_data"));
result.nextRow();
}
}