diff --git a/dDashboardServer/routes/APIRoutes.cpp b/dDashboardServer/routes/APIRoutes.cpp index 3c44328f0..4aed3deaa 100644 --- a/dDashboardServer/routes/APIRoutes.cpp +++ b/dDashboardServer/routes/APIRoutes.cpp @@ -13,6 +13,7 @@ #include "CharacterTools.h" #include "ClientAssets.h" #include "CharacterXml.h" +#include "CharacterXmlCheck.h" #include "Scenery.h" #include "Workers.h" #include "LiveWorld.h" @@ -135,6 +136,45 @@ namespace { return info->accountId; } + // Checks an uploaded character XML (CharacterXmlCheck) with the CDClient, the contraband list and the owner's account + CharacterXmlCheck::Result CheckUploadedCharacterXml(const std::string& xml, uint32_t ownerAccountId) { + CharacterXmlCheck::Context context; + context.ownerAccountId = ownerAccountId; + const auto account = Database::Get()->GetAccountById(ownerAccountId); + context.accountGmLevel = account.is_object() && account.contains("gm_level") && account["gm_level"].is_number() ? account["gm_level"].get() : 0; + context.contrabandApplies = Contraband::Applies(static_cast(context.accountGmLevel), ConfigFlag("contraband_ignore_staff", true)); + for (const auto& item : Database::Get()->GetContrabandItems()) context.contraband[item.lot] = { item.reason, item.action }; + + auto& lookups = context.lookups; + lookups.isItem = [](LOT lot) { + auto stmt = CDClientDatabase::CreatePreppedStmt("SELECT 1 FROM ComponentsRegistry WHERE id = ? AND component_type = 11 LIMIT 1;"); + stmt.bind(1, static_cast(lot)); + return !stmt.execQuery().eof(); + }; + lookups.stackSize = [](LOT lot) { + auto stmt = CDClientDatabase::CreatePreppedStmt("SELECT ic.stackSize FROM ComponentsRegistry cr JOIN ItemComponent ic ON ic.id = cr.component_id " + "WHERE cr.id = ? AND cr.component_type = 11 LIMIT 1;"); + stmt.bind(1, static_cast(lot)); + auto result = stmt.execQuery(); + return result.eof() ? 0 : result.getIntField(0, 0); + }; + lookups.missionExists = [](int32_t id) { + auto stmt = CDClientDatabase::CreatePreppedStmt("SELECT 1 FROM Missions WHERE id = ? LIMIT 1;"); + stmt.bind(1, id); + return !stmt.execQuery().eof(); + }; + lookups.levelUScore = [](uint32_t level) -> std::optional { + auto stmt = CDClientDatabase::CreatePreppedStmt("SELECT requiredUScore FROM LevelProgressionLookup WHERE id = ?;"); + stmt.bind(1, static_cast(level)); + auto result = stmt.execQuery(); + if (result.eof()) return std::nullopt; + return result.getInt64Field(0, 0); + }; + auto maxLevel = CDClientDatabase::ExecuteQuery("SELECT MAX(id) FROM LevelProgressionLookup;"); + if (!maxLevel.eof()) lookups.maxLevel = static_cast(maxLevel.getIntField(0, 0)); + return CharacterXmlCheck::Check(xml, context); + } + // Random play key in the XXXX-XXXX-XXXX-XXXX format used by NexusDashboard std::string GeneratePlayKey() { static constexpr std::string_view alphabet = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789"; // no 0/O/1/I @@ -811,7 +851,9 @@ namespace { JsonSuccess(reply, { {"requestId", requestId}, {"message", "Rescue requested"} }); }); - Route(eHTTPMethod::POST, "/api/characters/:id/xml", Perm("characters_edit_xml"), "Replace a character's XML (enable_char_xml_upload=1). Disconnects the owner first. Body: {xml}", + Route(eHTTPMethod::POST, "/api/characters/:id/xml", Perm("characters_edit_xml"), "Replace a character's XML (enable_char_xml_upload=1). Disconnects the owner first. " + "Refused (400, errors) when the game couldn't load it; suspicious content (contraband, out of reach values) needs confirm=true (409, warnings). " + "Body: {xml, confirm, remove_contraband}", [](HTTPReply& reply, const HTTPContext& context) { if (!ConfigFlag("enable_char_xml_upload", false)) return JsonError(reply, eHTTPStatusCode::FORBIDDEN, "Character XML upload is disabled (enable_char_xml_upload)"); const auto charId = RequireId(context, 2, reply); @@ -821,21 +863,59 @@ namespace { const auto owner = CharacterOwner(*charId, reply); if (!owner || !AuthorizeAccountAction(context, *owner, reply, eAccountAction::ITEMS)) return; - const std::string xml = body->value("xml", ""); - tinyxml2::XMLDocument doc; - if (xml.empty() || doc.Parse(xml.c_str()) != tinyxml2::XML_SUCCESS) return JsonError(reply, eHTTPStatusCode::BAD_REQUEST, "That is not valid XML"); - if (!doc.FirstChildElement("obj") || !doc.FirstChildElement("obj")->FirstChildElement("char")) { - return JsonError(reply, eHTTPStatusCode::BAD_REQUEST, "Character XML must have an root with a element"); + const std::string xml = body->is_object() && (*body)["xml"].is_string() ? (*body)["xml"].get() : std::string(); + const auto check = CheckUploadedCharacterXml(xml, *owner); + if (!check.Ok()) { + return JsonReply(reply, eHTTPStatusCode::BAD_REQUEST, { {"success", false}, + {"error", "The XML was not stored: " + std::to_string(check.errors.size()) + (check.errors.size() >= CharacterXmlCheck::MAX_REPORTED ? "+" : "") + " problem(s) the game can't load"}, + {"errors", check.errors}, {"warnings", check.warnings} }); } + nlohmann::json contraband = nlohmann::json::array(); + for (const auto& found : check.contraband) { + contraband.push_back({ {"id", std::to_string(found.item.id)}, {"lot", found.item.lot}, {"name", ClientAssets::ItemName(found.item.lot)}, {"count", found.item.count}, + {"inventory", InventoryType::InventoryTypeToString(found.item.inventory)}, {"reason", found.entry.reason}, + {"action", found.entry.action == IContraband::eContrabandAction::REMOVE ? "remove" : "flag"} }); + } + if (!check.warnings.empty() && !body->value("confirm", false)) { + return JsonReply(reply, eHTTPStatusCode::CONFLICT, { {"success", false}, {"confirm_required", true}, + {"error", "Check the warnings and confirm to store this XML"}, {"warnings", check.warnings}, {"contraband", contraband} }); + } + + // Contraband marked "flag and remove" only goes when the uploader chose so + const bool removeContraband = body->value("remove_contraband", false); + std::set removeIds; + if (removeContraband) for (const auto& found : check.contraband) if (found.entry.action == IContraband::eContrabandAction::REMOVE) removeIds.insert(found.item.id); // Store compactly like the game does + tinyxml2::XMLDocument doc; + doc.Parse(xml.c_str()); tinyxml2::XMLPrinter printer(nullptr, true); doc.Print(&printer); - const std::string compact = printer.CStr(); + const std::string compact = removeIds.empty() ? std::string(printer.CStr()) : CharacterXmlCheck::RemoveItems(printer.CStr(), removeIds); - // Through the safe path: the owner is disconnected first and the current version is kept as a snapshot + // Through the safe path: the owner is disconnected first and the current version is kept as a snapshot. + // Once stored, what the checks found is flagged and audited like the world's own contraband check. + const auto target = *charId; + const auto ownerId = *owner; + const auto actor = context.authenticatedUser; + const auto actorId = context.accountId; + const auto findings = check.contraband; + const auto warnings = check.warnings; const auto requestId = WriteCharacterXml(*charId, *owner, context.authenticatedUser, context.accountId, "XML upload", - [compact](const std::string&, std::string&) { return std::optional(compact); }); - JsonSuccess(reply, { {"requestId", requestId}, {"message", "Uploading"} }); + [compact](const std::string&, std::string&) { return std::optional(compact); }, nullptr, + [target, ownerId, actor, actorId, findings, warnings, removeIds](const PlayerActions::Outcome& outcome) { + if (!outcome.success || warnings.empty()) return; + const auto today = static_cast(std::time(nullptr) / 86400); + for (const auto& found : findings) { + const bool removed = removeIds.contains(found.item.id); + Database::Get()->InsertEconomyFlag({ today, IDashboardAdmin::eFlagKind::CONTRABAND, target, found.item.lot, found.item.id, found.item.count, removed ? 1 : 0, + std::string(removed ? "Removed from" : "Kept in") + " an uploaded character XML (" + InventoryType::InventoryTypeToString(found.item.inventory) + ", by " + actor + "): " + found.entry.reason }); + } + std::string text = "Character " + std::to_string(target) + " XML uploaded with " + std::to_string(warnings.size()) + " warning(s)" + + (removeIds.empty() ? "" : ", " + std::to_string(removeIds.size()) + " contraband item stack(s) removed") + ":"; + for (const auto& warning : warnings) text += "\n- " + warning; + Database::Get()->InsertAuditLog(actorId, actor, "character_xml_warnings", text, ownerId, target); + }); + JsonSuccess(reply, { {"requestId", requestId}, {"message", "Uploading"}, {"warnings", check.warnings}, {"removed", removeIds.size()} }); }); Route(eHTTPMethod::GET, "/api/characters/:id/mail", 0, "A character's mailbox (up to 100 most recent). With characters_mail, or the character's owner", diff --git a/dDashboardServer/routes/CharacterXmlCheck.h b/dDashboardServer/routes/CharacterXmlCheck.h new file mode 100644 index 000000000..69ffe9cbf --- /dev/null +++ b/dDashboardServer/routes/CharacterXmlCheck.h @@ -0,0 +1,380 @@ +#pragma once + +#include +#include +#include +#include +#include +#include +#include + +#include "tinyxml2.h" +#include "ContrabandRules.h" +#include "GeneralUtils.h" +#include "dCommonVars.h" +#include "eCharacterVersion.h" +#include "eInventoryType.h" + +/** + * Checks a character XML uploaded on the dashboard before it is stored. That upload is the one place hand-written + * XML reaches the game, and the game's load path (Character::DoQuickXMLDataParse and the LoadFromXml/LoadXml of the + * Character, Inventory, Mission, Destroyable, Buff, LevelProgression, ControllablePhysics and Character components) + * trusts the XML because the server writes it itself. So instead of the game skipping bad data at load (and saving + * the loss back), bad uploads are refused here. + * + * - errors: things the load path would throw on, read uninitialized, stop loading at (dropping everything after it, + * which is then saved back), or silently drop (an unknown mission or item, a duplicate item id or slot). + * The upload is refused. + * - warnings: valid but suspicious content (contraband, stacks above the item's stack size, coins, level or u-score + * out of reach, GM level above the account's). Staff see them and must confirm to store the XML. + * + * Pure (the game data it needs comes in through Lookups) so it can be unit tested. + */ +namespace CharacterXmlCheck { + constexpr int64_t MAX_COINS = 2000000000; + constexpr int64_t MAX_COUNT = 999999; + constexpr size_t MAX_REPORTED = 50; + + // Game data from the CDClient; an unset lookup skips its check + struct Lookups { + std::function isItem; // the LOT has an item component + std::function stackSize; // the item's stack size, 0 if unknown + std::function missionExists; // the mission is in the Missions table + std::function(uint32_t)> levelUScore; // u-score needed to reach a level + uint32_t maxLevel = 0; // highest level (0: unknown) + }; + + struct Context { + uint32_t ownerAccountId = 0; // the account of the character being replaced + int32_t accountGmLevel = 0; // that account's GM level + bool contrabandApplies = true; // Contraband::Applies for that account + Contraband::List contraband; + Lookups lookups; + }; + + struct Result { + std::vector errors; + std::vector warnings; + std::vector contraband; + bool Ok() const { return errors.empty(); } + }; + + namespace Detail { + enum class eKind { INT, UINT, INT64, UINT64, FLOAT, BOOL }; + + inline const char* KindName(eKind kind) { + switch (kind) { + case eKind::INT: return "a whole number"; + case eKind::UINT: return "a non-negative whole number"; + case eKind::INT64: return "a whole number"; + case eKind::UINT64: return "a non-negative whole number"; + case eKind::FLOAT: return "a number"; + case eKind::BOOL: return "true/false or 1/0"; + } + return "a number"; + } + + // Parses like the game's tinyxml2 Query*Attribute calls + inline bool Parses(const tinyxml2::XMLElement& e, const char* name, eKind kind) { + int i; unsigned u; int64_t i64; uint64_t u64; float f; bool b; + switch (kind) { + case eKind::INT: return e.QueryIntAttribute(name, &i) == tinyxml2::XML_SUCCESS; + case eKind::UINT: return e.QueryUnsignedAttribute(name, &u) == tinyxml2::XML_SUCCESS && e.Attribute(name)[0] != '-'; + case eKind::INT64: return e.QueryInt64Attribute(name, &i64) == tinyxml2::XML_SUCCESS; + case eKind::UINT64: return e.QueryUnsigned64Attribute(name, &u64) == tinyxml2::XML_SUCCESS && e.Attribute(name)[0] != '-'; + case eKind::FLOAT: return e.QueryFloatAttribute(name, &f) == tinyxml2::XML_SUCCESS; + case eKind::BOOL: return e.QueryBoolAttribute(name, &b) == tinyxml2::XML_SUCCESS; + } + return false; + } + + struct Checker { + Result& result; + const Context& context; + + void Error(const std::string& text) { if (result.errors.size() < MAX_REPORTED) result.errors.push_back(text); } + void Warn(const std::string& text) { if (result.warnings.size() < MAX_REPORTED) result.warnings.push_back(text); } + + // Optional attributes: when present they must parse + void Numbers(const tinyxml2::XMLElement& e, const std::string& where, eKind kind, std::initializer_list names) { + for (const auto* name : names) { + const char* value = e.Attribute(name); + if (value && !Parses(e, name, kind)) Error(where + ": " + name + "=\"" + value + "\" must be " + KindName(kind)); + } + } + + // Required attributes: the game reads them into uninitialized variables + bool Required(const tinyxml2::XMLElement& e, const std::string& where, eKind kind, std::initializer_list names) { + bool ok = true; + for (const auto* name : names) { + if (!e.Attribute(name)) { Error(where + ": " + name + " is missing"); ok = false; } + } + Numbers(e, where, kind, names); + for (const auto* name : names) if (e.Attribute(name) && !Parses(e, name, kind)) ok = false; + return ok; + } + + static int64_t Int64(const tinyxml2::XMLElement& e, const char* name) { return e.Int64Attribute(name, 0); } + static std::string Str(int64_t value) { return std::to_string(value); } + + static bool KnownInventory(uint32_t type) { return type <= eInventoryType::ITEM_SETS; } + static bool KnownMissionState(int64_t state) { + switch (state) { + case -1: case 0: case 1: case 2: case 4: case 8: case 9: case 10: case 12: case 16: return true; + default: return false; + } + } + + void Mf(const tinyxml2::XMLElement& mf) { + Numbers(mf, "", eKind::UINT, { "hc", "hs", "hd", "t", "l", "hdc", "cd", "lh", "rh", "es", "ess", "ms" }); + } + + void Char(const tinyxml2::XMLElement& c) { + const std::string where = ""; + Numbers(c, where, eKind::INT64, { "cc", "rpt", "ls", "lrid", "gid", "llog" }); + Numbers(c, where, eKind::INT, { "gm", "ft" }); + Numbers(c, where, eKind::UINT, { "lwid", "lnzid", "acct" }); + Numbers(c, where, eKind::UINT64, { "lzid", "co", "co1", "co2", "co3", "time" }); + Numbers(c, where, eKind::FLOAT, { "lzx", "lzy", "lzz", "lzrx", "lzry", "lzrz", "lzrw" }); + + if (c.Attribute("acct") && Parses(c, "acct", eKind::UINT) && c.UnsignedAttribute("acct") != context.ownerAccountId) { + Error(where + ": acct=\"" + c.Attribute("acct") + "\" is not the account of this character (" + std::to_string(context.ownerAccountId) + ")"); + } + if (c.Attribute("gm") && Parses(c, "gm", eKind::INT)) { + const auto gm = c.IntAttribute("gm"); + if (gm < 0 || gm > 9) Error(where + ": gm=\"" + std::to_string(gm) + "\" is not a GM level (0 to 9)"); + else if (gm > context.accountGmLevel) Warn("GM level " + std::to_string(gm) + " in the XML is above the account's (" + std::to_string(context.accountGmLevel) + ")"); + } + if (c.Attribute("cc") && Parses(c, "cc", eKind::INT64)) { + const auto coins = Int64(c, "cc"); + if (coins < 0) Error(where + ": cc (coins) can't be negative"); + else if (coins > MAX_COINS) Warn("Coins " + Str(coins) + " are above " + Str(MAX_COINS)); + } + if (c.Attribute("ls") && Parses(c, "ls", eKind::INT64) && Int64(c, "ls") < 0) Error(where + ": ls (u-score) can't be negative"); + + if (const auto* ue = c.FirstChildElement("ue")) { + for (const auto* e = ue->FirstChildElement(); e; e = e->NextSiblingElement()) Required(*e, " emote", eKind::INT, { "id" }); + } + if (const auto* vl = c.FirstChildElement("vl")) { + for (const auto* l = vl->FirstChildElement("l"); l; l = l->NextSiblingElement("l")) Numbers(*l, "", eKind::INT, { "id", "cid" }); + } + if (const auto* zs = c.FirstChildElement("zs")) { + for (const auto* s = zs->FirstChildElement(); s; s = s->NextSiblingElement()) { + Required(*s, " zone statistics", eKind::UINT, { "map" }); + Numbers(*s, " zone statistics", eKind::UINT64, { "ac", "cc", "es", "qbc" }); + Numbers(*s, " zone statistics", eKind::INT64, { "bc" }); + } + } + } + + void Flags(const tinyxml2::XMLElement& flags) { + for (const auto* f = flags.FirstChildElement(); f; f = f->NextSiblingElement()) { + const char* id = f->Attribute("id"); + const char* v = f->Attribute("v"); + // Character::DoQuickXMLDataParse reads both with std::stoul/std::stoull, which throw + if (id && v) { + if (!GeneralUtils::TryParse(id)) Error(std::string(": id=\"") + id + "\" must be a non-negative whole number"); + if (!GeneralUtils::TryParse(v)) Error(std::string(": v=\"") + v + "\" must be a non-negative whole number"); + } + } + } + + void Dest(const tinyxml2::XMLElement& dest) { + Numbers(dest, "", eKind::INT, { "hc", "ic", "ac" }); + Numbers(dest, "", eKind::FLOAT, { "hm", "im", "am" }); + if (const auto* buff = dest.FirstChildElement("buff")) { + for (const auto* b = buff->FirstChildElement("b"); b; b = b->NextSiblingElement("b")) { + Numbers(*b, "", eKind::INT, { "id", "b", "refCount" }); + Numbers(*b, "", eKind::FLOAT, { "t", "tk", "tt", "s" }); + Numbers(*b, "", eKind::INT64, { "sr" }); + Numbers(*b, "", eKind::BOOL, { "cancelOnDamaged", "cancelOnDeath", "cancelOnLogout", "cancelOnRemoveBuff", "cancelOnUi", "cancelOnUnequip", "cancelOnZone", "applyOnTeammates" }); + } + } + } + + void Level(const tinyxml2::XMLElement& lvl, int64_t uscore) { + Numbers(lvl, "", eKind::UINT, { "l", "cv" }); + Numbers(lvl, "", eKind::FLOAT, { "sb" }); + if (lvl.Attribute("cv") && Parses(lvl, "cv", eKind::UINT) && lvl.UnsignedAttribute("cv") > GeneralUtils::ToUnderlying(eCharacterVersion::UP_TO_DATE)) { + Error(": cv=\"" + std::string(lvl.Attribute("cv")) + "\" is newer than this server's character version (" + std::to_string(GeneralUtils::ToUnderlying(eCharacterVersion::UP_TO_DATE)) + ")"); + } + if (!lvl.Attribute("l") || !Parses(lvl, "l", eKind::UINT)) return; + const auto level = lvl.UnsignedAttribute("l"); + const auto& lookups = context.lookups; + if (lookups.maxLevel > 0 && level > lookups.maxLevel) { + Warn("Level " + std::to_string(level) + " is above the highest level (" + std::to_string(lookups.maxLevel) + ")"); + return; + } + if (!lookups.levelUScore || uscore < 0) return; + const auto needed = lookups.levelUScore(level); + if (needed && uscore < *needed) Warn("Level " + std::to_string(level) + " needs " + Str(*needed) + " u-score but the character has " + Str(uscore)); + const auto next = level < lookups.maxLevel ? lookups.levelUScore(level + 1) : std::nullopt; + if (next && uscore >= *next) Warn("U-score " + Str(uscore) + " is enough for level " + std::to_string(level + 1) + " but the character is level " + std::to_string(level)); + } + + void Inventory(const tinyxml2::XMLElement& inv) { + Numbers(inv, "", eKind::INT, { "csl" }); + if (const auto* grps = inv.FirstChildElement("grps")) { + for (const auto* g = grps->FirstChildElement("grp"); g; g = g->NextSiblingElement("grp")) Numbers(*g, "", eKind::UINT, { "t" }); + } + const auto* bag = inv.FirstChildElement("bag"); + for (const auto* b = bag->FirstChildElement(); b; b = b->NextSiblingElement()) { + if (Required(*b, "", eKind::UINT, { "t", "m" }) && !KnownInventory(b->UnsignedAttribute("t"))) { + Error(": t=\"" + std::string(b->Attribute("t")) + "\" is not an inventory type"); + } + } + + std::set ids; + std::map> slots; + std::map stackSizes; + const auto& lookups = context.lookups; + for (const auto* in = inv.FirstChildElement("items")->FirstChildElement(); in; in = in->NextSiblingElement()) { + if (!Required(*in, "", eKind::UINT, { "t" })) continue; + const auto type = in->UnsignedAttribute("t"); + if (!KnownInventory(type)) { + Error(": t=\"" + std::to_string(type) + "\" is not an inventory type"); + continue; + } + const auto inventoryName = std::string(InventoryType::InventoryTypeToString(static_cast(type))); + // Items in the build inventories are moved (and given new slots) when the character loads + const bool reslotted = type == eInventoryType::MODELS_IN_BBB || type == eInventoryType::BRICKS_IN_BBB; + for (const auto* i = in->FirstChildElement(); i; i = i->NextSiblingElement()) { + const std::string where = "Item in " + inventoryName + (i->Attribute("l") ? std::string(" (LOT ") + i->Attribute("l") + ")" : std::string()); + bool ok = Required(*i, where, eKind::INT64, { "id" }); + ok = Required(*i, where, eKind::INT, { "l" }) && ok; + ok = Required(*i, where, eKind::UINT, { "s", "c" }) && ok; + ok = Required(*i, where, eKind::BOOL, { "eq", "b" }) && ok; + Numbers(*i, where, eKind::INT64, { "sk", "parent" }); + if (!ok) continue; + + const auto id = i->Int64Attribute("id"); + const LOT lot = i->IntAttribute("l"); + const auto slot = i->UnsignedAttribute("s"); + const auto count = i->UnsignedAttribute("c"); + if (id == LWOOBJID_EMPTY) Error(where + ": id can't be 0"); + else if (!ids.insert(id).second) Error(where + ": id " + std::to_string(id) + " is used by another item (the game would drop one)"); + if (!reslotted && !slots[type].insert(slot).second) Error(where + ": slot " + std::to_string(slot) + " is used by another item in " + inventoryName + " (the game would drop one)"); + if (lookups.isItem && !lookups.isItem(lot)) { + Error(where + ": LOT " + std::to_string(lot) + " is not an item"); + continue; + } + if (count == 0) Warn(where + ": count is 0"); + else if (count > MAX_COUNT) Warn(where + ": count " + std::to_string(count) + " is above " + std::to_string(MAX_COUNT)); + else if (lookups.stackSize) { + auto it = stackSizes.find(lot); + if (it == stackSizes.end()) it = stackSizes.emplace(lot, lookups.stackSize(lot)).first; + if (it->second > 0 && count > static_cast(it->second)) Warn(where + ": count " + std::to_string(count) + " is above the stack size (" + std::to_string(it->second) + ")"); + } + // Proxy items of item sets aren't what the player holds, like the world's check + if (i->Int64Attribute("parent", LWOOBJID_EMPTY) == LWOOBJID_EMPTY) held.push_back({ id, lot, count, static_cast(type) }); + } + } + } + + void Pets(const tinyxml2::XMLElement& pet) { + for (const auto* p = pet.FirstChildElement(); p; p = p->NextSiblingElement()) { + Numbers(*p, " pet", eKind::INT64, { "id" }); + Numbers(*p, " pet", eKind::INT, { "l", "m", "t" }); + } + } + + void Missions(const tinyxml2::XMLElement& mis) { + const auto& lookups = context.lookups; + const auto mission = [&](const tinyxml2::XMLElement& m, const char* where, bool done) { + if (!Required(m, where, eKind::INT, { "id" })) return; + const auto id = m.IntAttribute("id"); + const std::string name = std::string(where) + " " + std::to_string(id); + if (lookups.missionExists && !lookups.missionExists(id)) Error(name + ": no such mission (the game would lose it)"); + Numbers(m, name, eKind::INT, { "state" }); + Numbers(m, name, eKind::UINT, { "cct", "cts", "o" }); + if (m.Attribute("state") && Parses(m, "state", eKind::INT)) { + const auto state = m.IntAttribute("state"); + if (!KnownMissionState(state)) Error(name + ": state " + std::to_string(state) + " is not a mission state"); + else if (done && state < 8) Warn(name + " is in the completed list but its state (" + std::to_string(state) + ") is not complete"); + } + if (!done) for (const auto* t = m.FirstChildElement(); t; t = t->NextSiblingElement()) Numbers(*t, name + " task", eKind::UINT, { "v" }); + }; + if (const auto* done = mis.FirstChildElement("done")) for (const auto* m = done->FirstChildElement(); m; m = m->NextSiblingElement()) mission(*m, "Completed mission", true); + if (const auto* cur = mis.FirstChildElement("cur")) for (const auto* m = cur->FirstChildElement(); m; m = m->NextSiblingElement()) mission(*m, "Current mission", false); + } + + void Respawns(const tinyxml2::XMLElement& res) { + for (const auto* r = res.FirstChildElement("r"); r; r = r->NextSiblingElement("r")) { + Numbers(*r, "", eKind::INT, { "w" }); + Numbers(*r, "", eKind::FLOAT, { "x", "y", "z" }); + } + } + + std::vector held; + }; + } + + inline Result Check(const std::string& xml, const Context& context) { + Result result; + Detail::Checker check{ result, context }; + tinyxml2::XMLDocument doc; + if (xml.empty() || doc.Parse(xml.c_str()) != tinyxml2::XML_SUCCESS) { + check.Error(xml.empty() ? "The XML is empty" : std::string("That is not valid XML: ") + (doc.ErrorStr() ? doc.ErrorStr() : "parse error")); + return result; + } + const auto* obj = doc.FirstChildElement("obj"); + if (!obj) { + check.Error("The root element must be "); + return result; + } + + // Loading stops at the first of these that is missing; what would have been read after it is lost when the character is saved + const auto* character = obj->FirstChildElement("char"); + const auto* mf = obj->FirstChildElement("mf"); + const auto* inv = obj->FirstChildElement("inv"); + const auto* items = inv ? inv->FirstChildElement("items") : nullptr; + if (!character) check.Error(" must have a element"); + if (!mf) check.Error(" must have an (appearance) element"); + if (!inv) check.Error(" must have an element"); + if (inv && !inv->FirstChildElement("bag")) check.Error(" must have a element"); + if (inv && (!items || !items->FirstChildElement("in"))) check.Error(" must have an element with at least one "); + + if (mf) check.Mf(*mf); + int64_t uscore = -1; + if (character) { + check.Char(*character); + if (character->Attribute("ls") && Detail::Parses(*character, "ls", Detail::eKind::INT64)) uscore = character->Int64Attribute("ls"); + else uscore = 0; + } + if (const auto* flags = obj->FirstChildElement("flag")) check.Flags(*flags); + if (const auto* dest = obj->FirstChildElement("dest")) check.Dest(*dest); + if (const auto* lvl = obj->FirstChildElement("lvl")) check.Level(*lvl, uscore); + if (inv && inv->FirstChildElement("bag") && items) check.Inventory(*inv); + if (const auto* pet = obj->FirstChildElement("pet")) check.Pets(*pet); + if (const auto* mis = obj->FirstChildElement("mis")) check.Missions(*mis); + if (const auto* res = obj->FirstChildElement("res")) check.Respawns(*res); + + if (context.contrabandApplies) { + result.contraband = Contraband::Find(check.held, context.contraband); + for (const auto& found : result.contraband) { + const bool remove = found.entry.action == IContraband::eContrabandAction::REMOVE; + check.Warn("Contraband: " + std::to_string(found.item.count) + " of item " + std::to_string(found.item.lot) + " (id " + std::to_string(found.item.id) + ") in " + + InventoryType::InventoryTypeToString(found.item.inventory) + (remove ? " [flag and remove]" : " [flag]") + (found.entry.reason.empty() ? "" : ": " + found.entry.reason)); + } + } + return result; + } + + // The XML without the items with these ids (and without set proxy items whose parent is one of them), printed compactly + inline std::string RemoveItems(const std::string& xml, const std::set& ids) { + tinyxml2::XMLDocument doc; + if (doc.Parse(xml.c_str()) != tinyxml2::XML_SUCCESS) return xml; + auto* inv = doc.FirstChildElement("obj") ? doc.FirstChildElement("obj")->FirstChildElement("inv") : nullptr; + auto* items = inv ? inv->FirstChildElement("items") : nullptr; + for (auto* in = items ? items->FirstChildElement() : nullptr; in; in = in->NextSiblingElement()) { + for (auto* i = in->FirstChildElement(); i;) { + auto* next = i->NextSiblingElement(); + if (ids.contains(i->Int64Attribute("id")) || ids.contains(i->Int64Attribute("parent", LWOOBJID_EMPTY))) in->DeleteChild(i); + i = next; + } + } + tinyxml2::XMLPrinter printer(nullptr, true); + doc.Print(&printer); + return printer.CStr(); + } +} diff --git a/dDashboardServer/templates/character-view.jinja2 b/dDashboardServer/templates/character-view.jinja2 index f8073a583..e68b943f3 100644 --- a/dDashboardServer/templates/character-view.jinja2 +++ b/dDashboardServer/templates/character-view.jinja2 @@ -335,11 +335,13 @@ @@ -575,16 +577,40 @@ function prettyXml(xml) { function openXmlEditor() { var editor = document.getElementById('xmlEditor'); editor.value = 'Loading…'; + showXmlFindings({}); + editor.oninput = function () { document.getElementById('xmlConfirm').classList.add('d-none'); }; // changed XML is checked again new bootstrap.Modal(document.getElementById('xmlModal')).show(); api.get('/api/characters/' + characterId + '/xml').then(function (r) { editor.value = prettyXml(r.text || ''); }); } -function saveXml() { - if (!confirm('Replace this character\'s data?')) return; - api.action('/api/characters/' + characterId + '/xml', { xml: document.getElementById('xmlEditor').value }).then(function (d) { - return d.result; - }).then(function (r) { - if (r && r.success) location.reload(); +// The server checks the XML first: problems the game can't load refuse it, suspicious content (contraband and values +// out of reach) needs "Save anyway" +function showXmlFindings(d) { + var box = document.getElementById('xmlFindings'); + var list = function (items) { return '
    ' + items.map(function (t) { return '
  • ' + esc(t) + '
  • '; }).join('') + '
