mirror of
https://github.com/DarkflameUniverse/DarkflameServer.git
synced 2026-10-02 19:03:43 +00:00
Every MASTER service packet is now an LUBitStream struct (docs/PacketArchitecture.md, PR 14) and the master server's switch is a dispatch map (PacketDispatcher), as are the master handlers of the world, chat and dashboard servers. - dNet/MasterPackets.h: RequestZoneTransfer, RequestZoneTransferResponse, ServerInfo, RequestSessionKey, SetSessionKey, SessionKeyResponse, NewSessionAlert, PlayerAdded / PlayerRemoved, CreatePrivateZone, RequestPrivateZone (passwords still cut to 50 characters when read), WorldReady, WorldReadyInfo (WORLD_READY to the dashboard), PrepZone, Shutdown, ShutdownResponse, WorldShutDown (SHUTDOWN_RESPONSE to the dashboard), ShutdownUniverse, AffirmTransferRequest/Response, RequestServerList, ServerListResponse, DashboardShutdown, ConfigReload, InstanceShutdown. The Send* functions are gone; MasterPackets::SendToMaster(msg) and SendTo(sysAddr, msg) send a struct. - The dashboard and instance migration structs (PlayerAction, DataChanged, Dashboard messages, MessageCapture, InstanceMigration) are LUBitStreams of the MASTER service now and moved to dNet/master/, included by MasterPackets.h. Their payloads are unchanged; master forwards them by re-serializing the struct instead of copying raw bytes. - InstanceManager, ZoneInstanceManager, MigrationCoordinator, dServer (server info, zone transfer response), auth (SET_SESSION_KEY), the world (session keys, player added and removed, world ready, shutdown response, affirmations, prep zone, shutdown universe) and the dashboard (server list, instance shutdown, config reload, announcements, player actions, message capture) send and read structs. - The login stamps are a `stamps` field of RequestZoneTransfer and RequestZoneTransferResponse (read leniently as before: a message without them reads as empty); master adds its stamps in the REQUEST_ZONE_TRANSFER handler and when it answers, as it did. - InstanceManager::GetInstanceBySysAddr takes a const address. Verified: tests/dGameTests/dNetTests/Legacy/MasterPacketsLegacy.h is a verbatim copy of the old writers and readers; MasterPacketsTests requires identical bytes for a grid of inputs, checks the old readers read what the structs write, round trips and truncation, hand written golden packets, that zone transfers without stamps still read, that the dashboard/migration structs write what "header + Serialize" wrote, and that the dispatcher drops truncated packets. No wire bytes changed. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
400 lines
23 KiB
C++
400 lines
23 KiB
C++
#include "CharacterTools.h"
|
|
|
|
#include <ctime>
|
|
|
|
#include "RouteUtils.h"
|
|
#include "CharacterXml.h"
|
|
#include "ObjectIDManager.h"
|
|
#include "Scheduler.h"
|
|
#include "Background.h"
|
|
#include "PlayerActions.h"
|
|
#include "master/PlayerAction.h"
|
|
#include "WSRoutes.h"
|
|
#include "ClientAssets.h"
|
|
#include "DashboardRoutes.h"
|
|
#include "tinyxml2.h"
|
|
#include "DashboardAuthService.h"
|
|
#include "CDClientDatabase.h"
|
|
#include "Database.h"
|
|
#include "Game.h"
|
|
#include "Logger.h"
|
|
#include "dConfig.h"
|
|
#include "GeneralUtils.h"
|
|
#include "ZCompression.h"
|
|
#include "eHTTPMethod.h"
|
|
#include "eServerDisconnectIdentifiers.h"
|
|
|
|
using namespace RouteUtils;
|
|
|
|
namespace {
|
|
constexpr int64_t DAY_SECONDS = 24 * 60 * 60;
|
|
|
|
int64_t Setting(const std::string& key, int64_t fallback) {
|
|
return GeneralUtils::TryParse<int64_t>(Game::config->GetValue(key)).value_or(fallback);
|
|
}
|
|
|
|
std::string Compress(const std::string& data) {
|
|
std::string out(ZCompression::GetMaxCompressedLength(static_cast<uint32_t>(data.size())), '\0');
|
|
const auto size = ZCompression::Compress(reinterpret_cast<const uint8_t*>(data.data()), static_cast<uint32_t>(data.size()),
|
|
reinterpret_cast<uint8_t*>(out.data()), static_cast<uint32_t>(out.size()));
|
|
out.resize(size > 0 ? static_cast<size_t>(size) : 0);
|
|
return out;
|
|
}
|
|
|
|
std::optional<std::string> Decompress(const ICharacterSnapshots::CharacterSnapshot& snapshot) {
|
|
std::string out(snapshot.size, '\0');
|
|
int32_t error = 0;
|
|
const auto size = ZCompression::Decompress(reinterpret_cast<const uint8_t*>(snapshot.compressed.data()), static_cast<uint32_t>(snapshot.compressed.size()),
|
|
reinterpret_cast<uint8_t*>(out.data()), snapshot.size, error);
|
|
if (size != static_cast<int32_t>(snapshot.size)) return std::nullopt;
|
|
return out;
|
|
}
|
|
|
|
ICharacterSnapshots::CharacterSnapshot MakeSnapshot(LWOOBJID characterId, const std::string& xml, const std::string& reason, const std::string& actor) {
|
|
return { 0, characterId, static_cast<int64_t>(std::time(nullptr)), reason, actor, static_cast<uint32_t>(xml.size()),
|
|
DashboardAuthService::Sha256Hex(xml), Compress(xml) };
|
|
}
|
|
|
|
bool LotExists(LOT lot) {
|
|
auto stmt = CDClientDatabase::CreatePreppedStmt("SELECT 1 FROM Objects WHERE id = ? LIMIT 1;");
|
|
stmt.bind(1, static_cast<int32_t>(lot));
|
|
return !stmt.execQuery().eof();
|
|
}
|
|
|
|
// Persistent object ids for items the editor adds, from the world servers' ObjectIDManager
|
|
int64_t NewItemId() {
|
|
return static_cast<int64_t>(ObjectIDManager::GetPersistentID());
|
|
}
|
|
|
|
// Add item names so the page can show them
|
|
void Name(nlohmann::json& summary) {
|
|
if (!summary.is_object()) return;
|
|
for (auto& inventory : summary["inventories"]) for (auto& item : inventory["items"]) item["name"] = ClientAssets::ItemName(item["lot"].get<LOT>());
|
|
}
|
|
|
|
nlohmann::json ZoneName(uint32_t zone) {
|
|
const auto& zones = ZoneNames();
|
|
const auto key = std::to_string(zone);
|
|
return zones.contains(key) ? zones[key] : nlohmann::json("Zone " + key);
|
|
}
|
|
|
|
nlohmann::json WithZones(nlohmann::json rows) {
|
|
for (auto& row : rows) row["zone_name"] = ZoneName(row.value("zone_id", 0u));
|
|
return rows;
|
|
}
|
|
|
|
nlohmann::json Transfers(const std::vector<LWOOBJID>& ids) {
|
|
auto rows = Database::Get()->GetTransfersForCharacters(ids, 0, 50)["data"];
|
|
for (auto& row : rows) if (row.value("lot", 0) > 0) row["name"] = ClientAssets::ItemName(row["lot"].get<LOT>());
|
|
return rows;
|
|
}
|
|
|
|
// Pets from the character's saved data: <pet><p id l (LOT) m (moderation: 2 approved) n (name)/></pet>
|
|
nlohmann::json Pets(const std::string& xml) {
|
|
nlohmann::json pets = nlohmann::json::array();
|
|
for (const auto& pet : CharacterXml::Pets(xml)) {
|
|
std::string name = pet.name;
|
|
// pet_names is the current word (the save only updates when the owner plays): 1 waiting, 2 approved,
|
|
// no row after a rejection
|
|
std::string status = "none", reason;
|
|
const auto current = Database::Get()->GetPetNameInfo(pet.id);
|
|
if (current) {
|
|
name = current->petName;
|
|
status = current->approvalStatus == 2 ? "approved" : "waiting";
|
|
} else if (!name.empty()) {
|
|
status = "rejected";
|
|
}
|
|
const auto decisions = Database::Get()->GetModerationDecisions("pet_name", pet.id, 1);
|
|
if (status == "rejected" && !decisions.empty() && !decisions[0]["approved"].get<bool>()) reason = decisions[0]["reason"];
|
|
pets.push_back({ {"id", std::to_string(pet.id)}, {"lot", pet.lot}, {"kind", ClientAssets::ItemName(pet.lot)},
|
|
{"name", name}, {"approved", status == "approved"}, {"status", status}, {"reason", reason} });
|
|
}
|
|
return pets;
|
|
}
|
|
|
|
// A character's name requests: the one waiting (if any), then earlier decisions with the moderator's reason
|
|
nlohmann::json NameRequests(LWOOBJID charId, const ICharInfo::Info& info) {
|
|
nlohmann::json rows = nlohmann::json::array();
|
|
if (!info.pendingName.empty()) rows.push_back({ {"character_id", std::to_string(charId)}, {"character_name", info.name}, {"name", info.pendingName}, {"status", "waiting"}, {"reason", ""}, {"time", 0} });
|
|
else if (info.needsRename) rows.push_back({ {"character_id", std::to_string(charId)}, {"character_name", info.name}, {"name", ""}, {"status", "rename_needed"}, {"reason", ""}, {"time", 0} });
|
|
for (const auto& d : Database::Get()->GetModerationDecisions("name", charId, 20)) {
|
|
rows.push_back({ {"character_id", std::to_string(charId)}, {"character_name", info.name}, {"name", d["subject"]},
|
|
{"status", d["approved"].get<bool>() ? "approved" : "rejected"}, {"reason", d["reason"]}, {"time", d["time"]} });
|
|
}
|
|
return rows;
|
|
}
|
|
|
|
// Recent chat by or to a character (or by an account), for the Related card; private chat only with chat_private
|
|
nlohmann::json RecentChat(const HTTPContext& context, LWOOBJID characterId, uint32_t accountId) {
|
|
IChatLog::ChatQuery q;
|
|
q.characterId = characterId;
|
|
q.accountId = accountId;
|
|
q.includePrivate = Can(context, "chat_private");
|
|
q.newestFirst = true;
|
|
q.limit = 100;
|
|
nlohmann::json rows = nlohmann::json::array();
|
|
for (const auto& m : Database::Get()->GetChatMessages(q)) {
|
|
rows.push_back({ {"id", m.id}, {"time", m.time}, {"channel", m.channel}, {"sender_id", std::to_string(m.senderId)}, {"sender_name", m.senderName},
|
|
{"recipient_id", std::to_string(m.recipientId)}, {"recipient_name", m.recipientName}, {"zone_id", m.zoneId}, {"message", m.message}, {"blocked", m.blocked} });
|
|
}
|
|
return rows;
|
|
}
|
|
|
|
std::optional<LWOOBJID> SnapshotCharacter(const HTTPContext& context, uint64_t snapshotId, HTTPReply& reply, std::optional<ICharacterSnapshots::CharacterSnapshot>& out) {
|
|
out = Database::Get()->GetCharacterSnapshot(snapshotId);
|
|
if (!out) { JsonError(reply, eHTTPStatusCode::NOT_FOUND, "Snapshot not found"); return std::nullopt; }
|
|
return out->characterId;
|
|
}
|
|
|
|
void TakeDailySnapshots(Scheduler::RunPtr run) {
|
|
const auto keep = static_cast<uint32_t>(std::max<int64_t>(Setting("snapshot_keep", 10), 1));
|
|
const auto days = std::max<int64_t>(Setting("snapshot_days", 90), 1);
|
|
const bool queued = Background::Run("character_snapshots", [keep, days](GameDatabase& db) -> nlohmann::json {
|
|
const auto latest = db.GetLatestSnapshotHashes();
|
|
uint32_t taken = 0, unchanged = 0;
|
|
db.ForEachCharacterXml([&](LWOOBJID characterId, const std::string& xml) {
|
|
if (xml.empty()) return;
|
|
const auto hash = DashboardAuthService::Sha256Hex(xml);
|
|
const auto it = latest.find(characterId);
|
|
if (it != latest.end() && it->second == hash) { unchanged++; return; }
|
|
auto snapshot = MakeSnapshot(characterId, xml, "daily", "");
|
|
snapshot.hash = hash;
|
|
db.InsertCharacterSnapshot(snapshot);
|
|
taken++;
|
|
});
|
|
const auto pruned = db.PruneCharacterSnapshots(static_cast<int64_t>(std::time(nullptr)) - days * DAY_SECONDS, keep);
|
|
return { {"taken", taken}, {"unchanged", unchanged}, {"pruned", pruned} };
|
|
}, [run](nlohmann::json result, const std::string& error) {
|
|
if (!error.empty()) return run->Finish(false, "Failed: " + error);
|
|
run->Log(std::to_string(result["unchanged"].get<uint32_t>()) + " characters unchanged since their last snapshot");
|
|
run->Finish(true, std::to_string(result["taken"].get<uint32_t>()) + " snapshot(s) taken, " + std::to_string(result["pruned"].get<uint32_t>()) + " old one(s) removed");
|
|
});
|
|
if (!queued) run->Finish(false, "Snapshots are already being taken");
|
|
}
|
|
}
|
|
|
|
namespace {
|
|
// Once the owner is disconnected: apply the change, keep a snapshot of the version before it, and audit it
|
|
PlayerActions::Outcome Write(LWOOBJID characterId, const std::string& actor, uint32_t actorId, const std::string& reason, const PlayerActionResult& result,
|
|
const std::function<std::optional<std::string>(const std::string& current, std::string& error)>& change, const std::function<std::string(const std::string& before, const std::string& after)>& describe) {
|
|
if (result.timedOut && result.affected == 0) {
|
|
return PlayerActions::Outcome{ false, "Not every world server responded, so the character may still be online. Nothing was changed; try again." };
|
|
}
|
|
const auto current = Database::Get()->GetCharacterXml(characterId);
|
|
std::string error;
|
|
const auto updated = change(current, error);
|
|
if (!updated) return PlayerActions::Outcome{ false, error.empty() ? "Nothing to change" : error };
|
|
if (!current.empty()) Database::Get()->InsertCharacterSnapshot(MakeSnapshot(characterId, current, "before " + reason, actor));
|
|
Database::Get()->UpdateCharacterXml(characterId, *updated);
|
|
const auto info = Database::Get()->GetCharacterInfo(characterId);
|
|
const auto name = info ? info->name : std::to_string(characterId);
|
|
const auto what = describe ? describe(current, *updated) : reason;
|
|
Database::Get()->InsertAuditLog(actorId, actor, "edit_character", name + ": " + what + (info ? OwnAccountNote(actorId, info->accountId) : ""), info ? info->accountId : 0, characterId);
|
|
BroadcastTableChanged("characters", std::to_string(characterId));
|
|
return PlayerActions::Outcome{ true, "Saved " + name + (result.affected ? " (they were disconnected)" : "") + ". The previous version is in the history." };
|
|
}
|
|
}
|
|
|
|
uint32_t WriteCharacterXml(LWOOBJID characterId, uint32_t ownerAccountId, const std::string& actor, uint32_t actorId, const std::string& reason,
|
|
std::function<std::optional<std::string>(const std::string& current, std::string& error)> change, std::function<std::string(const std::string& before, const std::string& after)> describe,
|
|
std::function<void(const PlayerActions::Outcome& outcome)> done) {
|
|
// Disconnect the owner first so their world saves and lets go of the character; otherwise the world's next save
|
|
// would overwrite this change
|
|
PlayerActionRequest request;
|
|
request.action = ePlayerAction::KICK_ACCOUNT;
|
|
request.accountId = ownerAccountId;
|
|
request.disconnectReason = static_cast<uint32_t>(eServerDisconnectIdentifiers::KICK);
|
|
return PlayerActions::Request(request, actorId, [=](const PlayerActionResult& result) {
|
|
const auto outcome = Write(characterId, actor, actorId, reason, result, change, describe);
|
|
if (done) done(outcome);
|
|
return outcome;
|
|
});
|
|
}
|
|
|
|
std::optional<std::string> SnapshotXml(const ICharacterSnapshots::CharacterSnapshot& snapshot) {
|
|
return Decompress(snapshot);
|
|
}
|
|
|
|
void RegisterCharacterTasks() {
|
|
Scheduler::Register({ "character_snapshots", "Character snapshots",
|
|
"Saves a copy of every character whose data changed since its last snapshot, so lost or broken characters can be "
|
|
"restored. Keeps snapshots for snapshot_days, and always each character's newest snapshot_keep.",
|
|
"0 4 * * *", TakeDailySnapshots, 6 * 60 * 60 });
|
|
}
|
|
|
|
void RegisterCharacterToolRoutes() {
|
|
Route(eHTTPMethod::GET, "/api/characters/:id/summary", 0, "Coins, U-score, level and inventories from a character's saved data (with item names)",
|
|
[](HTTPReply& reply, const HTTPContext& context) {
|
|
const auto charId = PathId<LWOOBJID>(context.path, 2);
|
|
const auto info = charId ? Database::Get()->GetCharacterInfo(*charId) : std::nullopt;
|
|
if (!info) return JsonError(reply, eHTTPStatusCode::NOT_FOUND, "Character not found");
|
|
if (!CanViewCharacter(context, info->accountId)) return JsonError(reply, eHTTPStatusCode::FORBIDDEN, "You may only view your own characters");
|
|
auto summary = CharacterXml::Summary(Database::Get()->GetCharacterXml(*charId));
|
|
if (summary.is_null()) return JsonError(reply, eHTTPStatusCode::NOT_FOUND, "This character has no readable saved data");
|
|
Name(summary);
|
|
JsonSuccess(reply, { {"summary", summary} });
|
|
});
|
|
|
|
Route(eHTTPMethod::GET, "/api/characters/:id/related", 0,
|
|
"Everything tied to a character: properties, pets, friends, trades and mail, bug reports and economy flags (each only with the permission to see it)",
|
|
[](HTTPReply& reply, const HTTPContext& context) {
|
|
const auto charId = PathId<LWOOBJID>(context.path, 2);
|
|
const auto info = charId ? Database::Get()->GetCharacterInfo(*charId) : std::nullopt;
|
|
if (!info) return JsonError(reply, eHTTPStatusCode::NOT_FOUND, "Character not found");
|
|
if (!CanViewCharacter(context, info->accountId)) return JsonError(reply, eHTTPStatusCode::FORBIDDEN, "You may only view your own characters");
|
|
const bool own = info->accountId == context.accountId;
|
|
nlohmann::json related = nlohmann::json::object();
|
|
if ((own && Can(context, "own_properties")) || Can(context, "properties_view")) related["properties"] = WithZones(Database::Get()->GetPropertiesOwnedBy(*charId));
|
|
related["pets"] = Pets(Database::Get()->GetCharacterXml(*charId));
|
|
if ((own && Can(context, "own_characters")) || Can(context, "moderate_names")) related["names"] = NameRequests(*charId, *info);
|
|
related["friends"] = Database::Get()->GetFriendsOf(*charId);
|
|
if ((own && Can(context, "own_history")) || Can(context, "reports_view")) related["transfers"] = Transfers({ *charId });
|
|
if (Can(context, "bug_reports_view")) related["bug_reports"] = Database::Get()->GetBugReportsBy(*charId, 50);
|
|
if (Can(context, "reports_view")) related["flags"] = Database::Get()->GetEconomyFlagsFor(*charId, 50);
|
|
if (Can(context, "chat_view")) related["chat"] = RecentChat(context, *charId, 0);
|
|
related["logs"] = { {"activity", Can(context, "logs_activity")}, {"commands", Can(context, "logs_command")} };
|
|
JsonSuccess(reply, { {"related", related} });
|
|
});
|
|
|
|
Route(eHTTPMethod::GET, "/api/accounts/:id/related", 0,
|
|
"Everything tied to an account: its characters' properties, trades and mail, bug reports and economy flags, cheat detections and "
|
|
"dashboard actions about it (each only with the permission to see it)",
|
|
[](HTTPReply& reply, const HTTPContext& context) {
|
|
const auto accountId = PathId<uint32_t>(context.path, 2);
|
|
if (!accountId) return JsonError(reply, eHTTPStatusCode::BAD_REQUEST, "Invalid ID");
|
|
const bool own = *accountId == context.accountId;
|
|
if (!own && !Can(context, "accounts_view")) return JsonError(reply, eHTTPStatusCode::FORBIDDEN, "You may only view your own account");
|
|
const auto ids = Database::Get()->GetAccountCharacterIds(*accountId);
|
|
std::map<LWOOBJID, std::string> names;
|
|
std::map<LWOOBJID, ICharInfo::Info> infos;
|
|
for (const auto id : ids) if (const auto info = Database::Get()->GetCharacterInfo(id)) { names[id] = info->name; infos[id] = *info; }
|
|
nlohmann::json related = nlohmann::json::object();
|
|
nlohmann::json pets = nlohmann::json::array();
|
|
for (const auto id : ids) for (auto row : Pets(Database::Get()->GetCharacterXml(id))) { row["owner_id"] = std::to_string(id); row["owner_name"] = names[id]; pets.push_back(std::move(row)); }
|
|
if ((own && Can(context, "own_characters")) || Can(context, "characters_view")) related["pets"] = pets;
|
|
if ((own && Can(context, "own_characters")) || Can(context, "moderate_names")) {
|
|
nlohmann::json requests = nlohmann::json::array();
|
|
for (const auto& [id, info] : infos) for (auto row : NameRequests(id, info)) requests.push_back(std::move(row));
|
|
related["names"] = requests;
|
|
}
|
|
if ((own && Can(context, "own_properties")) || Can(context, "properties_view")) {
|
|
nlohmann::json properties = nlohmann::json::array();
|
|
for (const auto id : ids) for (auto row : WithZones(Database::Get()->GetPropertiesOwnedBy(id))) {
|
|
row["owner_id"] = std::to_string(id);
|
|
row["owner_name"] = names[id];
|
|
properties.push_back(std::move(row));
|
|
}
|
|
related["properties"] = properties;
|
|
}
|
|
if ((own && Can(context, "own_history")) || Can(context, "reports_view")) related["transfers"] = Transfers(ids);
|
|
if (Can(context, "bug_reports_view")) {
|
|
nlohmann::json reports = nlohmann::json::array();
|
|
for (const auto id : ids) for (auto row : Database::Get()->GetBugReportsBy(id, 50)) { row["character_id"] = std::to_string(id); row["character_name"] = names[id]; reports.push_back(std::move(row)); }
|
|
related["bug_reports"] = reports;
|
|
}
|
|
if (Can(context, "reports_view")) {
|
|
nlohmann::json flags = nlohmann::json::array();
|
|
for (const auto id : ids) for (auto row : Database::Get()->GetEconomyFlagsFor(id, 50)) { row["character_id"] = std::to_string(id); row["character_name"] = names[id]; flags.push_back(std::move(row)); }
|
|
related["flags"] = flags;
|
|
}
|
|
if (Can(context, "chat_view")) related["chat"] = RecentChat(context, 0, *accountId);
|
|
if (Can(context, "accounts_notes")) related["cheats"] = Database::Get()->GetCheatDetectionsFor(*accountId, 100);
|
|
if (Can(context, "logs_audit")) related["audit"] = Database::Get()->GetAuditAbout(*accountId, 100);
|
|
JsonSuccess(reply, { {"related", related} });
|
|
});
|
|
|
|
Route(eHTTPMethod::POST, "/api/characters/:id/edit", Perm("characters_edit"),
|
|
"Edit a character (the owner is disconnected first, and a snapshot is kept). Body: {coins, uscore, level, counts: {itemId: count}, "
|
|
"remove: [itemId], add: [{lot, count, inventory}]}. Returns {requestId}",
|
|
[](HTTPReply& reply, const HTTPContext& context) {
|
|
const auto charId = PathId<LWOOBJID>(context.path, 2);
|
|
const auto body = ParseBody(context);
|
|
const auto info = charId ? Database::Get()->GetCharacterInfo(*charId) : std::nullopt;
|
|
if (!info) return JsonError(reply, eHTTPStatusCode::NOT_FOUND, "Character not found");
|
|
if (!body || !body->is_object()) return JsonError(reply, eHTTPStatusCode::BAD_REQUEST, "Invalid JSON");
|
|
if (!AuthorizeAccountAction(context, info->accountId, reply, eAccountAction::ITEMS)) return;
|
|
if (body->contains("add") && (*body)["add"].is_array()) {
|
|
for (const auto& add : (*body)["add"]) {
|
|
if (!LotExists(add.value("lot", 0))) return JsonError(reply, eHTTPStatusCode::BAD_REQUEST, "Unknown item LOT " + std::to_string(add.value("lot", 0)));
|
|
}
|
|
}
|
|
// Check the edit against the current data now, so mistakes are reported straight away
|
|
std::string error;
|
|
if (!CharacterXml::Apply(Database::Get()->GetCharacterXml(*charId), *body, [] { return int64_t{ 1 }; }, error)) return JsonError(reply, eHTTPStatusCode::BAD_REQUEST, error);
|
|
const auto edit = *body;
|
|
const auto requestId = WriteCharacterXml(*charId, info->accountId, context.authenticatedUser, context.accountId, "edit",
|
|
[edit](const std::string& current, std::string& error) { return CharacterXml::Apply(current, edit, NewItemId, error); },
|
|
[edit](const std::string&, const std::string&) {
|
|
std::vector<std::string> parts;
|
|
for (const auto* key : { "coins", "uscore", "level" }) if (edit.contains(key) && !edit[key].is_null()) parts.push_back(std::string(key) + " = " + edit[key].dump());
|
|
if (edit.contains("counts")) parts.push_back(std::to_string(edit["counts"].size()) + " count(s) changed");
|
|
if (edit.contains("remove")) parts.push_back(std::to_string(edit["remove"].size()) + " item(s) removed");
|
|
if (edit.contains("add")) for (const auto& add : edit["add"]) parts.push_back("added " + std::to_string(add.value("count", 1)) + "x " + ClientAssets::ItemName(add.value("lot", 0)));
|
|
std::string text;
|
|
for (const auto& part : parts) text += (text.empty() ? "" : ", ") + part;
|
|
return text.empty() ? std::string("no changes") : text;
|
|
});
|
|
JsonSuccess(reply, { {"requestId", requestId} });
|
|
});
|
|
|
|
Route(eHTTPMethod::GET, "/api/characters/:id/snapshots", Perm("characters_history"), "Saved earlier versions of a character, newest first",
|
|
[](HTTPReply& reply, const HTTPContext& context) {
|
|
const auto charId = PathId<LWOOBJID>(context.path, 2);
|
|
if (!charId) return JsonError(reply, eHTTPStatusCode::BAD_REQUEST, "Invalid ID");
|
|
nlohmann::json list = nlohmann::json::array();
|
|
for (const auto& s : Database::Get()->GetCharacterSnapshots(*charId)) {
|
|
list.push_back({ {"id", s.id}, {"taken_at", s.takenAt}, {"reason", s.reason}, {"actor", s.actor}, {"size", s.size} });
|
|
}
|
|
JsonSuccess(reply, { {"snapshots", list} });
|
|
});
|
|
|
|
Route(eHTTPMethod::GET, "/api/snapshots/:id", Perm("characters_history"), "One snapshot's summary (coins, level, inventories), to compare with the character now",
|
|
[](HTTPReply& reply, const HTTPContext& context) {
|
|
const auto id = PathId<uint64_t>(context.path, 2);
|
|
std::optional<ICharacterSnapshots::CharacterSnapshot> snapshot;
|
|
if (!id || !SnapshotCharacter(context, *id, reply, snapshot)) return;
|
|
const auto xml = Decompress(*snapshot);
|
|
auto summary = xml ? CharacterXml::Summary(*xml) : nullptr;
|
|
if (summary.is_null()) return JsonError(reply, eHTTPStatusCode::INTERNAL_SERVER_ERROR, "This snapshot can't be read");
|
|
Name(summary);
|
|
JsonSuccess(reply, { {"summary", summary}, {"taken_at", snapshot->takenAt}, {"reason", snapshot->reason}, {"character_id", std::to_string(snapshot->characterId)} });
|
|
});
|
|
|
|
Route(eHTTPMethod::GET, "/api/snapshots/:id/xml", Perm("characters_history"), "Download a snapshot's character XML",
|
|
[](HTTPReply& reply, const HTTPContext& context) {
|
|
const auto id = PathId<uint64_t>(context.path, 2);
|
|
std::optional<ICharacterSnapshots::CharacterSnapshot> snapshot;
|
|
if (!id || !SnapshotCharacter(context, *id, reply, snapshot)) return;
|
|
const auto xml = Decompress(*snapshot);
|
|
if (!xml) return JsonError(reply, eHTTPStatusCode::INTERNAL_SERVER_ERROR, "This snapshot can't be read");
|
|
reply.status = eHTTPStatusCode::OK;
|
|
reply.contentType = eContentType::APPLICATION_OCTET_STREAM;
|
|
reply.message = *xml;
|
|
reply.headers.push_back("Content-Disposition: attachment; filename=\"character-" + std::to_string(snapshot->characterId) + "-" + std::to_string(snapshot->id) + ".xml\"");
|
|
});
|
|
|
|
Route(eHTTPMethod::POST, "/api/snapshots/:id/restore", Perm("characters_edit"),
|
|
"Put a character back to a snapshot (the owner is disconnected first; the current version is kept as a snapshot). Returns {requestId}",
|
|
[](HTTPReply& reply, const HTTPContext& context) {
|
|
const auto id = PathId<uint64_t>(context.path, 2);
|
|
std::optional<ICharacterSnapshots::CharacterSnapshot> snapshot;
|
|
if (!id || !SnapshotCharacter(context, *id, reply, snapshot)) return;
|
|
const auto info = Database::Get()->GetCharacterInfo(snapshot->characterId);
|
|
if (!info) return JsonError(reply, eHTTPStatusCode::NOT_FOUND, "That character no longer exists");
|
|
if (!AuthorizeAccountAction(context, info->accountId, reply, eAccountAction::ITEMS)) return;
|
|
const auto xml = Decompress(*snapshot);
|
|
if (!xml) return JsonError(reply, eHTTPStatusCode::INTERNAL_SERVER_ERROR, "This snapshot can't be read");
|
|
const auto takenAt = snapshot->takenAt;
|
|
const auto restored = *xml;
|
|
const auto requestId = WriteCharacterXml(snapshot->characterId, info->accountId, context.authenticatedUser, context.accountId, "restore",
|
|
[restored](const std::string&, std::string&) { return std::optional<std::string>(restored); },
|
|
[takenAt](const std::string&, const std::string&) {
|
|
char when[32];
|
|
const std::time_t time = takenAt;
|
|
std::strftime(when, sizeof(when), "%Y-%m-%d %H:%M UTC", std::gmtime(&time));
|
|
return std::string("restored the snapshot from ") + when;
|
|
});
|
|
JsonSuccess(reply, { {"requestId", requestId} });
|
|
});
|
|
}
|