Files
DarkflameServer/dDatabase/GameDatabase/SQLite/Tables/CharXml.cpp
Aaron Kimbrell 341ce89a6a feat: refuse stale character saves with a save generation
Every character gets a save generation in charxml. A world bumps it when it loads the character for
play, and every save from that world only goes through while the stored generation is still the
one it loaded or last saved (and moves it on). Dashboard edits, restores and maintenance writes bump
it too. A world that lost the character to another world (a disconnect noticed late, a zone
transfer, an instance migration) or to a dashboard edit can no longer overwrite the newer data: the
save is refused, logged and audited as stale_save_refused, the world stops saving that character
and a player still connected to it is disconnected with the save failure reason so they reload.

Fixes #639

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 22:30:51 -05:00

111 lines
4.5 KiB
C++

#include "SQLiteDatabase.h"
std::string SQLiteDatabase::GetCharacterXml(const LWOOBJID charId) {
auto [_, result] = ExecuteSelect("SELECT xml_data FROM charxml WHERE id = ? LIMIT 1;", charId);
if (result.eof()) {
return "";
}
return result.getStringField("xml_data");
}
void SQLiteDatabase::UpdateCharacterXml(const LWOOBJID charId, const std::string_view lxfml) {
ExecuteUpdate("UPDATE charxml SET xml_data = ?, save_generation = save_generation + 1 WHERE id = ?;", lxfml, charId);
}
std::optional<ICharXml::CharacterXml> SQLiteDatabase::ClaimCharacterXml(const LWOOBJID charId) {
ExecuteUpdate("UPDATE charxml SET save_generation = save_generation + 1 WHERE id = ?;", charId);
// Read both from one row, so a write in between still leaves a matching pair
auto [_, result] = ExecuteSelect("SELECT xml_data, save_generation FROM charxml WHERE id = ? LIMIT 1;", charId);
if (result.eof()) return std::nullopt;
return CharacterXml{ result.getStringField("xml_data"), static_cast<uint64_t>(result.getInt64Field("save_generation")) };
}
bool SQLiteDatabase::SaveCharacterXml(const LWOOBJID charId, const std::string_view lxfml, const uint64_t generation) {
return ExecuteUpdate("UPDATE charxml SET xml_data = ?, save_generation = ? WHERE id = ? AND save_generation = ?;",
lxfml, static_cast<int64_t>(generation + 1), charId, static_cast<int64_t>(generation)) > 0;
}
uint64_t SQLiteDatabase::GetCharacterSaveGeneration(const LWOOBJID charId) {
auto [_, result] = ExecuteSelect("SELECT save_generation FROM charxml WHERE id = ? LIMIT 1;", charId);
return result.eof() ? 0 : static_cast<uint64_t>(result.getInt64Field("save_generation"));
}
void SQLiteDatabase::InsertCharacterXml(const LWOOBJID characterId, const std::string_view lxfml) {
ExecuteInsert("INSERT INTO `charxml` (`id`, `xml_data`) VALUES (?,?)", characterId, lxfml);
}
#include "GeneralUtils.h"
#include "json.hpp"
std::string SQLiteDatabase::GetCharactersTable(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 c.id, c.name, c.account_id, c.last_login, a.name as account_name FROM charinfo c JOIN accounts a ON c.account_id = a.id";
std::string whereClause;
std::string orderClause;
// Add search filter if provided
if (!search.empty()) {
whereClause = " WHERE (c.name LIKE '%' || ? || '%' OR c.id = ? OR a.name LIKE '%' || ? || '%')";
}
// Map column indices to database columns
std::string orderColumnName = "c.id";
switch (orderColumn) {
case 0: orderColumnName = "c.id"; break;
case 1: orderColumnName = "c.name"; break;
case 2: orderColumnName = "a.name"; break;
case 3: orderColumnName = "c.last_login"; break;
default: orderColumnName = "c.id";
}
orderClause = " ORDER BY " + orderColumnName + (orderAsc ? " ASC" : " DESC") + ", c.id";
// Build the main query
std::string mainQuery = baseQuery + whereClause + orderClause + " LIMIT ? OFFSET ?;";
// Get total count
std::string totalCountQuery = "SELECT COUNT(*) as count FROM charinfo;";
auto [__, totalCountResult] = ExecuteSelect(totalCountQuery);
uint32_t totalRecords = totalCountResult.eof() ? 0 : totalCountResult.getIntField("count");
// Get filtered count
uint32_t filteredRecords = totalRecords;
if (!search.empty()) {
std::string filteredCountQuery = "SELECT COUNT(*) as count FROM charinfo c JOIN accounts a ON c.account_id = a.id WHERE (c.name LIKE '%' || ? || '%' OR c.id = ? OR a.name LIKE '%' || ? || '%');";
auto [___, filteredCountResult] = ExecuteSelect(filteredCountQuery, search, searchId, search);
filteredRecords = filteredCountResult.eof() ? 0 : filteredCountResult.getIntField("count");
}
// Execute main query
auto [stmt, result] = !search.empty() ?
ExecuteSelect(mainQuery, search, searchId, search, length, start) :
ExecuteSelect(mainQuery, length, start);
// Build response JSON
nlohmann::json charactersArray = nlohmann::json::array();
while (!result.eof()) {
nlohmann::json character = {
{"id", std::to_string(result.getInt64Field("id"))},
{"name", result.getStringField("name")},
{"account_name", result.getStringField("account_name")},
{"last_login", result.getInt64Field("last_login")}
};
charactersArray.push_back(character);
result.nextRow();
}
nlohmann::json response = {
{"draw", 0},
{"recordsTotal", totalRecords},
{"recordsFiltered", filteredRecords},
{"data", charactersArray}
};
return response.dump();
}