'; }; + var html = ''; + if (d.errors && d.errors.length) html += '
' + esc(d.error || 'The XML was not stored') + '' + list(d.errors) + '
'; + if (d.warnings && d.warnings.length) html += '
Warnings' + list(d.warnings) + + ((d.contraband || []).some(function (c) { return c.action === 'remove'; }) + ? '
' + : '') + '
Contraband is flagged and the warnings are audited either way.
'; + box.innerHTML = html; + box.classList.toggle('d-none', !html); + document.getElementById('xmlConfirm').classList.toggle('d-none', !d.confirm_required); + box.scrollIntoView({ block: 'nearest' }); +} + +function saveXml(confirmed) { + if (!confirmed && !confirm('Replace this character\'s data?')) return; + var remove = document.getElementById('xmlRemoveContraband'); + api.post('/api/characters/' + characterId + '/xml', { xml: document.getElementById('xmlEditor').value, confirm: !!confirmed, remove_contraband: !!(remove && remove.checked) }).then(function (d) { + if (!d.success) { showXmlFindings(d); if (!d.errors && !d.confirm_required) toast(d.error || 'Request failed', 'danger'); return; } + showXmlFindings({}); + if (!d.requestId) return; + Live.waitForAction(d.requestId).then(function (r) { + if (r.message) toast(r.message, r.success ? 'info' : 'danger'); + if (r.success) location.reload(); + }); }).catch(function () {}); } diff --git a/dDatabase/GameDatabase/ITables/ContrabandRules.h b/dDatabase/GameDatabase/ITables/ContrabandRules.h new file mode 100644 index 000000000..7fdd2712d --- /dev/null +++ b/dDatabase/GameDatabase/ITables/ContrabandRules.h @@ -0,0 +1,55 @@ +#ifndef __CONTRABANDRULES__H__ +#define __CONTRABANDRULES__H__ + +#include +#include +#include +#include + +#include "dCommonVars.h" +#include "eGameMasterLevel.h" +#include "eInventoryType.h" +#include "IContraband.h" + +/** + * The pure contraband rules, shared by world servers (Contraband.h, checking characters as they load and receive + * items) and the dashboard (checking an uploaded character XML), so both find the same items. + */ +namespace Contraband { + struct Entry { + std::string reason; + IContraband::eContrabandAction action{}; + }; + + using List = std::map; + + struct HeldItem { + LWOOBJID id{}; + LOT lot{}; + uint32_t count{}; + eInventoryType inventory{}; + }; + + struct Finding { + HeldItem item; + Entry entry; + }; + + // The held items that are on the list, in the order given + inline std::vector Find(const std::vector& items, const List& list) { + std::vector found; + if (list.empty()) return found; + for (const auto& item : items) { + const auto it = list.find(item.lot); + if (it != list.end() && item.count > 0) found.push_back({ item, it->second }); + } + return found; + } + + // Whether an account at this GM level is checked + inline bool Applies(eGameMasterLevel accountLevel, bool ignoreStaff) { + return !ignoreStaff || accountLevel <= eGameMasterLevel::CIVILIAN; + } +} + +#endif //!__CONTRABANDRULES__H__ diff --git a/dGame/dUtilities/Contraband.cpp b/dGame/dUtilities/Contraband.cpp index d1cffb81d..b5ae06331 100644 --- a/dGame/dUtilities/Contraband.cpp +++ b/dGame/dUtilities/Contraband.cpp @@ -85,20 +85,6 @@ namespace { } namespace Contraband { - std::vector Find(const std::vector& items, const List& list) { - std::vector found; - if (list.empty()) return found; - for (const auto& item : items) { - const auto it = list.find(item.lot); - if (it != list.end() && item.count > 0) found.push_back({ item, it->second }); - } - return found; - } - - bool Applies(eGameMasterLevel accountLevel, bool ignoreStaff) { - return !ignoreStaff || accountLevel <= eGameMasterLevel::CIVILIAN; - } - bool CountsAsAdded(eLootSourceType source, eInventoryType sourceInventory) { return sourceInventory == eInventoryType::INVALID && source != eLootSourceType::RELOCATE && source != eLootSourceType::INVENTORY; } diff --git a/dGame/dUtilities/Contraband.h b/dGame/dUtilities/Contraband.h index ab00eb03e..0e74bf096 100644 --- a/dGame/dUtilities/Contraband.h +++ b/dGame/dUtilities/Contraband.h @@ -9,10 +9,9 @@ #include "dCommonVars.h" #include "eInventoryType.h" #include "eLootSourceType.h" -#include "IContraband.h" +#include "ContrabandRules.h" class Entity; -enum class eGameMasterLevel : uint8_t; /** * Contraband (issue #1563): items staff don't want players to have, listed on the dashboard's Contraband page @@ -29,32 +28,7 @@ enum class eGameMasterLevel : uint8_t; * Staff accounts (GM level above civilian) are not checked unless contraband_ignore_staff is 0. */ namespace Contraband { - struct Entry { - std::string reason; - IContraband::eContrabandAction action{}; - }; - - using List = std::map; - - struct HeldItem { - LWOOBJID id{}; - LOT lot{}; - uint32_t count{}; - eInventoryType inventory{}; - }; - - struct Finding { - HeldItem item; - Entry entry; - }; - - // ---- Pure rules, unit tested ---- - - // The held items that are on the list, in the order given - std::vector Find(const std::vector& items, const List& list); - - // Whether an account at this GM level is checked - bool Applies(eGameMasterLevel accountLevel, bool ignoreStaff); + // ---- Pure rules, unit tested (Entry, List, HeldItem, Finding, Find and Applies are in ContrabandRules.h) ---- // Whether an added item should be checked: moves between a player's own inventories are not new items bool CountsAsAdded(eLootSourceType source, eInventoryType sourceInventory); diff --git a/docs/Dashboard.md b/docs/Dashboard.md index 939ef0259..12e02461a 100644 --- a/docs/Dashboard.md +++ b/docs/Dashboard.md @@ -680,6 +680,14 @@ shows under the account's related data); that world saves nothing more for the c connected to it is disconnected (the client's save failure message) so they load the newer data. - **Edit** (GM 8+, `characters_edit`): coins, U-score, level, and adding or removing items. +- **Edit XML** (GM 8+, `characters_edit_xml`, needs `enable_char_xml_upload=1`): replace the whole character XML. The + upload is checked against everything the game assumes when it loads a character (required elements, numbers that + must parse, known inventory types and mission states, items and missions that exist, unique item IDs and slots, the + `acct` attribute matching the owner) and refused with the list of problems if the game couldn't load it. Suspicious + content (contraband, stacks above the item's stack size, coins, level or U-score out of reach, a GM level above the + account's) is shown as warnings and needs **Save anyway**; contraband marked *flag and remove* is taken out only if + you tick the box, and either way it is flagged (kind *Contraband*) and the warnings are audited + (`character_xml_warnings`). - **History** (GM 3+, `characters_history`): earlier versions of the character. Each one shows what differs from now, can be downloaded as XML, and (with `characters_edit`) restored. The `character_snapshots` task saves every character that changed once a day; old snapshots are removed after `snapshot_days` (90), but each character always diff --git a/tests/dWebTests/CMakeLists.txt b/tests/dWebTests/CMakeLists.txt index 40b13c1b6..3fcd361ba 100644 --- a/tests/dWebTests/CMakeLists.txt +++ b/tests/dWebTests/CMakeLists.txt @@ -17,6 +17,7 @@ set(DWEBTESTS_SOURCES "SettingsCatalogTests.cpp" "BehaviorXmlTests.cpp" "CharacterXmlTests.cpp" + "CharacterXmlCheckTests.cpp" "CharacterRestoreTests.cpp" "ZonePathsTests.cpp" "RawTerrainTests.cpp" diff --git a/tests/dWebTests/CharacterXmlCheckTests.cpp b/tests/dWebTests/CharacterXmlCheckTests.cpp new file mode 100644 index 000000000..b2d8e278d --- /dev/null +++ b/tests/dWebTests/CharacterXmlCheckTests.cpp @@ -0,0 +1,174 @@ +#include + +#include + +#include "CharacterXmlCheck.h" + +namespace { + // Shaped like what the server writes (UserManager's new character plus what the components save), made-up values + const std::string VALID = + "" + "" + "" + "" + "" + "" + "" + "" + "" + "" + "" + "

" + "" + ""; + + CharacterXmlCheck::Context MakeContext() { + CharacterXmlCheck::Context context; + context.ownerAccountId = 7; + auto& l = context.lookups; + l.isItem = [](LOT lot) { return lot == 1001 || lot == 1002 || lot == 2000 || lot == 6666; }; + l.stackSize = [](LOT lot) { return lot == 2000 ? 999 : 1; }; + l.missionExists = [](int32_t id) { return id == 1 || id == 2; }; + l.levelUScore = [](uint32_t level) -> std::optional { + if (level == 1) return 0; + if (level == 2) return 100; + if (level == 3) return 300; + return std::nullopt; + }; + l.maxLevel = 3; + return context; + } + + std::string Replace(std::string text, const std::string& from, const std::string& to) { + const auto at = text.find(from); + EXPECT_NE(at, std::string::npos) << from; + if (at != std::string::npos) text.replace(at, from.size(), to); + return text; + } + + bool Mentions(const std::vector& list, const std::string& part) { + return std::any_of(list.begin(), list.end(), [&](const std::string& s) { return s.find(part) != std::string::npos; }); + } + + CharacterXmlCheck::Result Check(const std::string& xml) { return CharacterXmlCheck::Check(xml, MakeContext()); } + + void ExpectError(const std::string& xml, const std::string& part) { + const auto result = Check(xml); + EXPECT_FALSE(result.Ok()) << part; + EXPECT_TRUE(Mentions(result.errors, part)) << "expected an error mentioning: " << part << (result.errors.empty() ? "" : " got: " + result.errors.front()); + } + + void ExpectWarning(const std::string& xml, const std::string& part) { + const auto result = Check(xml); + EXPECT_TRUE(result.Ok()) << (result.errors.empty() ? "" : result.errors.front()); + EXPECT_TRUE(Mentions(result.warnings, part)) << "expected a warning mentioning: " << part; + } +} + +TEST(CharacterXmlCheckTests, ValidCharacterPasses) { + const auto result = Check(VALID); + EXPECT_TRUE(result.errors.empty()) << (result.errors.empty() ? "" : result.errors.front()); + EXPECT_TRUE(result.warnings.empty()) << (result.warnings.empty() ? "" : result.warnings.front()); +} + +TEST(CharacterXmlCheckTests, Structure) { + ExpectError("", "empty"); + ExpectError("", "not valid XML"); + ExpectError("", ""); + ExpectError(Replace(Replace(VALID, "", ""), ""); + ExpectError(Replace(VALID, ""); + ExpectError(Replace(Replace(VALID, "", ""), "", ""), ""); + ExpectError(Replace(VALID, VALID.substr(VALID.find(""), VALID.find("") - VALID.find("")), ""), "at least one "); + ExpectError(Replace(Replace(VALID, "", ""), ""); +} + +TEST(CharacterXmlCheckTests, Numbers) { + ExpectError(Replace(VALID, "cc=\"150\"", "cc=\"lots\""), "cc=\"lots\""); + ExpectError(Replace(VALID, "cc=\"150\"", "cc=\"-5\""), "negative"); + ExpectError(Replace(VALID, "ls=\"150\"", "ls=\"-1\""), "negative"); + ExpectError(Replace(VALID, "lzx=\"-626.5\"", "lzx=\"west\""), "lzx"); + ExpectError(Replace(VALID, "hc=\"1\"", "hc=\"red\""), ""); + ExpectError(Replace(VALID, "hm=\"4\"", "hm=\"full\""), ""); + ExpectError(Replace(VALID, "refCount=\"1\"", "refCount=\"x\""), ""); + ExpectError(Replace(VALID, ""); + ExpectError(Replace(VALID, "", ""), "emote: id is missing"); + ExpectError(Replace(VALID, "map=\"1000\" ", ""), "map is missing"); + ExpectError(Replace(VALID, ""); + ExpectError(Replace(VALID, "l=\"2\" cv", "l=\"two\" cv"), ""); + ExpectError(Replace(VALID, "csl=\"-1\"", "csl=\"none\""), "csl"); + ExpectError(Replace(VALID, "

"); +} + +TEST(CharacterXmlCheckTests, Flags) { + // The game reads these with std::stoul/std::stoull, which throw + ExpectError(Replace(VALID, ": id"); + ExpectError(Replace(VALID, "v=\"18446744073709551615\"", "v=\"18446744073709551616\""), ": v"); + ExpectError(Replace(VALID, ": id"); +} + +TEST(CharacterXmlCheckTests, Enums) { + ExpectError(Replace(VALID, "gm=\"0\"", "gm=\"12\""), "not a GM level"); + ExpectError(Replace(VALID, "cv=\"1\"", "cv=\"99\""), "newer than"); + ExpectError(Replace(VALID, "", ""), "not an inventory type"); + ExpectError(Replace(VALID, "state=\"2\"", "state=\"3\""), "not a mission state"); +} + +TEST(CharacterXmlCheckTests, Items) { + ExpectError(Replace(VALID, " s=\"1\" c=\"1\"", " c=\"1\""), "s is missing"); + ExpectError(Replace(VALID, "c=\"500\" eq=\"0\"", "c=\"500\""), "eq is missing"); + ExpectError(Replace(VALID, "id=\"101\"", "id=\"abc\""), "id=\"abc\""); + ExpectError(Replace(VALID, "id=\"101\"", "id=\"0\""), "id can't be 0"); + ExpectError(Replace(VALID, "id=\"101\"", "id=\"100\""), "used by another item"); + ExpectError(Replace(VALID, "id=\"101\" s=\"1\"", "id=\"101\" s=\"0\""), "slot 0"); + ExpectError(Replace(VALID, "l=\"1002\"", "l=\"4242\""), "not an item"); + ExpectError(Replace(VALID, "c=\"500\"", "c=\"-3\""), "c=\"-3\""); + // Build inventories are re-slotted when loading, so shared slots there are fine + const auto bbb = Replace(VALID, "", ""); + EXPECT_TRUE(Check(bbb).Ok()); +} + +TEST(CharacterXmlCheckTests, MissionsAndOwnership) { + ExpectError(Replace(VALID, "", ""); + const auto removed = CharacterXmlCheck::RemoveItems(withProxy, { 102 }); + EXPECT_EQ(removed.find("id=\"102\""), std::string::npos); + EXPECT_EQ(removed.find("id=\"103\""), std::string::npos); + EXPECT_NE(removed.find("id=\"101\""), std::string::npos); + EXPECT_TRUE(Check(removed).Ok()); +}